From b5fb123bf2f52f6a4c995b6e3c92285345fb0149 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 19:57:10 +0200 Subject: [PATCH 001/142] Added: the NES register model for the player --- pyproject.toml | 4 + src/sampletones_core/constants/general.py | 6 +- src/sampletones_core/timers/__init__.py | 7 +- src/sampletones_core/timers/arithmetic.py | 51 ++++ .../timers/implementation/phase.py | 19 +- src/sampletones_core/timers/utils.py | 16 ++ src/sampletones_player/__init__.py | 0 src/sampletones_player/registers.py | 194 ++++++++++++++ .../specification/__init__.py | 0 .../specification/registers.py | 40 +++ .../timers/implementation/test_phase.py | 42 --- .../timers/test_arithmetic.py | 98 +++++++ tests/unit/sampletones_player/__init__.py | 0 .../unit/sampletones_player/test_registers.py | 247 ++++++++++++++++++ 14 files changed, 664 insertions(+), 60 deletions(-) create mode 100644 src/sampletones_core/timers/arithmetic.py create mode 100644 src/sampletones_player/__init__.py create mode 100644 src/sampletones_player/registers.py create mode 100644 src/sampletones_player/specification/__init__.py create mode 100644 src/sampletones_player/specification/registers.py create mode 100644 tests/unit/sampletones_core/timers/test_arithmetic.py create mode 100644 tests/unit/sampletones_player/__init__.py create mode 100644 tests/unit/sampletones_player/test_registers.py diff --git a/pyproject.toml b/pyproject.toml index ac47fab5a..024f48a5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ packages = [ "src/sampletones_assets", "src/sampletones_config", "src/sampletones_core", + "src/sampletones_player", "src/sampletones_shared", "src/sampletones_synthesis", ] @@ -121,6 +122,7 @@ known_first_party = [ "sampletones_assets", "sampletones_config", "sampletones_core", + "sampletones_player", "sampletones_shared", "sampletones_synthesis", ] @@ -133,6 +135,7 @@ source = [ "sampletones_application", "sampletones_assets", "sampletones_core", + "sampletones_player", "sampletones_shared", "sampletones_synthesis", ] @@ -148,6 +151,7 @@ files = [ "src/sampletones_application", "src/sampletones_assets", "src/sampletones_core", + "src/sampletones_player", "src/sampletones_shared", "src/sampletones_synthesis", ] diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py index aca1463b8..cda29fc14 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -3,14 +3,16 @@ # Pitches and frequencies APU_CLOCK: Final[float] = 1789773.0 +TIMER_CYCLE_DIVIDER: Final[int] = 16 +MAX_TIMER: Final[int] = 0x7FF LIMIT_MIN_PITCH: Final[int] = 24 MIN_PITCH: Final[int] = 33 MAX_PITCH: Final[int] = 119 LIMIT_MAX_PITCH: Final[int] = 127 PITCH_RANGE: Final[int] = MAX_PITCH - MIN_PITCH -MIN_FREQUENCY: Final[float] = APU_CLOCK / 0x8000 -MAX_FREQUENCY: Final[float] = APU_CLOCK / 0x10 +MIN_FREQUENCY: Final[float] = APU_CLOCK / (TIMER_CYCLE_DIVIDER * (MAX_TIMER + 1)) +MAX_FREQUENCY: Final[float] = APU_CLOCK / TIMER_CYCLE_DIVIDER A4_FREQUENCY: Final[float] = 440.0 A4_PITCH: Final[int] = 69 diff --git a/src/sampletones_core/timers/__init__.py b/src/sampletones_core/timers/__init__.py index f5e56c4c9..f4d684947 100644 --- a/src/sampletones_core/timers/__init__.py +++ b/src/sampletones_core/timers/__init__.py @@ -1,8 +1,9 @@ +from .arithmetic import frequency_to_timer, get_timer_ticks, timer_to_frequency from .implementation.lfsr import LFSRTimer from .implementation.phase import PhaseTimer from .timer import Timer from .types import TimerT, TimerTypeUnion, TimerUnion -from .utils import get_frequency_table +from .utils import get_frequency_table, get_timer_table __all__ = [ "LFSRTimer", @@ -11,5 +12,9 @@ "TimerT", "TimerTypeUnion", "TimerUnion", + "frequency_to_timer", "get_frequency_table", + "get_timer_table", + "get_timer_ticks", + "timer_to_frequency", ] diff --git a/src/sampletones_core/timers/arithmetic.py b/src/sampletones_core/timers/arithmetic.py new file mode 100644 index 000000000..0d0da768d --- /dev/null +++ b/src/sampletones_core/timers/arithmetic.py @@ -0,0 +1,51 @@ +from sampletones_core.constants.general import APU_CLOCK, MAX_TIMER, TIMER_CYCLE_DIVIDER + + +def frequency_to_timer(frequency: float) -> int: + """The timer register value a channel sounds a frequency at. + + The APU drives a channel's waveform by dividing its clock by ``16 * (timer + 1)``, so the + register value is that relation solved for the timer and rounded to the nearest whole + period. The result stays within the 11 bits the register offers, which is what holds the + pitches a channel reaches between ``MIN_FREQUENCY`` and ``MAX_FREQUENCY``. + + Args: + frequency: The frequency in Hz to sound. + + Returns: + int: The timer value, in ``[0, MAX_TIMER]``. A frequency of 0 Hz or below reads as 0. + """ + if frequency <= 0: + return 0 + + timer = round(APU_CLOCK / (TIMER_CYCLE_DIVIDER * frequency)) - 1 + return max(0, min(timer, MAX_TIMER)) + + +def get_timer_ticks(timer: int) -> int: + """The APU cycles one waveform period spans at a timer value. + + Args: + timer: The timer register value. + + Returns: + int: The cycles per period, ``16 * (timer + 1)``. A timer of 0 or below reads as 0, + the span a silent channel covers. + """ + return (timer + 1) * TIMER_CYCLE_DIVIDER if timer > 0 else 0 + + +def timer_to_frequency(timer: int) -> float: + """The frequency a channel sounds at a timer register value. + + This is the inverse of `frequency_to_timer`, and reading a timer back through it gives the + frequency the hardware actually produces — the nearest one the divider reaches, which a + rendered channel is tuned to. + + Args: + timer: The timer register value. + + Returns: + float: The frequency in Hz the divider produces for that timer. + """ + return APU_CLOCK / (TIMER_CYCLE_DIVIDER * (timer + 1)) diff --git a/src/sampletones_core/timers/implementation/phase.py b/src/sampletones_core/timers/implementation/phase.py index 8d6e2f422..1c54140b5 100644 --- a/src/sampletones_core/timers/implementation/phase.py +++ b/src/sampletones_core/timers/implementation/phase.py @@ -6,6 +6,7 @@ from sampletones_core.constants.general import APU_CLOCK from sampletones_shared.types.data import Initials +from ..arithmetic import frequency_to_timer, get_timer_ticks, timer_to_frequency from ..timer import Timer @@ -64,20 +65,8 @@ def generate_frame(self, save: bool = True) -> np.ndarray: def initials(self) -> Tuple[Any, ...]: return (self.phase,) - @staticmethod - def frequency_to_timer(frequency: float) -> int: - if frequency <= 0: - return 0 - - timer = round(APU_CLOCK / (16 * frequency)) - 1 - return max(0, min(timer, 0x7FF)) - - @staticmethod - def get_timer_ticks(timer: int) -> int: - return (timer + 1) * 16 if timer > 0 else 0 - def round_frequency_by_timer(self) -> None: - self._frequency = APU_CLOCK / (16 * (self._timer + 1)) + self._frequency = timer_to_frequency(self._timer) @property def frequency(self) -> float: @@ -86,8 +75,8 @@ def frequency(self) -> float: @frequency.setter def frequency(self, value: float) -> None: self._frequency = value - self._timer = self.frequency_to_timer(value) - self._timer_ticks = self.get_timer_ticks(self._timer) + self._timer = frequency_to_timer(value) + self._timer_ticks = get_timer_ticks(self._timer) self.round_frequency_by_timer() self._real_frequency = self.frequency * self.phase_increment diff --git a/src/sampletones_core/timers/utils.py b/src/sampletones_core/timers/utils.py index 2aed934a7..1e4f46f83 100644 --- a/src/sampletones_core/timers/utils.py +++ b/src/sampletones_core/timers/utils.py @@ -3,6 +3,7 @@ from sampletones_core.configs import Config from sampletones_core.utils.frequencies import pitch_to_frequency +from .arithmetic import frequency_to_timer from .implementation.phase import PhaseTimer @@ -23,3 +24,18 @@ def get_frequency_table(config: Config) -> Dict[int, float]: frequencies[note] = timer.frequency return frequencies + + +def get_timer_table(config: Config) -> Dict[int, int]: + """The timer register value each pitch sounds at. + + States the frequency table the generators render from in the terms the hardware takes, so + a channel driven by these values sounds the pitch a reconstruction was built against. + + Args: + config: The configuration the reconstruction was built with. + + Returns: + Dict[int, int]: The timer value for every pitch the configuration covers. + """ + return {pitch: frequency_to_timer(frequency) for pitch, frequency in get_frequency_table(config).items()} diff --git a/src/sampletones_player/__init__.py b/src/sampletones_player/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/registers.py b/src/sampletones_player/registers.py new file mode 100644 index 000000000..73422dce4 --- /dev/null +++ b/src/sampletones_player/registers.py @@ -0,0 +1,194 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.general import MAX_PERIOD +from sampletones_core.exporters.implementation.noise import NoiseExporter +from sampletones_core.exporters.implementation.pulse import PulseExporter +from sampletones_core.exporters.implementation.triangle import TriangleExporter +from sampletones_core.instructions import ( + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) + +from .specification.registers import ( + DUTY_CYCLE_SHIFT, + MAX_REGISTER_VALUE, + MAX_TIMER_HIGH, + NOISE_MODE_SHIFT, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, +) + + +class ChannelRegisters(BaseModel, ABC): + """The register values one channel writes for a single engine tick. + + The driver interprets nothing: it moves these bytes to the addresses its channel owns. + Every rule the hardware follows — how a duty cycle reaches its bits, which value silences + a channel, how a pitch becomes a period — is settled here, where it is testable. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + @property + @abstractmethod + def values(self) -> Tuple[int, ...]: + """The tick's register values, in the order the driver writes them. + + Returns: + Tuple[int, ...]: One value per register the channel writes each tick. + """ + + +class PulseRegisters(ChannelRegisters): + control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) + + @property + def values(self) -> Tuple[int, ...]: + return (self.control, self.timer_low, self.timer_high) + + +class TriangleRegisters(ChannelRegisters): + linear_counter: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) + + @property + def values(self) -> Tuple[int, ...]: + return (self.linear_counter, self.timer_low, self.timer_high) + + +class NoiseRegisters(ChannelRegisters): + control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + period: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + @property + def values(self) -> Tuple[int, ...]: + return (self.control, self.period) + + +def hold(values: List[int], index: int) -> int: + """Reads a held stream at a tick, sustaining its final value over the release tick. + + An exporter states a channel's pitch and timbre for every tick its instructions cover, and + appends one silent tick past them so a sample ends quiet. That release tick reads the values + the channel was holding when it stopped sounding. + + Args: + values: The held stream, covering at least one tick. + index: The tick to read. + + Returns: + int: The value at that tick, or the stream's final value once the index reaches its end. + """ + return values[min(index, len(values) - 1)] + + +def encode_pulse( + instructions: List[PulseInstruction], + timer_table: Dict[int, int], +) -> List[PulseRegisters]: + """Turns a pulse channel's instructions into the registers each tick writes. + + Volume rides in the control byte's low nibble, so a rest keeps its pitch and duty cycle and + sets the level to zero. Holding the period across a rest is what lets the driver leave the + timer's high byte alone, and leaving it alone is what keeps the waveform's phase running the + way a rendered channel does. + + Args: + instructions: The channel's per-tick instructions. + timer_table: The timer register value each pitch sounds at. + + Returns: + List[PulseRegisters]: One register set per tick, including the closing release tick. + """ + _, pitches, volumes, duty_cycles = PulseExporter.extract_data(instructions) + + registers: List[PulseRegisters] = [] + for index, volume in enumerate(volumes): + timer = timer_table[hold(pitches, index)] + duty_cycle = hold(duty_cycles, index) + registers.append( + PulseRegisters( + control=(duty_cycle << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | volume, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + ) + ) + + return registers + + +def encode_triangle( + instructions: List[TriangleInstruction], + timer_table: Dict[int, int], +) -> List[TriangleRegisters]: + """Turns a triangle channel's instructions into the registers each tick writes. + + The triangle sounds at one level, so a tick states whether it sounds through the linear + counter's reload value: a full reload keeps the waveform running, and a reload of zero + holds it silent. The control bit stays set throughout, which is what makes the counter + reload every frame and the note last as long as the ticks do. + + The timer is written from the instruction's pitch directly, and the channel sounds an + octave below it — the same octave a rendered triangle sounds. + + Args: + instructions: The channel's per-tick instructions. + timer_table: The timer register value each pitch sounds at. + + Returns: + List[TriangleRegisters]: One register set per tick, including the closing release tick. + """ + _, pitches, volumes = TriangleExporter.extract_data(instructions) + + registers: List[TriangleRegisters] = [] + for index, volume in enumerate(volumes): + timer = timer_table[hold(pitches, index)] + reload_value = TRIANGLE_SOUNDING_RELOAD if volume > 0 else TRIANGLE_SILENT_RELOAD + registers.append( + TriangleRegisters( + linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + ) + ) + + return registers + + +def encode_noise(instructions: List[NoiseInstruction]) -> List[NoiseRegisters]: + """Turns a noise channel's instructions into the registers each tick writes. + + The project counts noise periods from the slowest, and the register counts them from the + fastest, so a period reaches the register as its complement. The mode bit rides above it, + selecting the shift register's short 93-step cycle. + + Args: + instructions: The channel's per-tick instructions. + + Returns: + List[NoiseRegisters]: One register set per tick, including the closing release tick. + """ + _, periods, volumes, modes = NoiseExporter.extract_data(instructions) + + registers: List[NoiseRegisters] = [] + for index, volume in enumerate(volumes): + period = hold(periods, index) + mode = hold(modes, index) + registers.append( + NoiseRegisters( + control=SUSTAINED_LEVEL | volume, + period=(mode << NOISE_MODE_SHIFT) | (MAX_PERIOD - period), + ) + ) + + return registers diff --git a/src/sampletones_player/specification/__init__.py b/src/sampletones_player/specification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/specification/registers.py b/src/sampletones_player/specification/registers.py new file mode 100644 index 000000000..06ecaab92 --- /dev/null +++ b/src/sampletones_player/specification/registers.py @@ -0,0 +1,40 @@ +from typing import Final + +from sampletones_core.constants.general import MAX_TIMER + +PULSE1_CONTROL: Final[int] = 0x4000 +PULSE1_SWEEP: Final[int] = 0x4001 +PULSE1_TIMER_LOW: Final[int] = 0x4002 +PULSE1_TIMER_HIGH: Final[int] = 0x4003 +PULSE2_CONTROL: Final[int] = 0x4004 +PULSE2_SWEEP: Final[int] = 0x4005 +PULSE2_TIMER_LOW: Final[int] = 0x4006 +PULSE2_TIMER_HIGH: Final[int] = 0x4007 +TRIANGLE_LINEAR_COUNTER: Final[int] = 0x4008 +TRIANGLE_TIMER_LOW: Final[int] = 0x400A +TRIANGLE_TIMER_HIGH: Final[int] = 0x400B +NOISE_CONTROL: Final[int] = 0x400C +NOISE_PERIOD: Final[int] = 0x400E +NOISE_LENGTH_COUNTER: Final[int] = 0x400F +APU_STATUS: Final[int] = 0x4015 +APU_FRAME_COUNTER: Final[int] = 0x4017 + +MAX_REGISTER_VALUE: Final[int] = 0xFF +TIMER_HIGH_SHIFT: Final[int] = 8 +MAX_TIMER_HIGH: Final[int] = MAX_TIMER >> TIMER_HIGH_SHIFT + +LENGTH_COUNTER_HALT: Final[int] = 0x20 +CONSTANT_VOLUME: Final[int] = 0x10 +SUSTAINED_LEVEL: Final[int] = LENGTH_COUNTER_HALT | CONSTANT_VOLUME +DUTY_CYCLE_SHIFT: Final[int] = 6 +SWEEP_DISABLED: Final[int] = 0x08 + +TRIANGLE_COUNTER_CONTROL: Final[int] = 0x80 +TRIANGLE_SOUNDING_RELOAD: Final[int] = 0x7F +TRIANGLE_SILENT_RELOAD: Final[int] = 0x00 + +NOISE_MODE_SHIFT: Final[int] = 7 +NOISE_LENGTH_COUNTER_LOAD: Final[int] = 0x00 + +CHANNELS_ENABLED: Final[int] = 0x0F +FRAME_COUNTER_SEQUENCE: Final[int] = 0x40 diff --git a/tests/unit/sampletones_core/timers/implementation/test_phase.py b/tests/unit/sampletones_core/timers/implementation/test_phase.py index b0512259c..404ec03be 100644 --- a/tests/unit/sampletones_core/timers/implementation/test_phase.py +++ b/tests/unit/sampletones_core/timers/implementation/test_phase.py @@ -15,48 +15,6 @@ def phase_timer() -> PhaseTimer: return PhaseTimer(sample_rate=44100, nes_frequency=60) -class TestFrequencyToTimer: - @pytest.mark.parametrize( - "frequency, expected", - [ - (0, 0), - (-1.0, 0), - (440.0, 253), - (0.001, 0x7FF), - (1e9, 0), - ], - ids=[ - "zero_frequency", - "negative_frequency", - "a4_440hz", - "very_low_clamps_at_max", - "very_high_clamps_at_zero", - ], - ) - def test_timer_value_correct(self, frequency: float, expected: int) -> None: - assert PhaseTimer.frequency_to_timer(frequency) == expected - - -class TestGetTimerTicks: - @pytest.mark.parametrize( - "timer, expected", - [ - (0, 0), - (-1, 0), - (1, 32), - (100, 1616), - ], - ids=[ - "zero_returns_zero", - "negative_returns_zero", - "timer_1_gives_32", - "timer_100_gives_1616", - ], - ) - def test_tick_count_correct(self, timer: int, expected: int) -> None: - assert PhaseTimer.get_timer_ticks(timer) == expected - - class TestPhaseTimerValidate: @pytest.mark.parametrize( "initials", diff --git a/tests/unit/sampletones_core/timers/test_arithmetic.py b/tests/unit/sampletones_core/timers/test_arithmetic.py new file mode 100644 index 000000000..5cf637471 --- /dev/null +++ b/tests/unit/sampletones_core/timers/test_arithmetic.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.general import MAX_TIMER, TIMER_CYCLE_DIVIDER +from sampletones_core.timers.arithmetic import ( + frequency_to_timer, + get_timer_ticks, + timer_to_frequency, +) +from sampletones_core.timers.utils import get_frequency_table, get_timer_table +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestFrequencyToTimer(BaseTestSuite): + """The expected timers are the hardware's own values, which the APU reads back directly. + + A frequency beyond the 11-bit register's reach settles on the endpoint nearest it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + frequency: float + + @property + def label(self) -> str: + return f"frequency_{self.frequency:g}" + + test_cases = ( + TestCase(frequency=0.0, expected=0), + TestCase(frequency=-1.0, expected=0), + TestCase(frequency=440.0, expected=253), + TestCase(frequency=0.001, expected=MAX_TIMER), + TestCase(frequency=1e9, expected=0), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_timer_matches(self, test_case: TestCase) -> None: + assert frequency_to_timer(test_case.frequency) == test_case.expected + + +class TestGetTimerTicks(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + timer: int + + @property + def label(self) -> str: + return f"timer_{self.timer}" + + test_cases = ( + TestCase(timer=0, expected=0), + TestCase(timer=-1, expected=0), + TestCase(timer=1, expected=2 * TIMER_CYCLE_DIVIDER), + TestCase(timer=100, expected=101 * TIMER_CYCLE_DIVIDER), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_cycle_count_matches(self, test_case: TestCase) -> None: + assert get_timer_ticks(test_case.timer) == test_case.expected + + +class TestTimerRoundTrip: + """Reading a timer back as a frequency and converting it again returns the same timer. + + This is what lets a timer table be derived from a frequency table without drifting: the + frequency a table holds is already one the divider reaches exactly. + """ + + @pytest.mark.parametrize("timer", [0, 1, 8, 253, 1000, MAX_TIMER]) + def test_frequency_maps_back_to_its_timer(self, timer: int) -> None: + assert frequency_to_timer(timer_to_frequency(timer)) == timer + + +class TestGetTimerTable: + def test_covers_the_same_pitches_as_the_frequency_table(self) -> None: + config = Config() + assert get_timer_table(config).keys() == get_frequency_table(config).keys() + + def test_every_timer_sounds_its_pitch_frequency(self) -> None: + config = Config() + frequencies = get_frequency_table(config) + timers = get_timer_table(config) + + for pitch, frequency in frequencies.items(): + assert timer_to_frequency(timers[pitch]) == frequency diff --git a/tests/unit/sampletones_player/__init__.py b/tests/unit/sampletones_player/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/test_registers.py b/tests/unit/sampletones_player/test_registers.py new file mode 100644 index 000000000..43f64598b --- /dev/null +++ b/tests/unit/sampletones_player/test_registers.py @@ -0,0 +1,247 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Tuple + +import pytest + +from sampletones_core.constants.general import ( + MAX_DUTY_CYCLE, + MAX_PERIOD, + MAX_PITCH, + MAX_TIMER, + MAX_VOLUME, + MIN_PITCH, +) +from sampletones_core.instructions import ( + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.timers.arithmetic import frequency_to_timer +from sampletones_core.utils.frequencies import pitch_to_frequency +from sampletones_player.registers import ( + PulseRegisters, + encode_noise, + encode_pulse, + encode_triangle, +) +from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +TIMER_TABLE: Final[Dict[int, int]] = { + pitch: frequency_to_timer(pitch_to_frequency(pitch)) for pitch in range(MIN_PITCH, MAX_PITCH + 1) +} + +PULSE_TIMER_MUTE_FLOOR: Final[int] = 8 +REFERENCE_PITCH: Final[int] = 69 + + +def sounding_pulse( + pitch: int, + volume: int, + duty_cycle: int, +) -> PulseInstruction: + return PulseInstruction( + on=True, + pitch=pitch, + volume=volume, + duty_cycle=duty_cycle, + ) + + +def silent_pulse() -> PulseInstruction: + return PulseInstruction.null_instruction() + + +class TestPulseTimerRange(BaseTestSuite): + """Every pitch a channel may sound reaches a timer the hardware plays. + + The APU silences a pulse channel below timer 8 and the register holds 11 bits, so the + playable pitch range has to land between those two bounds for the driver to sound it. + """ + + @pytest.mark.parametrize("pitch", [MIN_PITCH, REFERENCE_PITCH, MAX_PITCH]) + def test_timer_lies_within_the_audible_register_range(self, pitch: int) -> None: + timer = TIMER_TABLE[pitch] + assert PULSE_TIMER_MUTE_FLOOR <= timer <= MAX_TIMER + + def test_timer_splits_into_a_byte_and_three_bits(self) -> None: + registers = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0)], TIMER_TABLE) + timer = TIMER_TABLE[REFERENCE_PITCH] + assert registers[0].timer_low == timer & MAX_REGISTER_VALUE + assert registers[0].timer_high == timer >> TIMER_HIGH_SHIFT + + +class TestTickRecord: + """A tick states its values in the order the driver moves them to its channel.""" + + def test_pulse_tick_states_control_then_timer(self) -> None: + registers = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE)], TIMER_TABLE)[0] + timer = TIMER_TABLE[REFERENCE_PITCH] + assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) + + def test_triangle_tick_states_the_counter_then_timer(self) -> None: + registers = encode_triangle([TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], TIMER_TABLE)[0] + timer = TIMER_TABLE[REFERENCE_PITCH] + assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) + + +class TestPulseControlByte(BaseTestSuite): + """The expected bytes are the values the APU reads from ``$4000``. + + A duty cycle occupies the top two bits, the length-halt and constant-volume bits sit + below them, and the level fills the low nibble. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + duty_cycle: int + volume: int + + @property + def label(self) -> str: + return f"duty_{self.duty_cycle}_volume_{self.volume}" + + test_cases = ( + TestCase(duty_cycle=0, volume=MAX_VOLUME, expected=0x3F), + TestCase(duty_cycle=1, volume=MAX_VOLUME, expected=0x7F), + TestCase(duty_cycle=2, volume=MAX_VOLUME, expected=0xBF), + TestCase(duty_cycle=MAX_DUTY_CYCLE, volume=MAX_VOLUME, expected=0xFF), + TestCase(duty_cycle=2, volume=0, expected=0xB0), + TestCase(duty_cycle=0, volume=1, expected=0x31), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_control_byte_matches(self, test_case: TestCase) -> None: + instructions = [sounding_pulse(REFERENCE_PITCH, test_case.volume, test_case.duty_cycle)] + registers = encode_pulse(instructions, TIMER_TABLE) + assert registers[0].control == test_case.expected + + +class TestPulseRest: + """A rest zeroes the level and keeps everything else the channel was holding. + + Holding the period across a rest is what lets the driver leave the timer's high byte + untouched, and leaving it untouched is what keeps the waveform's phase running. + """ + + @staticmethod + def encode_note_then_rest() -> List[PulseRegisters]: + instructions = [ + sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE), + silent_pulse(), + ] + return encode_pulse(instructions, TIMER_TABLE) + + def test_rest_clears_the_volume_nibble(self) -> None: + sounding, resting = self.encode_note_then_rest()[:2] + assert sounding.control & 0x0F == MAX_VOLUME + assert resting.control & 0x0F == 0 + + def test_rest_keeps_the_duty_cycle(self) -> None: + sounding, resting = self.encode_note_then_rest()[:2] + assert resting.control & 0xF0 == sounding.control & 0xF0 + + def test_rest_keeps_the_timer(self) -> None: + sounding, resting = self.encode_note_then_rest()[:2] + assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + + +class TestReleaseTick: + """A sample that ends while sounding gains one closing tick that silences its channel.""" + + def test_pulse_gains_a_silent_closing_tick(self) -> None: + registers = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0)], TIMER_TABLE) + assert len(registers) == 2 + assert registers[-1].control & 0x0F == 0 + + def test_a_sample_ending_in_a_rest_gains_no_extra_tick(self) -> None: + instructions = [sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0), silent_pulse()] + assert len(encode_pulse(instructions, TIMER_TABLE)) == 2 + + def test_no_instructions_encode_to_no_ticks(self) -> None: + assert encode_pulse([], TIMER_TABLE) == [] + + +class TestEncodeTriangle: + """The triangle states whether it sounds through the linear counter's reload value. + + The control bit stays set so the counter reloads every frame, and a reload of zero is + what holds the channel silent. + """ + + def test_sounding_tick_reloads_the_counter_fully(self) -> None: + registers = encode_triangle([TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], TIMER_TABLE) + assert registers[0].linear_counter == 0xFF + + def test_resting_tick_reloads_the_counter_to_zero(self) -> None: + instructions = [ + TriangleInstruction(on=True, pitch=REFERENCE_PITCH), + TriangleInstruction.null_instruction(), + ] + registers = encode_triangle(instructions, TIMER_TABLE) + assert registers[1].linear_counter == 0x80 + + def test_rest_keeps_the_timer(self) -> None: + instructions = [ + TriangleInstruction(on=True, pitch=REFERENCE_PITCH), + TriangleInstruction.null_instruction(), + ] + sounding, resting = encode_triangle(instructions, TIMER_TABLE)[:2] + assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + + def test_triangle_shares_the_pulse_timer(self) -> None: + triangle = encode_triangle([TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], TIMER_TABLE) + pulse = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0)], TIMER_TABLE) + assert (triangle[0].timer_low, triangle[0].timer_high) == (pulse[0].timer_low, pulse[0].timer_high) + + +class TestEncodeNoise(BaseTestSuite): + """The project counts noise periods from the slowest and the register from the fastest.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, int] + period: int + short: bool + + @property + def label(self) -> str: + mode = "short" if self.short else "normal" + return f"period_{self.period}_{mode}" + + test_cases = ( + TestCase(period=0, short=False, expected=(0x3F, MAX_PERIOD)), + TestCase(period=MAX_PERIOD, short=False, expected=(0x3F, 0)), + TestCase(period=0, short=True, expected=(0x3F, 0x80 | MAX_PERIOD)), + TestCase(period=MAX_PERIOD, short=True, expected=(0x3F, 0x80)), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_registers_match(self, test_case: TestCase) -> None: + instruction = NoiseInstruction( + on=True, + period=test_case.period, + volume=MAX_VOLUME, + short=test_case.short, + ) + registers = encode_noise([instruction]) + assert registers[0].values == test_case.expected + + def test_rest_clears_the_volume_and_keeps_the_period(self) -> None: + instructions = [ + NoiseInstruction(on=True, period=4, volume=MAX_VOLUME, short=False), + NoiseInstruction.null_instruction(), + ] + sounding, resting = encode_noise(instructions)[:2] + assert resting.control & 0x0F == 0 + assert resting.period == sounding.period From d7cc9ff1254f3691464d426b7f48b44e8c5110b5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 20:54:21 +0200 Subject: [PATCH 002/142] Added: the play-call clock for the player --- src/sampletones_player/clock/__init__.py | 0 src/sampletones_player/clock/schedule.py | 156 +++++++++++ src/sampletones_player/clock/step.py | 31 +++ src/sampletones_player/registers.py | 3 +- src/sampletones_player/specification/clock.py | 9 + tests/unit/sampletones_player/test_clock.py | 255 ++++++++++++++++++ 6 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 src/sampletones_player/clock/__init__.py create mode 100644 src/sampletones_player/clock/schedule.py create mode 100644 src/sampletones_player/clock/step.py create mode 100644 src/sampletones_player/specification/clock.py create mode 100644 tests/unit/sampletones_player/test_clock.py diff --git a/src/sampletones_player/clock/__init__.py b/src/sampletones_player/clock/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py new file mode 100644 index 000000000..1281029a7 --- /dev/null +++ b/src/sampletones_player/clock/schedule.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from math import floor + +from sampletones_player.clock.step import FixedPointStep +from sampletones_player.specification.clock import ( + FIXED_POINT_BITS, + FIXED_POINT_SCALE, + MAX_STEP_WHOLE, + MICROSECONDS_PER_SECOND, + PLAY_PERIOD_MICROSECONDS, +) + + +@dataclass(frozen=True) +class PlaySchedule: + """The engine ticks each play call advances a stream by, held exact so a stream keeps its rate. + + An NSF asks the console to call its play routine at one fixed rate, and a reconstruction is + built at whatever rate its ``nes_frequency`` states. The two meet here: the schedule states how + far the stream stands from its start after any number of calls, and the driver follows it by + adding :attr:`fixed_point_step` to an accumulator and advancing by the whole ticks that fall + out. One data set plays at every stream rate, and a stream slower than the play rate simply + stands still on the calls between its ticks. + + This is the rule :class:`~sampletones_core.timing.clock.TickClock` applies one level down: + spread the fractional part across consecutive units so the running total tracks the exact + clock. There it is audio samples per engine tick; here it is engine ticks per play call. + + Initialisation leaves the stream on tick 0, and the play call at index ``play_call`` leaves it + on tick ``ticks_at(play_call + 1)``. + + Attributes: + ticks_per_play_call: The exact ticks one play call advances the stream by. + """ + + ticks_per_play_call: Fraction + + def __post_init__(self) -> None: + if self.ticks_per_play_call <= 0: + raise ValueError(f"ticks_per_play_call must be above 0, got {self.ticks_per_play_call}") + + if self.ticks_per_play_call > MAX_STEP_WHOLE: + raise ValueError(f"ticks_per_play_call must be at most {MAX_STEP_WHOLE}, got {self.ticks_per_play_call}") + + @classmethod + def from_parameters(cls, nes_frequency: int) -> PlaySchedule: + """Derives the schedule a stream built at ``nes_frequency`` plays on. + + The play rate follows from the period the NSF header asks for, so the step the driver + carries and the rate the file requests state the same clock. + + Args: + nes_frequency: The engine tick rate the reconstruction was built at, in Hz. + + Returns: + PlaySchedule: The exact ticks one play call advances the stream by. + + Raises: + ValueError: If ``nes_frequency`` is below 1, or asks for more ticks per call than the + step's whole byte holds. + """ + if nes_frequency < 1: + raise ValueError(f"nes_frequency must be at least 1, got {nes_frequency}") + + return cls( + ticks_per_play_call=Fraction( + nes_frequency * PLAY_PERIOD_MICROSECONDS, + MICROSECONDS_PER_SECOND, + ), + ) + + def ticks_at(self, play_calls: int) -> int: + """The tick the stream stands on once ``play_calls`` calls have been made. + + Args: + play_calls: How many play calls have been made, at least 0. + + Returns: + int: The tick's index, within one tick of where the exact clock puts the stream. + + Raises: + ValueError: If ``play_calls`` is negative. + """ + if play_calls < 0: + raise ValueError(f"play_calls must be at least 0, got {play_calls}") + + return floor(self.ticks_per_play_call * play_calls) + + def advance_at(self, play_call: int) -> int: + """The ticks the stream advances by during the call at ``play_call``. + + Taking the difference of two cumulative counts is what makes a run of advances sum to the + exact span it covers, however the fraction falls. This is the carry the driver reads out of + its accumulator, and a carry of zero marks a call the stream holds its tick through. + + Args: + play_call: The call's position in the run, counted from 0. + + Returns: + int: The ticks that call advances by. + + Raises: + ValueError: If ``play_call`` is negative. + """ + return self.ticks_at(play_call + 1) - self.ticks_at(play_call) + + @property + def fixed_point_step(self) -> FixedPointStep: + """The exact step rounded to the nearest unit the driver's accumulator counts in.""" + whole, fraction = divmod(round(self.ticks_per_play_call * FIXED_POINT_SCALE), FIXED_POINT_SCALE) + return FixedPointStep(whole=whole, fraction=fraction) + + def fixed_point_ticks_at(self, play_calls: int) -> int: + """The tick the driver's own arithmetic stands on once ``play_calls`` calls have been made. + + Reproduces the accumulator in full: the rounded step added once per call, with the whole + ticks read off the top of the running total. Holding this beside :meth:`ticks_at` is what + shows the rounding staying within a tick of the exact clock. + + Args: + play_calls: How many play calls have been made, at least 0. + + Returns: + int: The tick's index as the driver counts it. + + Raises: + ValueError: If ``play_calls`` is negative. + """ + if play_calls < 0: + raise ValueError(f"play_calls must be at least 0, got {play_calls}") + + return (play_calls * self.fixed_point_step.value) >> FIXED_POINT_BITS + + def maximum_drift(self, play_calls: int) -> int: + """The furthest the driver's schedule stands from the exact one across a run of calls. + + Args: + play_calls: How many calls the run covers, at least 0. + + Returns: + int: The largest gap in ticks, measured at every call in the run. + + Raises: + ValueError: If ``play_calls`` is negative. + """ + if play_calls < 0: + raise ValueError(f"play_calls must be at least 0, got {play_calls}") + + step = self.fixed_point_step.value + return max( + abs((play_call * step >> FIXED_POINT_BITS) - self.ticks_at(play_call)) + for play_call in range(play_calls + 1) + ) diff --git a/src/sampletones_player/clock/step.py b/src/sampletones_player/clock/step.py new file mode 100644 index 000000000..4c45ea8b4 --- /dev/null +++ b/src/sampletones_player/clock/step.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_player.specification.clock import ( + FIXED_POINT_BITS, + MAX_STEP_FRACTION, + MAX_STEP_WHOLE, +) + + +class FixedPointStep(BaseModel): + """The step the driver adds to its accumulator on every play call. + + The 6502 has no fractional arithmetic, so the step reaches the console as a whole byte and a + 16-bit fraction, and the song header carries the two fields as they are written here. Adding + them into a 24-bit accumulator and reading the whole ticks off the top is what lets a stream + of any rate advance by a fractional amount per call. + + Attributes: + whole: The whole ticks every call advances by. + fraction: The remainder, in 1/65536ths of a tick. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + whole: int = Field(..., ge=0, le=MAX_STEP_WHOLE) + fraction: int = Field(..., ge=0, le=MAX_STEP_FRACTION) + + @property + def value(self) -> int: + """The step as one number, in the 1/65536ths of a tick the accumulator counts in.""" + return (self.whole << FIXED_POINT_BITS) | self.fraction diff --git a/src/sampletones_player/registers.py b/src/sampletones_player/registers.py index 73422dce4..d3f25ca3b 100644 --- a/src/sampletones_player/registers.py +++ b/src/sampletones_player/registers.py @@ -12,8 +12,7 @@ PulseInstruction, TriangleInstruction, ) - -from .specification.registers import ( +from sampletones_player.specification.registers import ( DUTY_CYCLE_SHIFT, MAX_REGISTER_VALUE, MAX_TIMER_HIGH, diff --git a/src/sampletones_player/specification/clock.py b/src/sampletones_player/specification/clock.py new file mode 100644 index 000000000..0e153515e --- /dev/null +++ b/src/sampletones_player/specification/clock.py @@ -0,0 +1,9 @@ +from typing import Final + +PLAY_PERIOD_MICROSECONDS: Final[int] = 16666 +MICROSECONDS_PER_SECOND: Final[int] = 1_000_000 + +FIXED_POINT_BITS: Final[int] = 16 +FIXED_POINT_SCALE: Final[int] = 1 << FIXED_POINT_BITS +MAX_STEP_FRACTION: Final[int] = FIXED_POINT_SCALE - 1 +MAX_STEP_WHOLE: Final[int] = 0xFF diff --git a/tests/unit/sampletones_player/test_clock.py b/tests/unit/sampletones_player/test_clock.py new file mode 100644 index 000000000..8574bb5c5 --- /dev/null +++ b/tests/unit/sampletones_player/test_clock.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from typing import Final, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_player.clock.schedule import FixedPointStep, PlaySchedule +from sampletones_player.specification.clock import ( + FIXED_POINT_SCALE, + MAX_STEP_WHOLE, + MICROSECONDS_PER_SECOND, + PLAY_PERIOD_MICROSECONDS, +) +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +LONG_RUN_PLAY_CALLS: Final[int] = 36000 +NES_FREQUENCIES: Final[Tuple[int, ...]] = (15, 24, 25, 30, 50, 60, 100, 120, 200, 299, 300) + + +def exact_rate(nes_frequency: int) -> Fraction: + return Fraction(nes_frequency * PLAY_PERIOD_MICROSECONDS, MICROSECONDS_PER_SECOND) + + +class TestPlaySchedule(BaseTestSuite): + """One case table, read both for the advances it produces and for the rules they obey.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + nes_frequency: int + + @property + def label(self) -> str: + return f"{self.nes_frequency}hz" + + @property + def schedule(self) -> PlaySchedule: + return PlaySchedule.from_parameters(self.nes_frequency) + + test_cases = ( + TestCase(nes_frequency=60, expected=(0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)), + TestCase(nes_frequency=30, expected=(0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0)), + TestCase(nes_frequency=24, expected=(0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0)), + TestCase(nes_frequency=15, expected=(0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)), + TestCase(nes_frequency=50, expected=(0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0)), + TestCase(nes_frequency=100, expected=(1, 2, 1, 2, 2, 1, 2, 2, 1, 2, 2, 1)), + TestCase(nes_frequency=120, expected=(1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2)), + TestCase(nes_frequency=300, expected=(4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5)), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_advances_match(self, test_case: TestCase) -> None: + schedule = test_case.schedule + advances = tuple(schedule.advance_at(play_call) for play_call in range(len(test_case.expected))) + assert advances == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_advances_sum_to_the_cumulative_count(self, test_case: TestCase) -> None: + schedule = test_case.schedule + calls = len(test_case.expected) + assert sum(schedule.advance_at(play_call) for play_call in range(calls)) == schedule.ticks_at(calls) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_only_the_floor_and_the_ceiling_appear(self, test_case: TestCase) -> None: + """Consecutive calls advance by one of two neighbouring amounts, so the stream moves evenly.""" + schedule = test_case.schedule + advances = {schedule.advance_at(play_call) for play_call in range(LONG_RUN_PLAY_CALLS)} + assert max(advances) - min(advances) <= 1 + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_stream_only_moves_forward(self, test_case: TestCase) -> None: + schedule = test_case.schedule + assert all(schedule.advance_at(play_call) >= 0 for play_call in range(LONG_RUN_PLAY_CALLS)) + + +class TestTheScheduleHoldsTheRate(BaseTestSuite): + """The property the whole schedule exists for: a run of calls lands on its exact tick.""" + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_a_long_run_lands_on_the_exact_tick_count(self, nes_frequency: int) -> None: + schedule = PlaySchedule.from_parameters(nes_frequency) + exact = exact_rate(nes_frequency) * LONG_RUN_PLAY_CALLS + assert abs(schedule.ticks_at(LONG_RUN_PLAY_CALLS) - exact) < 1 + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_cumulative_count_never_drifts_past_one_tick(self, nes_frequency: int) -> None: + schedule = PlaySchedule.from_parameters(nes_frequency) + rate = exact_rate(nes_frequency) + assert all( + abs(schedule.ticks_at(play_calls) - rate * play_calls) < 1 + for play_calls in range(0, LONG_RUN_PLAY_CALLS, 97) + ) + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_rate_is_the_stream_measured_against_the_play_period(self, nes_frequency: int) -> None: + schedule = PlaySchedule.from_parameters(nes_frequency) + assert schedule.ticks_per_play_call == exact_rate(nes_frequency) + + def test_a_stream_at_the_play_rate_advances_a_tick_a_call(self) -> None: + """A stream built at the period the header asks for lands a whole tick on every call.""" + schedule = PlaySchedule(ticks_per_play_call=Fraction(1)) + assert all(schedule.advance_at(play_call) == 1 for play_call in range(LONG_RUN_PLAY_CALLS)) + + def test_a_stream_at_half_the_play_rate_alternates(self) -> None: + schedule = PlaySchedule(ticks_per_play_call=Fraction(1, 2)) + advances = tuple(schedule.advance_at(play_call) for play_call in range(8)) + assert advances == (0, 1, 0, 1, 0, 1, 0, 1) + + +class TestFixedPointStep(BaseTestSuite): + """The step the driver carries, and how far its rounding takes the stream from the exact clock.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, int] + nes_frequency: int + + @property + def label(self) -> str: + return f"{self.nes_frequency}hz" + + test_cases = ( + TestCase(nes_frequency=15, expected=(0, 16383)), + TestCase(nes_frequency=24, expected=(0, 26213)), + TestCase(nes_frequency=30, expected=(0, 32767)), + TestCase(nes_frequency=50, expected=(0, 54611)), + TestCase(nes_frequency=60, expected=(0, 65533)), + TestCase(nes_frequency=100, expected=(1, 43686)), + TestCase(nes_frequency=120, expected=(1, 65531)), + TestCase(nes_frequency=200, expected=(3, 21837)), + TestCase(nes_frequency=300, expected=(4, 65523)), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_step_fields_match(self, test_case: TestCase) -> None: + step = PlaySchedule.from_parameters(test_case.nes_frequency).fixed_point_step + assert (step.whole, step.fraction) == test_case.expected + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_value_recomposes_the_fields(self, nes_frequency: int) -> None: + step = PlaySchedule.from_parameters(nes_frequency).fixed_point_step + assert divmod(step.value, FIXED_POINT_SCALE) == (step.whole, step.fraction) + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_step_is_the_nearest_unit_to_the_exact_rate(self, nes_frequency: int) -> None: + schedule = PlaySchedule.from_parameters(nes_frequency) + step = Fraction(schedule.fixed_point_step.value, FIXED_POINT_SCALE) + assert abs(step - schedule.ticks_per_play_call) <= Fraction(1, 2 * FIXED_POINT_SCALE) + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_driver_stays_within_a_tick_of_the_exact_schedule(self, nes_frequency: int) -> None: + """The claim the whole fixed-point step rests on, held across ten minutes of play calls.""" + schedule = PlaySchedule.from_parameters(nes_frequency) + assert schedule.maximum_drift(LONG_RUN_PLAY_CALLS) <= 1 + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_driver_starts_on_the_first_tick(self, nes_frequency: int) -> None: + schedule = PlaySchedule.from_parameters(nes_frequency) + assert schedule.fixed_point_ticks_at(0) == 0 + assert schedule.maximum_drift(0) == 0 + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + def test_the_driver_only_moves_forward(self, nes_frequency: int) -> None: + schedule = PlaySchedule.from_parameters(nes_frequency) + ticks = [schedule.fixed_point_ticks_at(play_calls) for play_calls in range(1024)] + assert all(later >= earlier for earlier, later in zip(ticks, ticks[1:])) + + def test_a_whole_step_needs_no_fraction(self) -> None: + step = PlaySchedule(ticks_per_play_call=Fraction(3)).fixed_point_step + assert (step.whole, step.fraction, step.value) == (3, 0, 3 * FIXED_POINT_SCALE) + + def test_a_step_a_hair_under_a_whole_tick_carries_into_the_whole_byte(self) -> None: + """Rounding the fraction up spills into the whole byte, keeping both fields in range.""" + rate = Fraction(2) - Fraction(1, 10 * FIXED_POINT_SCALE) + step = PlaySchedule(ticks_per_play_call=rate).fixed_point_step + assert (step.whole, step.fraction) == (2, 0) + + +class TestPlayScheduleBounds(BaseTestSuite): + def test_the_stream_starts_on_its_first_tick(self) -> None: + assert PlaySchedule.from_parameters(60).ticks_at(0) == 0 + + @pytest.mark.parametrize("nes_frequency", (MIN_NES_FREQUENCY, MAX_NES_FREQUENCY)) + def test_the_engine_range_fits_the_step(self, nes_frequency: int) -> None: + step = PlaySchedule.from_parameters(nes_frequency).fixed_point_step + assert step.whole <= MAX_STEP_WHOLE + + @pytest.mark.parametrize("nes_frequency", (0, -1)) + def test_a_tick_rate_below_one_is_rejected(self, nes_frequency: int) -> None: + with pytest.raises(ValueError, match="nes_frequency must be at least 1"): + PlaySchedule.from_parameters(nes_frequency) + + @pytest.mark.parametrize("ticks_per_play_call", (Fraction(0), Fraction(-1, 2))) + def test_a_stream_that_never_advances_is_rejected(self, ticks_per_play_call: Fraction) -> None: + with pytest.raises(ValueError, match="ticks_per_play_call must be above 0"): + PlaySchedule(ticks_per_play_call=ticks_per_play_call) + + def test_a_step_past_the_whole_byte_is_rejected(self) -> None: + with pytest.raises(ValueError, match="ticks_per_play_call must be at most"): + PlaySchedule(ticks_per_play_call=Fraction(MAX_STEP_WHOLE + 1)) + + def test_a_negative_call_count_is_rejected(self) -> None: + schedule = PlaySchedule.from_parameters(60) + with pytest.raises(ValueError, match="play_calls must be at least 0"): + schedule.ticks_at(-1) + + def test_a_negative_call_count_is_rejected_by_the_driver_schedule(self) -> None: + schedule = PlaySchedule.from_parameters(60) + with pytest.raises(ValueError, match="play_calls must be at least 0"): + schedule.fixed_point_ticks_at(-1) + + def test_a_negative_run_is_rejected_by_the_drift(self) -> None: + schedule = PlaySchedule.from_parameters(60) + with pytest.raises(ValueError, match="play_calls must be at least 0"): + schedule.maximum_drift(-1) + + def test_a_negative_call_index_is_rejected(self) -> None: + schedule = PlaySchedule.from_parameters(60) + with pytest.raises(ValueError, match="play_calls must be at least 0"): + schedule.advance_at(-1) + + @pytest.mark.parametrize("fraction", (-1, FIXED_POINT_SCALE)) + def test_a_fraction_outside_the_word_is_rejected(self, fraction: int) -> None: + with pytest.raises(ValidationError): + FixedPointStep(whole=0, fraction=fraction) + + @pytest.mark.parametrize("whole", (-1, MAX_STEP_WHOLE + 1)) + def test_a_whole_part_outside_the_byte_is_rejected(self, whole: int) -> None: + with pytest.raises(ValidationError): + FixedPointStep(whole=whole, fraction=0) From eec0b98f5363d4a9fd845e9f90705ca1cd720e25 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 00:24:56 +0200 Subject: [PATCH 003/142] Added: the song format and the register-trace model for the player --- src/sampletones_core/formats/binary.py | 57 ++++ .../formats/famitracker/binary.py | 66 +---- .../formats/famitracker/instrument.py | 14 +- .../formats/famitracker/module.py | 26 +- src/sampletones_player/nsf/__init__.py | 0 src/sampletones_player/nsf/song.py | 90 +++++++ src/sampletones_player/registers.py | 193 -------------- src/sampletones_player/registers/__init__.py | 0 src/sampletones_player/registers/base.py | 24 ++ src/sampletones_player/registers/hold.py | 22 ++ src/sampletones_player/registers/noise.py | 54 ++++ src/sampletones_player/registers/pulse.py | 63 +++++ src/sampletones_player/registers/streams.py | 74 ++++++ src/sampletones_player/registers/triangle.py | 67 +++++ src/sampletones_player/song.py | 50 ++++ .../specification/channels.py | 30 +++ .../specification/registers.py | 14 +- src/sampletones_player/specification/song.py | 15 ++ src/sampletones_player/trace/__init__.py | 0 src/sampletones_player/trace/trace.py | 110 ++++++++ src/sampletones_player/trace/write.py | 13 + src/sampletones_shared/exceptions/__init__.py | 3 + src/sampletones_shared/exceptions/player.py | 9 + tests/suite/player.py | 123 +++++++++ .../unit/sampletones_core/formats/__init__.py | 0 .../formats/famitracker/test_binary.py | 85 +----- .../sampletones_core/formats/test_binary.py | 94 +++++++ .../unit/sampletones_player/clock/__init__.py | 0 .../{test_clock.py => clock/test_schedule.py} | 13 +- .../sampletones_player/clock/test_step.py | 57 ++++ tests/unit/sampletones_player/nsf/__init__.py | 0 .../unit/sampletones_player/nsf/test_song.py | 176 +++++++++++++ .../sampletones_player/registers/__init__.py | 0 .../registers/test_noise.py | 56 ++++ .../registers/test_pulse.py | 136 ++++++++++ .../registers/test_streams.py | 70 +++++ .../registers/test_triangle.py | 49 ++++ .../unit/sampletones_player/test_registers.py | 247 ------------------ tests/unit/sampletones_player/test_song.py | 91 +++++++ .../unit/sampletones_player/trace/__init__.py | 0 .../sampletones_player/trace/test_trace.py | 183 +++++++++++++ 41 files changed, 1771 insertions(+), 603 deletions(-) create mode 100644 src/sampletones_core/formats/binary.py create mode 100644 src/sampletones_player/nsf/__init__.py create mode 100644 src/sampletones_player/nsf/song.py delete mode 100644 src/sampletones_player/registers.py create mode 100644 src/sampletones_player/registers/__init__.py create mode 100644 src/sampletones_player/registers/base.py create mode 100644 src/sampletones_player/registers/hold.py create mode 100644 src/sampletones_player/registers/noise.py create mode 100644 src/sampletones_player/registers/pulse.py create mode 100644 src/sampletones_player/registers/streams.py create mode 100644 src/sampletones_player/registers/triangle.py create mode 100644 src/sampletones_player/song.py create mode 100644 src/sampletones_player/specification/channels.py create mode 100644 src/sampletones_player/specification/song.py create mode 100644 src/sampletones_player/trace/__init__.py create mode 100644 src/sampletones_player/trace/trace.py create mode 100644 src/sampletones_player/trace/write.py create mode 100644 src/sampletones_shared/exceptions/player.py create mode 100644 tests/suite/player.py create mode 100644 tests/unit/sampletones_core/formats/__init__.py create mode 100644 tests/unit/sampletones_core/formats/test_binary.py create mode 100644 tests/unit/sampletones_player/clock/__init__.py rename tests/unit/sampletones_player/{test_clock.py => clock/test_schedule.py} (94%) create mode 100644 tests/unit/sampletones_player/clock/test_step.py create mode 100644 tests/unit/sampletones_player/nsf/__init__.py create mode 100644 tests/unit/sampletones_player/nsf/test_song.py create mode 100644 tests/unit/sampletones_player/registers/__init__.py create mode 100644 tests/unit/sampletones_player/registers/test_noise.py create mode 100644 tests/unit/sampletones_player/registers/test_pulse.py create mode 100644 tests/unit/sampletones_player/registers/test_streams.py create mode 100644 tests/unit/sampletones_player/registers/test_triangle.py delete mode 100644 tests/unit/sampletones_player/test_registers.py create mode 100644 tests/unit/sampletones_player/test_song.py create mode 100644 tests/unit/sampletones_player/trace/__init__.py create mode 100644 tests/unit/sampletones_player/trace/test_trace.py diff --git a/src/sampletones_core/formats/binary.py b/src/sampletones_core/formats/binary.py new file mode 100644 index 000000000..5d5777f47 --- /dev/null +++ b/src/sampletones_core/formats/binary.py @@ -0,0 +1,57 @@ +import struct + + +class BinaryWriter: + """Builds a little-endian byte buffer through named, semantic write methods. + + Binary file writing goes through this class, so raw struct packing stays confined here and + the writers above it read field by field, the way the format specification states them. + """ + + def __init__(self) -> None: + self._buffer = bytearray() + + @property + def data(self) -> bytes: + return bytes(self._buffer) + + def __len__(self) -> int: + return len(self._buffer) + + def write_bytes(self, data: bytes) -> None: + self._buffer.extend(data) + + def write_uint8(self, value: int) -> None: + self._buffer.extend(struct.pack(" None: + self._buffer.extend(struct.pack(" None: + self._buffer.extend(struct.pack(" None: + self._buffer.extend(struct.pack(" None: + self._buffer.extend(struct.pack(" None: + """Writes ``text`` as UTF-8 into a fixed ``length``-byte field, NUL-padded. + + Text whose UTF-8 encoding exceeds ``length`` bytes is cut to fit the field. + """ + encoded = text.encode("utf-8")[:length] + self._buffer.extend(encoded) + self._buffer.extend(b"\x00" * (length - len(encoded))) + + def write_counted_string(self, text: str) -> None: + """Writes a ``uint32`` byte length followed by the UTF-8 bytes of ``text``.""" + encoded = text.encode("utf-8") + self.write_uint32(len(encoded)) + self._buffer.extend(encoded) + + def write_terminated_string(self, text: str) -> None: + """Writes the UTF-8 bytes of ``text`` followed by a single NUL terminator.""" + self._buffer.extend(text.encode("utf-8")) + self._buffer.extend(b"\x00") diff --git a/src/sampletones_core/formats/famitracker/binary.py b/src/sampletones_core/formats/famitracker/binary.py index ff778b900..2b1984b13 100644 --- a/src/sampletones_core/formats/famitracker/binary.py +++ b/src/sampletones_core/formats/famitracker/binary.py @@ -1,67 +1,17 @@ from __future__ import annotations -import struct from contextlib import contextmanager from typing import Iterator +from sampletones_core.formats.binary import BinaryWriter from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block -class BinaryWriter: - """Builds a little-endian byte buffer through named, semantic write methods. - - All FamiTracker file writing goes through this class, so raw struct packing - stays confined here and the higher-level block writers read field-by-field like - the format specification. - """ - - def __init__(self) -> None: - self._buffer = bytearray() - - @property - def data(self) -> bytes: - return bytes(self._buffer) - - def __len__(self) -> int: - return len(self._buffer) - - def write_bytes(self, data: bytes) -> None: - self._buffer.extend(data) - - def write_uint8(self, value: int) -> None: - self._buffer.extend(struct.pack(" None: - self._buffer.extend(struct.pack(" None: - self._buffer.extend(struct.pack(" None: - self._buffer.extend(struct.pack(" None: - """Writes ``text`` as UTF-8 into a fixed ``length``-byte field, NUL-padded. - - Text whose UTF-8 encoding exceeds ``length`` bytes is cut to fit the field. - """ - encoded = text.encode("utf-8")[:length] - self._buffer.extend(encoded) - self._buffer.extend(b"\x00" * (length - len(encoded))) - - def write_counted_string(self, text: str) -> None: - """Writes a ``uint32`` byte length followed by the UTF-8 bytes of ``text``.""" - encoded = text.encode("utf-8") - self.write_uint32(len(encoded)) - self._buffer.extend(encoded) - - def write_terminated_string(self, text: str) -> None: - """Writes the UTF-8 bytes of ``text`` followed by a single NUL terminator.""" - self._buffer.extend(text.encode("utf-8")) - self._buffer.extend(b"\x00") +class FamiTrackerWriter(BinaryWriter): + """A binary writer that frames the named, versioned blocks a FamiTracker module is built from.""" @contextmanager - def block(self, descriptor: Block) -> Iterator[BinaryWriter]: + def block(self, descriptor: Block) -> Iterator[FamiTrackerWriter]: """Frames a named, versioned block around a buffered payload. The payload written to the yielded writer is emitted with the block name @@ -69,19 +19,19 @@ def block(self, descriptor: Block) -> Iterator[BinaryWriter]: payload size (``int32``) in front of it, so the size is known before the header is written. """ - payload = BinaryWriter() + payload = FamiTrackerWriter() yield payload body = payload.data self._write_block_name(descriptor.name) self.write_int32(descriptor.version) self.write_int32(len(body)) - self._buffer.extend(body) + self.write_bytes(body) def _write_block_name(self, name: str) -> None: encoded = name.encode("ascii") if len(encoded) > BLOCK_NAME_LENGTH: raise ValueError(f"Block name '{name}' exceeds {BLOCK_NAME_LENGTH} bytes") - self._buffer.extend(encoded) - self._buffer.extend(b"\x00" * (BLOCK_NAME_LENGTH - len(encoded))) + self.write_bytes(encoded) + self.write_bytes(b"\x00" * (BLOCK_NAME_LENGTH - len(encoded))) diff --git a/src/sampletones_core/formats/famitracker/instrument.py b/src/sampletones_core/formats/famitracker/instrument.py index 4592e8ad4..fe18550af 100644 --- a/src/sampletones_core/formats/famitracker/instrument.py +++ b/src/sampletones_core/formats/famitracker/instrument.py @@ -1,4 +1,4 @@ -from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.binary import FamiTrackerWriter from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.file import FTI_MAGIC, FTI_VERSION @@ -17,13 +17,13 @@ from sampletones_shared.utils.serialization import save_binary -def _write_header(writer: BinaryWriter) -> None: +def _write_header(writer: FamiTrackerWriter) -> None: writer.write_bytes(FTI_MAGIC) writer.write_bytes(FTI_VERSION) def _write_type_and_name( - writer: BinaryWriter, + writer: FamiTrackerWriter, instrument: Instrument2A03, ) -> None: writer.write_uint8(INSTRUMENT_TYPE_2A03) @@ -31,7 +31,7 @@ def _write_type_and_name( def _write_sequence( - writer: BinaryWriter, + writer: FamiTrackerWriter, sequence: InstrumentSequence, ) -> None: if not sequence.enabled: @@ -47,20 +47,20 @@ def _write_sequence( writer.write_int8(item) -def _write_sequences(writer: BinaryWriter, instrument: Instrument2A03) -> None: +def _write_sequences(writer: FamiTrackerWriter, instrument: Instrument2A03) -> None: writer.write_int8(SEQUENCE_COUNT_2A03) for kind in SequenceKind: _write_sequence(writer, instrument.sequences[kind]) -def _write_empty_dpcm_section(writer: BinaryWriter) -> None: +def _write_empty_dpcm_section(writer: FamiTrackerWriter) -> None: writer.write_uint32(EMPTY_DPCM_ASSIGNMENTS) writer.write_uint32(EMPTY_DPCM_SAMPLES) def instrument_to_fti_bytes(instrument: Instrument2A03) -> bytes: """Serializes a 2A03 instrument to the FamiTracker ``.fti`` byte layout.""" - writer = BinaryWriter() + writer = FamiTrackerWriter() _write_header(writer) _write_type_and_name(writer, instrument) _write_sequences(writer, instrument) diff --git a/src/sampletones_core/formats/famitracker/module.py b/src/sampletones_core/formats/famitracker/module.py index fc88d49ac..d27592e7b 100644 --- a/src/sampletones_core/formats/famitracker/module.py +++ b/src/sampletones_core/formats/famitracker/module.py @@ -1,6 +1,6 @@ from typing import Sequence -from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.binary import FamiTrackerWriter from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.module import ( FamiTrackerModule, @@ -51,12 +51,12 @@ ) -def _write_file_header(writer: BinaryWriter) -> None: +def _write_file_header(writer: FamiTrackerWriter) -> None: writer.write_bytes(FTM_MAGIC) writer.write_uint32(FTM_VERSION) -def _write_params_block(writer: BinaryWriter, parameters: ModuleParameters) -> None: +def _write_params_block(writer: FamiTrackerWriter, parameters: ModuleParameters) -> None: with writer.block(BLOCK_PARAMS) as body: body.write_uint8(parameters.expansion_chip) body.write_int32(parameters.channel_count) @@ -68,14 +68,14 @@ def _write_params_block(writer: BinaryWriter, parameters: ModuleParameters) -> N body.write_int32(parameters.speed_split_point) -def _write_info_block(writer: BinaryWriter, information: ModuleInformation) -> None: +def _write_info_block(writer: FamiTrackerWriter, information: ModuleInformation) -> None: with writer.block(BLOCK_INFO) as body: body.write_fixed_string(information.title, INFO_STRING_LENGTH) body.write_fixed_string(information.author, INFO_STRING_LENGTH) body.write_fixed_string(information.copyright, INFO_STRING_LENGTH) -def _write_header_block(writer: BinaryWriter, track: Track) -> None: +def _write_header_block(writer: FamiTrackerWriter, track: Track) -> None: with writer.block(BLOCK_HEADER) as body: body.write_uint8(SINGLE_TRACK_COUNT - 1) body.write_terminated_string(track.title) @@ -85,7 +85,7 @@ def _write_header_block(writer: BinaryWriter, track: Track) -> None: def _write_instrument_body( - writer: BinaryWriter, + writer: FamiTrackerWriter, instrument: Instrument2A03, references: SequenceReferences, ) -> None: @@ -99,7 +99,7 @@ def _write_instrument_body( def _write_instruments_block( - writer: BinaryWriter, + writer: FamiTrackerWriter, instruments: Sequence[Instrument2A03], references: SequenceReferences, ) -> None: @@ -112,7 +112,7 @@ def _write_instruments_block( body.write_counted_string(instrument.name) -def _write_sequences_block(writer: BinaryWriter, pool: Sequence[PooledSequence]) -> None: +def _write_sequences_block(writer: FamiTrackerWriter, pool: Sequence[PooledSequence]) -> None: with writer.block(BLOCK_SEQUENCES) as body: body.write_int32(len(pool)) for pooled in pool: @@ -127,7 +127,7 @@ def _write_sequences_block(writer: BinaryWriter, pool: Sequence[PooledSequence]) body.write_int32(pooled.sequence.setting) -def _write_frames_block(writer: BinaryWriter, track: Track) -> None: +def _write_frames_block(writer: FamiTrackerWriter, track: Track) -> None: with writer.block(BLOCK_FRAMES) as body: body.write_int32(len(track.order)) body.write_int32(track.speed) @@ -138,7 +138,7 @@ def _write_frames_block(writer: BinaryWriter, track: Track) -> None: body.write_uint8(pattern_index) -def _write_patterns_block(writer: BinaryWriter, patterns: Sequence[PatternData]) -> None: +def _write_patterns_block(writer: FamiTrackerWriter, patterns: Sequence[PatternData]) -> None: with writer.block(BLOCK_PATTERNS) as body: for pattern in patterns: body.write_int32(FIRST_TRACK_INDEX) @@ -156,12 +156,12 @@ def _write_patterns_block(writer: BinaryWriter, patterns: Sequence[PatternData]) body.write_int8(param) -def _write_dpcm_samples_block(writer: BinaryWriter) -> None: +def _write_dpcm_samples_block(writer: FamiTrackerWriter) -> None: with writer.block(BLOCK_DPCM_SAMPLES) as body: body.write_uint8(EMPTY_DPCM_SAMPLES) -def _write_comments_block(writer: BinaryWriter, comment: str) -> None: +def _write_comments_block(writer: FamiTrackerWriter, comment: str) -> None: with writer.block(BLOCK_COMMENTS) as body: body.write_int32(COMMENT_HIDDEN_ON_OPEN) body.write_terminated_string(comment) @@ -171,7 +171,7 @@ def module_to_ftm_bytes(module: FamiTrackerModule) -> bytes: """Serializes a FamiTracker module to the ``.ftm`` byte layout.""" pool, references = build_sequence_pool(module.instruments) - writer = BinaryWriter() + writer = FamiTrackerWriter() _write_file_header(writer) _write_params_block(writer, module.parameters) _write_info_block(writer, module.information) diff --git a/src/sampletones_player/nsf/__init__.py b/src/sampletones_player/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py new file mode 100644 index 000000000..0a43d46e0 --- /dev/null +++ b/src/sampletones_player/nsf/song.py @@ -0,0 +1,90 @@ +from typing import Sequence, Tuple + +from sampletones_core.formats.binary import BinaryWriter +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.song import Song +from sampletones_player.specification.channels import CHANNEL_ORDER +from sampletones_player.specification.song import ( + MAX_STREAM_OFFSET, + NO_LOOP, + SONG_HEADER_SIZE, +) +from sampletones_shared.exceptions import SongTooLargeError + + +def _stream_to_bytes(stream: Sequence[ChannelRegisters]) -> bytes: + writer = BinaryWriter() + for registers in stream: + for value in registers.values: + writer.write_uint8(value) + + return writer.data + + +def _stream_offsets(bodies: Sequence[bytes]) -> Tuple[int, ...]: + offsets = [] + offset = SONG_HEADER_SIZE + for body in bodies: + offsets.append(offset) + offset += len(body) + + return tuple(offsets) + + +def _write_header( + writer: BinaryWriter, + song: Song, + offsets: Sequence[int], +) -> None: + step = song.schedule.fixed_point_step + writer.write_uint8(step.whole) + writer.write_uint16(step.fraction) + writer.write_uint16(song.ticks) + writer.write_uint16(NO_LOOP if song.loop_tick is None else song.loop_tick) + for offset in offsets: + writer.write_uint16(offset) + + +def _validate_space(size: int, available_bytes: int) -> None: + if size > available_bytes: + raise SongTooLargeError(f"the song takes {size} bytes and {available_bytes} are free") + + +def _validate_offsets(offsets: Sequence[int]) -> None: + for channel, offset in zip(CHANNEL_ORDER, offsets): + if offset > MAX_STREAM_OFFSET: + raise SongTooLargeError( + f"the {channel.value} stream starts {offset} bytes into the song " + f"and its header states at most {MAX_STREAM_OFFSET}", + ) + + +def song_to_bytes(song: Song, available_bytes: int) -> bytes: + """Serializes a song to the bytes the driver reads it from. + + The header states the clock and the length, then names where each channel's stream begins as + a distance from the song's own first byte, so the whole block plays from wherever the file + loads it and each channel can later be compressed on its own. + + Args: + song: The streams, the clock and the loop point to write. + available_bytes: The space the song has to fit in. + + Returns: + bytes: The song header followed by the four channel streams. + + Raises: + SongTooLargeError: If the song takes more than ``available_bytes``, or reaches further + into itself than a stream offset states. + """ + bodies = tuple(_stream_to_bytes(stream) for stream in song.streams.padded) + offsets = _stream_offsets(bodies) + _validate_space(SONG_HEADER_SIZE + sum(len(body) for body in bodies), available_bytes) + _validate_offsets(offsets) + + writer = BinaryWriter() + _write_header(writer, song, offsets) + for body in bodies: + writer.write_bytes(body) + + return writer.data diff --git a/src/sampletones_player/registers.py b/src/sampletones_player/registers.py deleted file mode 100644 index d3f25ca3b..000000000 --- a/src/sampletones_player/registers.py +++ /dev/null @@ -1,193 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Dict, List, Tuple - -from pydantic import BaseModel, ConfigDict, Field - -from sampletones_core.constants.general import MAX_PERIOD -from sampletones_core.exporters.implementation.noise import NoiseExporter -from sampletones_core.exporters.implementation.pulse import PulseExporter -from sampletones_core.exporters.implementation.triangle import TriangleExporter -from sampletones_core.instructions import ( - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) -from sampletones_player.specification.registers import ( - DUTY_CYCLE_SHIFT, - MAX_REGISTER_VALUE, - MAX_TIMER_HIGH, - NOISE_MODE_SHIFT, - SUSTAINED_LEVEL, - TIMER_HIGH_SHIFT, - TRIANGLE_COUNTER_CONTROL, - TRIANGLE_SILENT_RELOAD, - TRIANGLE_SOUNDING_RELOAD, -) - - -class ChannelRegisters(BaseModel, ABC): - """The register values one channel writes for a single engine tick. - - The driver interprets nothing: it moves these bytes to the addresses its channel owns. - Every rule the hardware follows — how a duty cycle reaches its bits, which value silences - a channel, how a pitch becomes a period — is settled here, where it is testable. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - @property - @abstractmethod - def values(self) -> Tuple[int, ...]: - """The tick's register values, in the order the driver writes them. - - Returns: - Tuple[int, ...]: One value per register the channel writes each tick. - """ - - -class PulseRegisters(ChannelRegisters): - control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) - timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) - timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) - - @property - def values(self) -> Tuple[int, ...]: - return (self.control, self.timer_low, self.timer_high) - - -class TriangleRegisters(ChannelRegisters): - linear_counter: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) - timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) - timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) - - @property - def values(self) -> Tuple[int, ...]: - return (self.linear_counter, self.timer_low, self.timer_high) - - -class NoiseRegisters(ChannelRegisters): - control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) - period: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) - - @property - def values(self) -> Tuple[int, ...]: - return (self.control, self.period) - - -def hold(values: List[int], index: int) -> int: - """Reads a held stream at a tick, sustaining its final value over the release tick. - - An exporter states a channel's pitch and timbre for every tick its instructions cover, and - appends one silent tick past them so a sample ends quiet. That release tick reads the values - the channel was holding when it stopped sounding. - - Args: - values: The held stream, covering at least one tick. - index: The tick to read. - - Returns: - int: The value at that tick, or the stream's final value once the index reaches its end. - """ - return values[min(index, len(values) - 1)] - - -def encode_pulse( - instructions: List[PulseInstruction], - timer_table: Dict[int, int], -) -> List[PulseRegisters]: - """Turns a pulse channel's instructions into the registers each tick writes. - - Volume rides in the control byte's low nibble, so a rest keeps its pitch and duty cycle and - sets the level to zero. Holding the period across a rest is what lets the driver leave the - timer's high byte alone, and leaving it alone is what keeps the waveform's phase running the - way a rendered channel does. - - Args: - instructions: The channel's per-tick instructions. - timer_table: The timer register value each pitch sounds at. - - Returns: - List[PulseRegisters]: One register set per tick, including the closing release tick. - """ - _, pitches, volumes, duty_cycles = PulseExporter.extract_data(instructions) - - registers: List[PulseRegisters] = [] - for index, volume in enumerate(volumes): - timer = timer_table[hold(pitches, index)] - duty_cycle = hold(duty_cycles, index) - registers.append( - PulseRegisters( - control=(duty_cycle << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | volume, - timer_low=timer & MAX_REGISTER_VALUE, - timer_high=timer >> TIMER_HIGH_SHIFT, - ) - ) - - return registers - - -def encode_triangle( - instructions: List[TriangleInstruction], - timer_table: Dict[int, int], -) -> List[TriangleRegisters]: - """Turns a triangle channel's instructions into the registers each tick writes. - - The triangle sounds at one level, so a tick states whether it sounds through the linear - counter's reload value: a full reload keeps the waveform running, and a reload of zero - holds it silent. The control bit stays set throughout, which is what makes the counter - reload every frame and the note last as long as the ticks do. - - The timer is written from the instruction's pitch directly, and the channel sounds an - octave below it — the same octave a rendered triangle sounds. - - Args: - instructions: The channel's per-tick instructions. - timer_table: The timer register value each pitch sounds at. - - Returns: - List[TriangleRegisters]: One register set per tick, including the closing release tick. - """ - _, pitches, volumes = TriangleExporter.extract_data(instructions) - - registers: List[TriangleRegisters] = [] - for index, volume in enumerate(volumes): - timer = timer_table[hold(pitches, index)] - reload_value = TRIANGLE_SOUNDING_RELOAD if volume > 0 else TRIANGLE_SILENT_RELOAD - registers.append( - TriangleRegisters( - linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, - timer_low=timer & MAX_REGISTER_VALUE, - timer_high=timer >> TIMER_HIGH_SHIFT, - ) - ) - - return registers - - -def encode_noise(instructions: List[NoiseInstruction]) -> List[NoiseRegisters]: - """Turns a noise channel's instructions into the registers each tick writes. - - The project counts noise periods from the slowest, and the register counts them from the - fastest, so a period reaches the register as its complement. The mode bit rides above it, - selecting the shift register's short 93-step cycle. - - Args: - instructions: The channel's per-tick instructions. - - Returns: - List[NoiseRegisters]: One register set per tick, including the closing release tick. - """ - _, periods, volumes, modes = NoiseExporter.extract_data(instructions) - - registers: List[NoiseRegisters] = [] - for index, volume in enumerate(volumes): - period = hold(periods, index) - mode = hold(modes, index) - registers.append( - NoiseRegisters( - control=SUSTAINED_LEVEL | volume, - period=(mode << NOISE_MODE_SHIFT) | (MAX_PERIOD - period), - ) - ) - - return registers diff --git a/src/sampletones_player/registers/__init__.py b/src/sampletones_player/registers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/registers/base.py b/src/sampletones_player/registers/base.py new file mode 100644 index 000000000..140f6249a --- /dev/null +++ b/src/sampletones_player/registers/base.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from pydantic import BaseModel, ConfigDict + + +class ChannelRegisters(BaseModel, ABC): + """The register values one channel writes for a single engine tick. + + The driver interprets nothing: it moves these bytes to the addresses its channel owns. + Every rule the hardware follows — how a duty cycle reaches its bits, which value silences + a channel, how a pitch becomes a period — is settled here, where it is testable. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + @property + @abstractmethod + def values(self) -> Tuple[int, ...]: + """The tick's register values, in the order the driver writes them. + + Returns: + Tuple[int, ...]: One value per register the channel writes each tick. + """ diff --git a/src/sampletones_player/registers/hold.py b/src/sampletones_player/registers/hold.py new file mode 100644 index 000000000..1372396c0 --- /dev/null +++ b/src/sampletones_player/registers/hold.py @@ -0,0 +1,22 @@ +from typing import Sequence, TypeVar + +HeldValue = TypeVar("HeldValue") + + +def hold(values: Sequence[HeldValue], index: int) -> HeldValue: + """Reads a held stream at a tick, sustaining its final value past the stream's end. + + An exporter states a channel's pitch and timbre for every tick its instructions cover, and + appends one silent tick past them so a sample ends quiet. That release tick reads the values + the channel was holding when it stopped sounding, and the same rule carries a channel that + runs out early through the rest of a song. + + Args: + values: The held stream, covering at least one tick. + index: The tick to read. + + Returns: + HeldValue: The value at that tick, or the stream's final value once the index reaches + its end. + """ + return values[min(index, len(values) - 1)] diff --git a/src/sampletones_player/registers/noise.py b/src/sampletones_player/registers/noise.py new file mode 100644 index 000000000..dc4b4b1eb --- /dev/null +++ b/src/sampletones_player/registers/noise.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import List, Tuple + +from pydantic import Field + +from sampletones_core.constants.general import MAX_PERIOD +from sampletones_core.exporters.implementation.noise import NoiseExporter +from sampletones_core.instructions import NoiseInstruction +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.registers.hold import hold +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + NOISE_MODE_SHIFT, + SUSTAINED_LEVEL, +) + + +class NoiseRegisters(ChannelRegisters): + control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + period: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + @property + def values(self) -> Tuple[int, ...]: + return (self.control, self.period) + + @classmethod + def from_instructions(cls, instructions: List[NoiseInstruction]) -> List[NoiseRegisters]: + """Turns a noise channel's instructions into the registers each tick writes. + + The project counts noise periods from the slowest, and the register counts them from the + fastest, so a period reaches the register as its complement. The mode bit rides above it, + selecting the shift register's short 93-step cycle. + + Args: + instructions: The channel's per-tick instructions. + + Returns: + List[NoiseRegisters]: One register set per tick, including the closing release tick. + """ + _, periods, volumes, modes = NoiseExporter.extract_data(instructions) + + registers: List[NoiseRegisters] = [] + for index, volume in enumerate(volumes): + period = hold(periods, index) + mode = hold(modes, index) + registers.append( + cls( + control=SUSTAINED_LEVEL | volume, + period=(mode << NOISE_MODE_SHIFT) | (MAX_PERIOD - period), + ) + ) + + return registers diff --git a/src/sampletones_player/registers/pulse.py b/src/sampletones_player/registers/pulse.py new file mode 100644 index 000000000..f0ca0a5a3 --- /dev/null +++ b/src/sampletones_player/registers/pulse.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Dict, List, Tuple + +from pydantic import Field + +from sampletones_core.exporters.implementation.pulse import PulseExporter +from sampletones_core.instructions import PulseInstruction +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.registers.hold import hold +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + MAX_REGISTER_VALUE, + MAX_TIMER_HIGH, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, +) + + +class PulseRegisters(ChannelRegisters): + control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) + + @property + def values(self) -> Tuple[int, ...]: + return (self.control, self.timer_low, self.timer_high) + + @classmethod + def from_instructions( + cls, + instructions: List[PulseInstruction], + timer_table: Dict[int, int], + ) -> List[PulseRegisters]: + """Turns a pulse channel's instructions into the registers each tick writes. + + Volume rides in the control byte's low nibble, so a rest keeps its pitch and duty cycle + and sets the level to zero. Holding the period across a rest is what lets the driver leave + the timer's high byte alone, and leaving it alone is what keeps the waveform's phase + running the way a rendered channel does. + + Args: + instructions: The channel's per-tick instructions. + timer_table: The timer register value each pitch sounds at. + + Returns: + List[PulseRegisters]: One register set per tick, including the closing release tick. + """ + _, pitches, volumes, duty_cycles = PulseExporter.extract_data(instructions) + + registers: List[PulseRegisters] = [] + for index, volume in enumerate(volumes): + timer = timer_table[hold(pitches, index)] + duty_cycle = hold(duty_cycles, index) + registers.append( + cls( + control=(duty_cycle << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | volume, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + ) + ) + + return registers diff --git a/src/sampletones_player/registers/streams.py b/src/sampletones_player/registers/streams.py new file mode 100644 index 000000000..2a55c19e3 --- /dev/null +++ b/src/sampletones_player/registers/streams.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.registers.hold import hold +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.triangle import TriangleRegisters +from sampletones_player.specification.channels import CHANNEL_ORDER + + +class ChannelStreams(BaseModel): + """The per-tick register values of all four channels, together the whole of what a song plays. + + A channel's stream ends where its instructions do, and an exporter closes a channel that was + still sounding with one silent tick, so the four streams reach the same tick give or take that + one. The song therefore lasts as long as its longest channel, and a channel that runs out + first holds its final values — silent ones, every stream ending on a rest — through the ticks + that remain. + + Attributes: + pulse1: The first pulse channel's stream. + pulse2: The second pulse channel's stream. + triangle: The triangle channel's stream. + noise: The noise channel's stream. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + pulse1: Tuple[PulseRegisters, ...] + pulse2: Tuple[PulseRegisters, ...] + triangle: Tuple[TriangleRegisters, ...] + noise: Tuple[NoiseRegisters, ...] + + @model_validator(mode="after") + def _validate_every_channel_reaches_a_tick(self) -> ChannelStreams: + empty = tuple(channel.value for channel, stream in zip(CHANNEL_ORDER, self.ordered) if not stream) + if empty: + raise ValueError(f"every channel needs at least one tick, and {', '.join(empty)} has none") + + return self + + @property + def ordered(self) -> Tuple[Tuple[ChannelRegisters, ...], ...]: + """The four streams in the order :data:`CHANNEL_ORDER` states.""" + return (self.pulse1, self.pulse2, self.triangle, self.noise) + + @property + def ticks(self) -> int: + """The ticks the song lasts, the longest channel stating the length.""" + return max(len(stream) for stream in self.ordered) + + @property + def padded(self) -> Tuple[Tuple[ChannelRegisters, ...], ...]: + """The four streams each carried to the song's full length, ready to serialise. + + Every channel reaching the same tick count is what lets the driver read a record by + multiplying the tick by the channel's record size. + """ + return tuple(tuple(hold(stream, tick) for tick in range(self.ticks)) for stream in self.ordered) + + def at(self, tick: int) -> Tuple[ChannelRegisters, ...]: + """Each channel's registers at ``tick``, a channel past its end holding its final values. + + Args: + tick: The tick to read, counted from 0. + + Returns: + Tuple[ChannelRegisters, ...]: One register set per channel, in channel order. + """ + return tuple(hold(stream, tick) for stream in self.ordered) diff --git a/src/sampletones_player/registers/triangle.py b/src/sampletones_player/registers/triangle.py new file mode 100644 index 000000000..88a3a3724 --- /dev/null +++ b/src/sampletones_player/registers/triangle.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Dict, List, Tuple + +from pydantic import Field + +from sampletones_core.exporters.implementation.triangle import TriangleExporter +from sampletones_core.instructions import TriangleInstruction +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.registers.hold import hold +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + MAX_TIMER_HIGH, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, +) + + +class TriangleRegisters(ChannelRegisters): + linear_counter: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) + + @property + def values(self) -> Tuple[int, ...]: + return (self.linear_counter, self.timer_low, self.timer_high) + + @classmethod + def from_instructions( + cls, + instructions: List[TriangleInstruction], + timer_table: Dict[int, int], + ) -> List[TriangleRegisters]: + """Turns a triangle channel's instructions into the registers each tick writes. + + The triangle sounds at one level, so a tick states whether it sounds through the linear + counter's reload value: a full reload keeps the waveform running, and a reload of zero + holds it silent. The control bit stays set throughout, which is what makes the counter + reload every frame and the note last as long as the ticks do. + + The timer is written from the instruction's pitch directly, and the channel sounds an + octave below it — the same octave a rendered triangle sounds. + + Args: + instructions: The channel's per-tick instructions. + timer_table: The timer register value each pitch sounds at. + + Returns: + List[TriangleRegisters]: One register set per tick, including the closing release tick. + """ + _, pitches, volumes = TriangleExporter.extract_data(instructions) + + registers: List[TriangleRegisters] = [] + for index, volume in enumerate(volumes): + timer = timer_table[hold(pitches, index)] + reload_value = TRIANGLE_SOUNDING_RELOAD if volume > 0 else TRIANGLE_SILENT_RELOAD + registers.append( + cls( + linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + ) + ) + + return registers diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py new file mode 100644 index 000000000..c82588f9c --- /dev/null +++ b/src/sampletones_player/song.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Optional + +from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.registers.streams import ChannelStreams + + +@dataclass(frozen=True) +class Song: + """A reconstruction as the player holds it: the four streams, the clock, and where it repeats. + + Attributes: + streams: The per-tick register values every channel plays. + schedule: The engine ticks each play call advances the streams by. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + """ + + streams: ChannelStreams + schedule: PlaySchedule + loop_tick: Optional[int] + + def __post_init__(self) -> None: + if self.loop_tick is not None and not 0 <= self.loop_tick < self.ticks: + raise ValueError(f"loop_tick must lie within the song's {self.ticks} ticks, got {self.loop_tick}") + + @property + def ticks(self) -> int: + """The ticks the song lasts.""" + return self.streams.ticks + + def tick_at(self, play_call: int) -> Optional[int]: + """The tick the call at ``play_call`` leaves the streams on. + + A song that runs past its end either returns to its loop tick and keeps going, or stops + and leaves the channels holding the silent values its final tick wrote. + + Args: + play_call: The call's position in the run, counted from 0. + + Returns: + Optional[int]: The tick to play, or ``None`` once a song without a loop has ended. + """ + tick = self.schedule.ticks_at(play_call + 1) + if tick < self.ticks: + return tick + + if self.loop_tick is None: + return None + + return self.loop_tick + (tick - self.ticks) % (self.ticks - self.loop_tick) diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py new file mode 100644 index 000000000..9c8df5f45 --- /dev/null +++ b/src/sampletones_player/specification/channels.py @@ -0,0 +1,30 @@ +from typing import Dict, Final, Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_player.specification.registers import ( + NOISE_CONTROL, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + PULSE2_TIMER_HIGH, + PULSE2_TIMER_LOW, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) + +CHANNEL_ORDER: Final[Tuple[GeneratorName, ...]] = ( + GeneratorName.PULSE1, + GeneratorName.PULSE2, + GeneratorName.TRIANGLE, + GeneratorName.NOISE, +) + +CHANNEL_REGISTER_ADDRESSES: Final[Dict[GeneratorName, Tuple[int, ...]]] = { + GeneratorName.PULSE1: (PULSE1_CONTROL, PULSE1_TIMER_LOW, PULSE1_TIMER_HIGH), + GeneratorName.PULSE2: (PULSE2_CONTROL, PULSE2_TIMER_LOW, PULSE2_TIMER_HIGH), + GeneratorName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH), + GeneratorName.NOISE: (NOISE_CONTROL, NOISE_PERIOD), +} diff --git a/src/sampletones_player/specification/registers.py b/src/sampletones_player/specification/registers.py index 06ecaab92..e44e09c75 100644 --- a/src/sampletones_player/specification/registers.py +++ b/src/sampletones_player/specification/registers.py @@ -1,4 +1,4 @@ -from typing import Final +from typing import Final, FrozenSet from sampletones_core.constants.general import MAX_TIMER @@ -38,3 +38,15 @@ CHANNELS_ENABLED: Final[int] = 0x0F FRAME_COUNTER_SEQUENCE: Final[int] = 0x40 + +FIRST_CHANNEL_REGISTER: Final[int] = 0x4000 +LAST_CHANNEL_REGISTER: Final[int] = 0x4013 +SILENCED_REGISTER: Final[int] = 0x00 + +REGISTERS_WRITTEN_ON_CHANGE: Final[FrozenSet[int]] = frozenset( + { + PULSE1_TIMER_HIGH, + PULSE2_TIMER_HIGH, + TRIANGLE_TIMER_HIGH, + } +) diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py new file mode 100644 index 000000000..bb2c8ed07 --- /dev/null +++ b/src/sampletones_player/specification/song.py @@ -0,0 +1,15 @@ +from typing import Final + +from sampletones_player.specification.channels import CHANNEL_ORDER + +WORD_SIZE: Final[int] = 2 + +STEP_WHOLE_OFFSET: Final[int] = 0 +STEP_FRACTION_OFFSET: Final[int] = STEP_WHOLE_OFFSET + 1 +TOTAL_TICKS_OFFSET: Final[int] = STEP_FRACTION_OFFSET + WORD_SIZE +LOOP_TICK_OFFSET: Final[int] = TOTAL_TICKS_OFFSET + WORD_SIZE +STREAM_OFFSETS_OFFSET: Final[int] = LOOP_TICK_OFFSET + WORD_SIZE +SONG_HEADER_SIZE: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * len(CHANNEL_ORDER) + +NO_LOOP: Final[int] = 0xFFFF +MAX_STREAM_OFFSET: Final[int] = 0xFFFF diff --git a/src/sampletones_player/trace/__init__.py b/src/sampletones_player/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py new file mode 100644 index 000000000..f463665a3 --- /dev/null +++ b/src/sampletones_player/trace/trace.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, List, Tuple + +from sampletones_player.song import Song +from sampletones_player.specification.channels import CHANNEL_ORDER, CHANNEL_REGISTER_ADDRESSES +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + APU_STATUS, + CHANNELS_ENABLED, + FIRST_CHANNEL_REGISTER, + FRAME_COUNTER_SEQUENCE, + LAST_CHANNEL_REGISTER, + PULSE1_SWEEP, + PULSE2_SWEEP, + REGISTERS_WRITTEN_ON_CHANGE, + SILENCED_REGISTER, + SWEEP_DISABLED, +) +from sampletones_player.trace.write import RegisterWrite + +FIRST_TICK: Final[int] = 0 + + +@dataclass(frozen=True) +class RegisterTrace: + """Every APU register write a run of the driver makes, grouped by the call that makes it. + + This is the contract the assembly is written against: initialisation clears the channels, + enables them and sounds the song's first tick, and each play call afterwards either advances + the streams and writes the tick it lands on, or leaves the console alone. The three registers + that reset a running channel are written only where their value changes, which is what keeps + a pulse waveform's phase running across a rest the way a rendered channel does. + + Attributes: + initialisation: The writes the init routine makes, leaving the console on the song's + first tick. + play_calls: The writes each play call makes, one entry per call, and an empty one for a + call the streams hold their tick through. + """ + + initialisation: Tuple[RegisterWrite, ...] + play_calls: Tuple[Tuple[RegisterWrite, ...], ...] + + @staticmethod + def _tick_writes( + song: Song, + tick: int, + shadows: Dict[int, int], + ) -> Tuple[RegisterWrite, ...]: + writes: List[RegisterWrite] = [] + for channel, registers in zip(CHANNEL_ORDER, song.streams.at(tick)): + for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], registers.values): + if address in REGISTERS_WRITTEN_ON_CHANGE: + if shadows.get(address) == value: + continue + + shadows[address] = value + + writes.append(RegisterWrite(address, value)) + + return tuple(writes) + + @classmethod + def _initialisation_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[RegisterWrite, ...]: + writes = [ + RegisterWrite(address, SILENCED_REGISTER) + for address in range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1) + ] + writes.append(RegisterWrite(APU_STATUS, CHANNELS_ENABLED)) + writes.append(RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE)) + writes.append(RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED)) + writes.append(RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED)) + writes.extend(cls._tick_writes(song, FIRST_TICK, shadows)) + return tuple(writes) + + @classmethod + def from_song(cls, song: Song, play_calls: int) -> RegisterTrace: + """States every APU write a correct driver makes over a run of play calls. + + Args: + song: The streams, the clock and the loop point the driver plays. + play_calls: How many play calls the run covers, at least 0. + + Returns: + RegisterTrace: The initialisation writes and the writes of every call in the run. + + Raises: + ValueError: If ``play_calls`` is negative. + """ + if play_calls < 0: + raise ValueError(f"play_calls must be at least 0, got {play_calls}") + + shadows: Dict[int, int] = {} + initialisation = cls._initialisation_writes(song, shadows) + + calls: List[Tuple[RegisterWrite, ...]] = [] + for play_call in range(play_calls): + tick = song.tick_at(play_call) + if tick is None or song.schedule.advance_at(play_call) == 0: + calls.append(()) + continue + + calls.append(cls._tick_writes(song, tick, shadows)) + + return cls( + initialisation=initialisation, + play_calls=tuple(calls), + ) diff --git a/src/sampletones_player/trace/write.py b/src/sampletones_player/trace/write.py new file mode 100644 index 000000000..ef07da300 --- /dev/null +++ b/src/sampletones_player/trace/write.py @@ -0,0 +1,13 @@ +from typing import NamedTuple + + +class RegisterWrite(NamedTuple): + """One store the driver makes to an APU register. + + Attributes: + address: The register the store lands on. + value: The byte it carries. + """ + + address: int + value: int diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 169cd46b2..9ae9b5c06 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -15,6 +15,7 @@ NoLibraryDataError, UnhandledLibraryError, ) +from .player import PlayerError, SongTooLargeError from .project import ( IncompatibleProjectVersionError, IncorrectReconstructionDataError, @@ -72,9 +73,11 @@ "NoLibraryDataError", "NotAValidArchiveError", "PlaybackError", + "PlayerError", "ReconstructionError", "SampleToNESError", "SerializationError", + "SongTooLargeError", "UnhandledLibraryError", "UnhandledProjectError", "UnhandledReconstructionError", diff --git a/src/sampletones_shared/exceptions/player.py b/src/sampletones_shared/exceptions/player.py new file mode 100644 index 000000000..bbeab7cf8 --- /dev/null +++ b/src/sampletones_shared/exceptions/player.py @@ -0,0 +1,9 @@ +from .base import SampleToNESError + + +class PlayerError(SampleToNESError): + """Base class for NES player errors.""" + + +class SongTooLargeError(PlayerError): + """Raised when a song's data outgrows the space the player has for it.""" diff --git a/tests/suite/player.py b/tests/suite/player.py new file mode 100644 index 000000000..b63533c66 --- /dev/null +++ b/tests/suite/player.py @@ -0,0 +1,123 @@ +from typing import Dict, Final, Optional, Sequence + +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.instructions import PulseInstruction +from sampletones_core.timers.arithmetic import frequency_to_timer +from sampletones_core.utils.frequencies import pitch_to_frequency +from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.registers.triangle import TriangleRegisters +from sampletones_player.song import Song +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + MAX_REGISTER_VALUE, + NOISE_MODE_SHIFT, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, +) + +PLAYER_REFERENCE_TIMER: Final[int] = 0x154 +PLAYER_OCTAVE_UP_TIMER: Final[int] = PLAYER_REFERENCE_TIMER // 2 +PLAYER_REFERENCE_PERIOD: Final[int] = 0x0A +PLAYER_FULL_VOLUME: Final[int] = 15 +PLAYER_SILENT_VOLUME: Final[int] = 0 + + +def pulse_tick( + volume: int, + duty_cycle: int, + timer: int, +) -> PulseRegisters: + """A pulse channel's registers for one tick, spelled the way the encoder spells them.""" + return PulseRegisters( + control=(duty_cycle << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | volume, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + ) + + +def triangle_tick(sounding: bool, timer: int) -> TriangleRegisters: + """A triangle channel's registers for one tick, spelled the way the encoder spells them.""" + reload_value = TRIANGLE_SOUNDING_RELOAD if sounding else TRIANGLE_SILENT_RELOAD + return TriangleRegisters( + linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + ) + + +def noise_tick( + volume: int, + mode: int, + register_period: int, +) -> NoiseRegisters: + """A noise channel's registers for one tick, its period counted the way ``$400E`` counts it.""" + return NoiseRegisters( + control=SUSTAINED_LEVEL | volume, + period=(mode << NOISE_MODE_SHIFT) | register_period, + ) + + +def player_streams( + pulse1: Sequence[PulseRegisters], + pulse2: Sequence[PulseRegisters], + triangle: Sequence[TriangleRegisters], + noise: Sequence[NoiseRegisters], +) -> ChannelStreams: + return ChannelStreams( + pulse1=tuple(pulse1), + pulse2=tuple(pulse2), + triangle=tuple(triangle), + noise=tuple(noise), + ) + + +def resting_streams(pulse1: Sequence[PulseRegisters]) -> ChannelStreams: + """Streams where one pulse channel carries the song and the other three rest on a single tick.""" + return player_streams( + pulse1=pulse1, + pulse2=(pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER),), + triangle=(triangle_tick(False, PLAYER_REFERENCE_TIMER),), + noise=(noise_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_PERIOD),), + ) + + +def player_song( + streams: ChannelStreams, + nes_frequency: int, + loop_tick: Optional[int], +) -> Song: + return Song( + streams=streams, + schedule=PlaySchedule.from_parameters(nes_frequency), + loop_tick=loop_tick, + ) + + +PLAYER_TIMER_TABLE: Final[Dict[int, int]] = { + pitch: frequency_to_timer(pitch_to_frequency(pitch)) for pitch in range(MIN_PITCH, MAX_PITCH + 1) +} +PLAYER_REFERENCE_PITCH: Final[int] = 69 +PLAYER_PULSE_TIMER_MUTE_FLOOR: Final[int] = 8 + + +def sounding_pulse( + pitch: int, + volume: int, + duty_cycle: int, +) -> PulseInstruction: + return PulseInstruction( + on=True, + pitch=pitch, + volume=volume, + duty_cycle=duty_cycle, + ) + + +def silent_pulse() -> PulseInstruction: + return PulseInstruction.null_instruction() diff --git a/tests/unit/sampletones_core/formats/__init__.py b/tests/unit/sampletones_core/formats/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/formats/famitracker/test_binary.py b/tests/unit/sampletones_core/formats/famitracker/test_binary.py index b07743490..b6bf6549e 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_binary.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_binary.py @@ -1,90 +1,23 @@ import struct -from dataclasses import dataclass -from typing import List import pytest -from sampletones_core.formats.famitracker.binary import BinaryWriter +from sampletones_core.formats.famitracker.binary import FamiTrackerWriter from sampletones_core.formats.famitracker.specification.blocks import ( BLOCK_NAME_LENGTH, Block, ) -@dataclass -class IntegerCase: - method: str - format: str - value: int - - -INTEGER_CASES: List[IntegerCase] = [ - IntegerCase("write_uint8", " None: - writer = BinaryWriter() - getattr(writer, case.method)(case.value) - (unpacked,) = struct.unpack(case.format, writer.data) - assert unpacked == case.value - - def test_writes_are_little_endian(self) -> None: - writer = BinaryWriter() - writer.write_uint32(0x0440) - assert writer.data == b"\x40\x04\x00\x00" - - def test_length_tracks_written_bytes(self) -> None: - writer = BinaryWriter() - writer.write_uint8(1) - writer.write_uint32(2) - assert len(writer) == 5 - - -class TestStringPrimitives: - def test_fixed_string_pads_with_nul(self) -> None: - writer = BinaryWriter() - writer.write_fixed_string("abc", 8) - assert writer.data == b"abc\x00\x00\x00\x00\x00" - assert len(writer) == 8 - - def test_fixed_string_truncates_to_length(self) -> None: - writer = BinaryWriter() - writer.write_fixed_string("abcdefgh", 4) - assert writer.data == b"abcd" - - def test_counted_string_prefixes_length(self) -> None: - writer = BinaryWriter() - writer.write_counted_string("hi") - length = struct.unpack_from(" None: - writer = BinaryWriter() - writer.write_terminated_string("note") - assert writer.data == b"note\x00" - - class TestBlockFraming: def test_block_name_padded_to_fixed_length(self) -> None: - writer = BinaryWriter() + writer = FamiTrackerWriter() with writer.block(Block("PARAMS", 6)): pass assert writer.data[:BLOCK_NAME_LENGTH] == b"PARAMS".ljust(BLOCK_NAME_LENGTH, b"\x00") def test_block_header_carries_version_and_size(self) -> None: - writer = BinaryWriter() + writer = FamiTrackerWriter() with writer.block(Block("INFO", 1)) as body: body.write_uint8(7) body.write_uint8(9) @@ -94,15 +27,23 @@ def test_block_header_carries_version_and_size(self) -> None: assert size == 2 def test_block_payload_follows_header(self) -> None: - writer = BinaryWriter() + writer = FamiTrackerWriter() with writer.block(Block("INFO", 1)) as body: body.write_uint8(7) body.write_uint8(9) payload_offset = BLOCK_NAME_LENGTH + 8 assert writer.data[payload_offset:] == b"\x07\x09" + def test_a_nested_block_frames_inside_its_parent(self) -> None: + writer = FamiTrackerWriter() + with writer.block(Block("INFO", 1)) as body: + with body.block(Block("PARAMS", 2)) as inner: + inner.write_uint8(7) + size = struct.unpack_from(" None: - writer = BinaryWriter() + writer = FamiTrackerWriter() with pytest.raises(ValueError): with writer.block(Block("N" * (BLOCK_NAME_LENGTH + 1), 1)): pass diff --git a/tests/unit/sampletones_core/formats/test_binary.py b/tests/unit/sampletones_core/formats/test_binary.py new file mode 100644 index 000000000..8c0a99219 --- /dev/null +++ b/tests/unit/sampletones_core/formats/test_binary.py @@ -0,0 +1,94 @@ +import struct +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_core.formats.binary import BinaryWriter +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestIntegerPrimitives(BaseTestSuite): + """Each named write packs the width and signedness its name states.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: bytes + method: str + value: int + + @property + def label(self) -> str: + return f"{self.method}-{self.value}" + + test_cases: Tuple[TestCase, ...] = ( + TestCase(method="write_uint8", value=0, expected=b"\x00"), + TestCase(method="write_uint8", value=255, expected=b"\xff"), + TestCase(method="write_int8", value=-128, expected=b"\x80"), + TestCase(method="write_int8", value=127, expected=b"\x7f"), + TestCase(method="write_uint16", value=0, expected=b"\x00\x00"), + TestCase(method="write_uint16", value=0x0440, expected=b"\x40\x04"), + TestCase(method="write_uint16", value=65535, expected=b"\xff\xff"), + TestCase(method="write_uint32", value=0x0440, expected=b"\x40\x04\x00\x00"), + TestCase(method="write_uint32", value=4294967295, expected=b"\xff\xff\xff\xff"), + TestCase(method="write_int32", value=-1, expected=b"\xff\xff\xff\xff"), + TestCase(method="write_int32", value=2147483647, expected=b"\xff\xff\xff\x7f"), + ) + + @staticmethod + def write(test_case: TestCase) -> BinaryWriter: + writer = BinaryWriter() + { + "write_uint8": writer.write_uint8, + "write_int8": writer.write_int8, + "write_uint16": writer.write_uint16, + "write_uint32": writer.write_uint32, + "write_int32": writer.write_int32, + }[test_case.method](test_case.value) + return writer + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_bytes_are_little_endian(self, test_case: TestCase) -> None: + assert self.write(test_case).data == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_field_takes_the_width_its_name_states(self, test_case: TestCase) -> None: + assert len(self.write(test_case)) == len(test_case.expected) + + def test_a_value_above_the_field_raises(self) -> None: + writer = BinaryWriter() + with pytest.raises(struct.error): + writer.write_uint16(65536) + + def test_the_length_tracks_every_write(self) -> None: + writer = BinaryWriter() + writer.write_uint8(1) + writer.write_uint16(2) + writer.write_uint32(3) + assert len(writer) == 7 + + +class TestStringPrimitives: + def test_fixed_string_pads_with_nul(self) -> None: + writer = BinaryWriter() + writer.write_fixed_string("abc", 8) + assert writer.data == b"abc\x00\x00\x00\x00\x00" + assert len(writer) == 8 + + def test_fixed_string_truncates_to_length(self) -> None: + writer = BinaryWriter() + writer.write_fixed_string("abcdefgh", 4) + assert writer.data == b"abcd" + + def test_counted_string_prefixes_length(self) -> None: + writer = BinaryWriter() + writer.write_counted_string("hi") + length = struct.unpack_from(" None: + writer = BinaryWriter() + writer.write_terminated_string("note") + assert writer.data == b"note\x00" diff --git a/tests/unit/sampletones_player/clock/__init__.py b/tests/unit/sampletones_player/clock/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/test_clock.py b/tests/unit/sampletones_player/clock/test_schedule.py similarity index 94% rename from tests/unit/sampletones_player/test_clock.py rename to tests/unit/sampletones_player/clock/test_schedule.py index 8574bb5c5..9ea3664c4 100644 --- a/tests/unit/sampletones_player/test_clock.py +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -5,9 +5,8 @@ from typing import Final, Tuple import pytest -from pydantic import ValidationError -from sampletones_player.clock.schedule import FixedPointStep, PlaySchedule +from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.specification.clock import ( FIXED_POINT_SCALE, MAX_STEP_WHOLE, @@ -243,13 +242,3 @@ def test_a_negative_call_index_is_rejected(self) -> None: schedule = PlaySchedule.from_parameters(60) with pytest.raises(ValueError, match="play_calls must be at least 0"): schedule.advance_at(-1) - - @pytest.mark.parametrize("fraction", (-1, FIXED_POINT_SCALE)) - def test_a_fraction_outside_the_word_is_rejected(self, fraction: int) -> None: - with pytest.raises(ValidationError): - FixedPointStep(whole=0, fraction=fraction) - - @pytest.mark.parametrize("whole", (-1, MAX_STEP_WHOLE + 1)) - def test_a_whole_part_outside_the_byte_is_rejected(self, whole: int) -> None: - with pytest.raises(ValidationError): - FixedPointStep(whole=whole, fraction=0) diff --git a/tests/unit/sampletones_player/clock/test_step.py b/tests/unit/sampletones_player/clock/test_step.py new file mode 100644 index 000000000..35572dbef --- /dev/null +++ b/tests/unit/sampletones_player/clock/test_step.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_player.clock.step import FixedPointStep +from sampletones_player.specification.clock import ( + FIXED_POINT_SCALE, + MAX_STEP_FRACTION, + MAX_STEP_WHOLE, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestFixedPointStep(BaseTestSuite): + """The whole byte and the word the driver adds into its accumulator.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + fields: Tuple[int, int] + + @property + def label(self) -> str: + whole, fraction = self.fields + return f"{whole}-{fraction}" + + test_cases = ( + TestCase(fields=(0, 0), expected=0), + TestCase(fields=(0, MAX_STEP_FRACTION), expected=MAX_STEP_FRACTION), + TestCase(fields=(1, 0), expected=FIXED_POINT_SCALE), + TestCase(fields=(3, 21837), expected=3 * FIXED_POINT_SCALE + 21837), + TestCase(fields=(MAX_STEP_WHOLE, MAX_STEP_FRACTION), expected=FIXED_POINT_SCALE * (MAX_STEP_WHOLE + 1) - 1), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_value_composes_the_fields(self, test_case: TestCase) -> None: + whole, fraction = test_case.fields + assert FixedPointStep(whole=whole, fraction=fraction).value == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_fields_read_back_off_the_value(self, test_case: TestCase) -> None: + whole, fraction = test_case.fields + step = FixedPointStep(whole=whole, fraction=fraction) + assert divmod(step.value, FIXED_POINT_SCALE) == (step.whole, step.fraction) + + @pytest.mark.parametrize("fraction", (-1, FIXED_POINT_SCALE)) + def test_a_fraction_outside_the_word_is_rejected(self, fraction: int) -> None: + with pytest.raises(ValidationError): + FixedPointStep(whole=0, fraction=fraction) + + @pytest.mark.parametrize("whole", (-1, MAX_STEP_WHOLE + 1)) + def test_a_whole_part_outside_the_byte_is_rejected(self, whole: int) -> None: + with pytest.raises(ValidationError): + FixedPointStep(whole=whole, fraction=0) diff --git a/tests/unit/sampletones_player/nsf/__init__.py b/tests/unit/sampletones_player/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py new file mode 100644 index 000000000..c2ec7bbe1 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -0,0 +1,176 @@ +import struct +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.channels import CHANNEL_ORDER +from sampletones_player.specification.song import ( + LOOP_TICK_OFFSET, + MAX_STREAM_OFFSET, + NO_LOOP, + SONG_HEADER_SIZE, + STEP_FRACTION_OFFSET, + STEP_WHOLE_OFFSET, + STREAM_OFFSETS_OFFSET, + TOTAL_TICKS_OFFSET, + WORD_SIZE, +) +from sampletones_shared.exceptions import SongTooLargeError +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + player_song, + pulse_tick, + resting_streams, +) + +NTSC_FREQUENCY: Final[int] = 60 +HALF_RATE_FREQUENCY: Final[int] = 30 +PROGRAM_AREA_BYTES: Final[int] = 0x8000 +UNBOUNDED_SPACE: Final[int] = MAX_STREAM_OFFSET * len(CHANNEL_ORDER) + +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + + +def read_word(data: bytes, offset: int) -> int: + return int(struct.unpack_from(" Tuple[int, ...]: + return tuple(read_word(data, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(CHANNEL_ORDER))) + + +def two_tick_song(nes_frequency: int) -> Song: + return player_song(resting_streams((SOUNDING, RESTING)), nes_frequency, loop_tick=None) + + +class TestSongBytes: + """The exact bytes a hand-built song serialises to. + + The layout is the contract the driver reads the song through, so the literal states it in + full: a fifteen-byte header, then each channel's records back to back in channel order. + """ + + EXPECTED: Final[bytes] = ( + b"\x00\xff\x7f" + b"\x02\x00" + b"\xff\xff" + b"\x0f\x00\x15\x00\x1b\x00\x21\x00" + b"\x3f\x54\x01\x30\x54\x01" + b"\x30\x54\x01\x30\x54\x01" + b"\x80\x54\x01\x80\x54\x01" + b"\x30\x0a\x30\x0a" + ) + + def test_the_song_serialises_to_the_expected_bytes(self) -> None: + assert song_to_bytes(two_tick_song(HALF_RATE_FREQUENCY), PROGRAM_AREA_BYTES) == self.EXPECTED + + def test_the_streams_begin_where_the_header_ends(self) -> None: + assert stream_offsets(self.EXPECTED)[0] == SONG_HEADER_SIZE + + +class TestSongHeader(BaseTestSuite): + """Each header field carries the value its offset is read for.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + name: str + nes_frequency: int + + @property + def label(self) -> str: + return self.name + + test_cases: Tuple[TestCase, ...] = ( + TestCase(name="60hz-whole", nes_frequency=NTSC_FREQUENCY, expected=0), + TestCase(name="30hz-whole", nes_frequency=HALF_RATE_FREQUENCY, expected=0), + TestCase(name="120hz-whole", nes_frequency=120, expected=1), + TestCase(name="300hz-whole", nes_frequency=300, expected=4), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_step_reaches_the_header_as_the_driver_holds_it(self, test_case: TestCase) -> None: + song = two_tick_song(test_case.nes_frequency) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + step = song.schedule.fixed_point_step + assert data[STEP_WHOLE_OFFSET] == test_case.expected + assert data[STEP_WHOLE_OFFSET] == step.whole + assert read_word(data, STEP_FRACTION_OFFSET) == step.fraction + + def test_the_header_states_the_songs_length(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING)), NTSC_FREQUENCY, loop_tick=None) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + assert read_word(data, TOTAL_TICKS_OFFSET) == song.ticks + + def test_a_song_that_stops_states_no_loop(self) -> None: + data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) + assert read_word(data, LOOP_TICK_OFFSET) == NO_LOOP + + def test_a_song_that_repeats_states_its_loop_tick(self) -> None: + song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=1) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + assert read_word(data, LOOP_TICK_OFFSET) == 1 + + +class TestStreamOffsets: + """Every channel's stream is found where the header says it is.""" + + def test_the_first_stream_begins_past_the_header(self) -> None: + data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) + assert stream_offsets(data)[0] == SONG_HEADER_SIZE + + def test_the_offsets_ascend_in_channel_order(self) -> None: + data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) + offsets = stream_offsets(data) + assert list(offsets) == sorted(offsets) + + def test_each_offset_lands_on_that_channels_first_record(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + for offset, stream in zip(stream_offsets(data), song.streams.padded): + assert tuple(data[offset : offset + len(stream[0].values)]) == stream[0].values + + def test_the_streams_fill_the_song_to_its_last_byte(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + records = sum(len(registers.values) for stream in song.streams.padded for registers in stream) + assert len(data) == SONG_HEADER_SIZE + records + + def test_a_shorter_channel_is_written_to_the_songs_length(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING)), NTSC_FREQUENCY, loop_tick=None) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + offsets = stream_offsets(data) + noise_bytes = data[offsets[3] :] + assert noise_bytes == bytes(song.streams.noise[0].values) * song.ticks + + +class TestSongTooLarge: + """A song that outgrows what the header or the console can hold names the overflow.""" + + def test_a_song_past_the_available_space_raises(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + with pytest.raises(SongTooLargeError): + song_to_bytes(song, len(data) - 1) + + def test_a_song_filling_the_available_space_exactly_is_written(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + assert song_to_bytes(song, len(data)) == data + + def test_a_song_reaching_past_the_offset_field_raises(self) -> None: + ticks = MAX_STREAM_OFFSET // len(SOUNDING.values) + 1 + song = player_song(resting_streams((SOUNDING,) * ticks), NTSC_FREQUENCY, loop_tick=None) + with pytest.raises(SongTooLargeError, match=GeneratorName.PULSE2.value): + song_to_bytes(song, UNBOUNDED_SPACE) diff --git a/tests/unit/sampletones_player/registers/__init__.py b/tests/unit/sampletones_player/registers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/registers/test_noise.py b/tests/unit/sampletones_player/registers/test_noise.py new file mode 100644 index 000000000..038e0397d --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_noise.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME +from sampletones_core.instructions import NoiseInstruction +from sampletones_player.registers.noise import NoiseRegisters +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestNoiseRegisters(BaseTestSuite): + """The project counts noise periods from the slowest and the register from the fastest.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, int] + period: int + short: bool + + @property + def label(self) -> str: + mode = "short" if self.short else "normal" + return f"period_{self.period}_{mode}" + + test_cases = ( + TestCase(period=0, short=False, expected=(0x3F, MAX_PERIOD)), + TestCase(period=MAX_PERIOD, short=False, expected=(0x3F, 0)), + TestCase(period=0, short=True, expected=(0x3F, 0x80 | MAX_PERIOD)), + TestCase(period=MAX_PERIOD, short=True, expected=(0x3F, 0x80)), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_registers_match(self, test_case: TestCase) -> None: + instruction = NoiseInstruction( + on=True, + period=test_case.period, + volume=MAX_VOLUME, + short=test_case.short, + ) + registers = NoiseRegisters.from_instructions([instruction]) + assert registers[0].values == test_case.expected + + def test_rest_clears_the_volume_and_keeps_the_period(self) -> None: + instructions = [ + NoiseInstruction(on=True, period=4, volume=MAX_VOLUME, short=False), + NoiseInstruction.null_instruction(), + ] + sounding, resting = NoiseRegisters.from_instructions(instructions)[:2] + assert resting.control & 0x0F == 0 + assert resting.period == sounding.period diff --git a/tests/unit/sampletones_player/registers/test_pulse.py b/tests/unit/sampletones_player/registers/test_pulse.py new file mode 100644 index 000000000..8cd0b3f69 --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_pulse.py @@ -0,0 +1,136 @@ +from dataclasses import dataclass +from typing import List + +import pytest + +from sampletones_core.constants.general import ( + MAX_DUTY_CYCLE, + MAX_PITCH, + MAX_TIMER, + MAX_VOLUME, + MIN_PITCH, +) +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.player import ( + PLAYER_PULSE_TIMER_MUTE_FLOOR, + PLAYER_REFERENCE_PITCH, + PLAYER_TIMER_TABLE, + silent_pulse, + sounding_pulse, +) + + +class TestPulseTimerRange(BaseTestSuite): + """Every pitch a channel may sound reaches a timer the hardware plays. + + The APU silences a pulse channel below timer 8 and the register holds 11 bits, so the + playable pitch range has to land between those two bounds for the driver to sound it. + """ + + @pytest.mark.parametrize("pitch", [MIN_PITCH, PLAYER_REFERENCE_PITCH, MAX_PITCH]) + def test_timer_lies_within_the_audible_register_range(self, pitch: int) -> None: + timer = PLAYER_TIMER_TABLE[pitch] + assert PLAYER_PULSE_TIMER_MUTE_FLOOR <= timer <= MAX_TIMER + + def test_timer_splits_into_a_byte_and_three_bits(self) -> None: + instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)] + registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) + timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert registers[0].timer_low == timer & MAX_REGISTER_VALUE + assert registers[0].timer_high == timer >> TIMER_HIGH_SHIFT + + +class TestPulseTickRecord: + """A tick states its values in the order the driver moves them to its channel.""" + + def test_a_tick_states_control_then_timer(self) -> None: + instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE)] + registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[0] + timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) + + +class TestPulseControlByte(BaseTestSuite): + """The expected bytes are the values the APU reads from ``$4000``. + + A duty cycle occupies the top two bits, the length-halt and constant-volume bits sit + below them, and the level fills the low nibble. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + duty_cycle: int + volume: int + + @property + def label(self) -> str: + return f"duty_{self.duty_cycle}_volume_{self.volume}" + + test_cases = ( + TestCase(duty_cycle=0, volume=MAX_VOLUME, expected=0x3F), + TestCase(duty_cycle=1, volume=MAX_VOLUME, expected=0x7F), + TestCase(duty_cycle=2, volume=MAX_VOLUME, expected=0xBF), + TestCase(duty_cycle=MAX_DUTY_CYCLE, volume=MAX_VOLUME, expected=0xFF), + TestCase(duty_cycle=2, volume=0, expected=0xB0), + TestCase(duty_cycle=0, volume=1, expected=0x31), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_control_byte_matches(self, test_case: TestCase) -> None: + instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, test_case.volume, test_case.duty_cycle)] + registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) + assert registers[0].control == test_case.expected + + +class TestPulseRest: + """A rest zeroes the level and keeps everything else the channel was holding. + + Holding the period across a rest is what lets the driver leave the timer's high byte + untouched, and leaving it untouched is what keeps the waveform's phase running. + """ + + @staticmethod + def encode_note_then_rest() -> List[PulseRegisters]: + instructions = [ + sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE), + silent_pulse(), + ] + return PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) + + def test_rest_clears_the_volume_nibble(self) -> None: + sounding, resting = self.encode_note_then_rest()[:2] + assert sounding.control & 0x0F == MAX_VOLUME + assert resting.control & 0x0F == 0 + + def test_rest_keeps_the_duty_cycle(self) -> None: + sounding, resting = self.encode_note_then_rest()[:2] + assert resting.control & 0xF0 == sounding.control & 0xF0 + + def test_rest_keeps_the_timer(self) -> None: + sounding, resting = self.encode_note_then_rest()[:2] + assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + + +class TestPulseReleaseTick: + """A sample that ends while sounding gains one closing tick that silences its channel.""" + + def test_pulse_gains_a_silent_closing_tick(self) -> None: + instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)] + registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) + assert len(registers) == 2 + assert registers[-1].control & 0x0F == 0 + + def test_a_sample_ending_in_a_rest_gains_no_extra_tick(self) -> None: + instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0), silent_pulse()] + assert len(PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)) == 2 + + def test_no_instructions_encode_to_no_ticks(self) -> None: + assert PulseRegisters.from_instructions([], PLAYER_TIMER_TABLE) == [] diff --git a/tests/unit/sampletones_player/registers/test_streams.py b/tests/unit/sampletones_player/registers/test_streams.py new file mode 100644 index 000000000..70d2433ec --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_streams.py @@ -0,0 +1,70 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_player.specification.channels import CHANNEL_ORDER +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_PERIOD, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + noise_tick, + player_streams, + pulse_tick, + resting_streams, + triangle_tick, +) + +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + + +class TestChannelStreams: + """The four channels reach one common length, the longest of them stating it.""" + + def test_the_longest_channel_states_the_length(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + assert streams.ticks == 3 + + def test_a_channel_past_its_end_holds_its_final_values(self) -> None: + streams = resting_streams((SOUNDING, RESTING)) + _, pulse2, triangle, noise = streams.at(1) + assert (pulse2, triangle, noise) == (streams.pulse2[0], streams.triangle[0], streams.noise[0]) + + def test_every_channel_reads_its_own_tick_while_it_lasts(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP)) + assert streams.at(0)[0] == SOUNDING + assert streams.at(1)[0] == OCTAVE_UP + + def test_padding_carries_every_channel_to_the_songs_length(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + assert tuple(len(stream) for stream in streams.padded) == (3, 3, 3, 3) + + def test_padding_leaves_a_full_length_channel_as_it_stands(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + assert streams.padded[0] == (SOUNDING, OCTAVE_UP, RESTING) + + def test_padding_repeats_the_final_values_of_a_shorter_channel(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + assert streams.padded[3] == (streams.noise[0],) * 3 + + def test_the_streams_stand_in_channel_order(self) -> None: + streams = resting_streams((SOUNDING,)) + assert streams.ordered == (streams.pulse1, streams.pulse2, streams.triangle, streams.noise) + + def test_a_channel_without_a_tick_raises(self) -> None: + with pytest.raises(ValidationError): + player_streams( + pulse1=(SOUNDING,), + pulse2=(), + triangle=(triangle_tick(False, PLAYER_REFERENCE_TIMER),), + noise=(noise_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_PERIOD),), + ) + + def test_the_streams_stay_as_built(self) -> None: + streams = resting_streams((SOUNDING,)) + with pytest.raises(ValidationError): + streams.pulse1 = () diff --git a/tests/unit/sampletones_player/registers/test_triangle.py b/tests/unit/sampletones_player/registers/test_triangle.py new file mode 100644 index 000000000..67101523e --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_triangle.py @@ -0,0 +1,49 @@ +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.instructions import TriangleInstruction +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.triangle import TriangleRegisters +from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT +from tests.suite.player import PLAYER_REFERENCE_PITCH, PLAYER_TIMER_TABLE, sounding_pulse + + +def sounding_triangle() -> TriangleInstruction: + return TriangleInstruction(on=True, pitch=PLAYER_REFERENCE_PITCH) + + +class TestTriangleTickRecord: + """A tick states its values in the order the driver moves them to its channel.""" + + def test_a_tick_states_the_counter_then_timer(self) -> None: + registers = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE)[0] + timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) + + +class TestTriangleRegisters: + """The triangle states whether it sounds through the linear counter's reload value. + + The control bit stays set so the counter reloads every frame, and a reload of zero is + what holds the channel silent. + """ + + def test_sounding_tick_reloads_the_counter_fully(self) -> None: + registers = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE) + assert registers[0].linear_counter == 0xFF + + def test_resting_tick_reloads_the_counter_to_zero(self) -> None: + instructions = [sounding_triangle(), TriangleInstruction.null_instruction()] + registers = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) + assert registers[1].linear_counter == 0x80 + + def test_rest_keeps_the_timer(self) -> None: + instructions = [sounding_triangle(), TriangleInstruction.null_instruction()] + sounding, resting = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] + assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) + + def test_triangle_shares_the_pulse_timer(self) -> None: + triangle = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE) + pulse = PulseRegisters.from_instructions( + [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)], + PLAYER_TIMER_TABLE, + ) + assert (triangle[0].timer_low, triangle[0].timer_high) == (pulse[0].timer_low, pulse[0].timer_high) diff --git a/tests/unit/sampletones_player/test_registers.py b/tests/unit/sampletones_player/test_registers.py deleted file mode 100644 index 43f64598b..000000000 --- a/tests/unit/sampletones_player/test_registers.py +++ /dev/null @@ -1,247 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, Final, List, Tuple - -import pytest - -from sampletones_core.constants.general import ( - MAX_DUTY_CYCLE, - MAX_PERIOD, - MAX_PITCH, - MAX_TIMER, - MAX_VOLUME, - MIN_PITCH, -) -from sampletones_core.instructions import ( - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) -from sampletones_core.timers.arithmetic import frequency_to_timer -from sampletones_core.utils.frequencies import pitch_to_frequency -from sampletones_player.registers import ( - PulseRegisters, - encode_noise, - encode_pulse, - encode_triangle, -) -from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseAutolabelTestCase - -TIMER_TABLE: Final[Dict[int, int]] = { - pitch: frequency_to_timer(pitch_to_frequency(pitch)) for pitch in range(MIN_PITCH, MAX_PITCH + 1) -} - -PULSE_TIMER_MUTE_FLOOR: Final[int] = 8 -REFERENCE_PITCH: Final[int] = 69 - - -def sounding_pulse( - pitch: int, - volume: int, - duty_cycle: int, -) -> PulseInstruction: - return PulseInstruction( - on=True, - pitch=pitch, - volume=volume, - duty_cycle=duty_cycle, - ) - - -def silent_pulse() -> PulseInstruction: - return PulseInstruction.null_instruction() - - -class TestPulseTimerRange(BaseTestSuite): - """Every pitch a channel may sound reaches a timer the hardware plays. - - The APU silences a pulse channel below timer 8 and the register holds 11 bits, so the - playable pitch range has to land between those two bounds for the driver to sound it. - """ - - @pytest.mark.parametrize("pitch", [MIN_PITCH, REFERENCE_PITCH, MAX_PITCH]) - def test_timer_lies_within_the_audible_register_range(self, pitch: int) -> None: - timer = TIMER_TABLE[pitch] - assert PULSE_TIMER_MUTE_FLOOR <= timer <= MAX_TIMER - - def test_timer_splits_into_a_byte_and_three_bits(self) -> None: - registers = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0)], TIMER_TABLE) - timer = TIMER_TABLE[REFERENCE_PITCH] - assert registers[0].timer_low == timer & MAX_REGISTER_VALUE - assert registers[0].timer_high == timer >> TIMER_HIGH_SHIFT - - -class TestTickRecord: - """A tick states its values in the order the driver moves them to its channel.""" - - def test_pulse_tick_states_control_then_timer(self) -> None: - registers = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE)], TIMER_TABLE)[0] - timer = TIMER_TABLE[REFERENCE_PITCH] - assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) - - def test_triangle_tick_states_the_counter_then_timer(self) -> None: - registers = encode_triangle([TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], TIMER_TABLE)[0] - timer = TIMER_TABLE[REFERENCE_PITCH] - assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT) - - -class TestPulseControlByte(BaseTestSuite): - """The expected bytes are the values the APU reads from ``$4000``. - - A duty cycle occupies the top two bits, the length-halt and constant-volume bits sit - below them, and the level fills the low nibble. - """ - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseAutolabelTestCase): - expected: int - duty_cycle: int - volume: int - - @property - def label(self) -> str: - return f"duty_{self.duty_cycle}_volume_{self.volume}" - - test_cases = ( - TestCase(duty_cycle=0, volume=MAX_VOLUME, expected=0x3F), - TestCase(duty_cycle=1, volume=MAX_VOLUME, expected=0x7F), - TestCase(duty_cycle=2, volume=MAX_VOLUME, expected=0xBF), - TestCase(duty_cycle=MAX_DUTY_CYCLE, volume=MAX_VOLUME, expected=0xFF), - TestCase(duty_cycle=2, volume=0, expected=0xB0), - TestCase(duty_cycle=0, volume=1, expected=0x31), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_control_byte_matches(self, test_case: TestCase) -> None: - instructions = [sounding_pulse(REFERENCE_PITCH, test_case.volume, test_case.duty_cycle)] - registers = encode_pulse(instructions, TIMER_TABLE) - assert registers[0].control == test_case.expected - - -class TestPulseRest: - """A rest zeroes the level and keeps everything else the channel was holding. - - Holding the period across a rest is what lets the driver leave the timer's high byte - untouched, and leaving it untouched is what keeps the waveform's phase running. - """ - - @staticmethod - def encode_note_then_rest() -> List[PulseRegisters]: - instructions = [ - sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE), - silent_pulse(), - ] - return encode_pulse(instructions, TIMER_TABLE) - - def test_rest_clears_the_volume_nibble(self) -> None: - sounding, resting = self.encode_note_then_rest()[:2] - assert sounding.control & 0x0F == MAX_VOLUME - assert resting.control & 0x0F == 0 - - def test_rest_keeps_the_duty_cycle(self) -> None: - sounding, resting = self.encode_note_then_rest()[:2] - assert resting.control & 0xF0 == sounding.control & 0xF0 - - def test_rest_keeps_the_timer(self) -> None: - sounding, resting = self.encode_note_then_rest()[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) - - -class TestReleaseTick: - """A sample that ends while sounding gains one closing tick that silences its channel.""" - - def test_pulse_gains_a_silent_closing_tick(self) -> None: - registers = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0)], TIMER_TABLE) - assert len(registers) == 2 - assert registers[-1].control & 0x0F == 0 - - def test_a_sample_ending_in_a_rest_gains_no_extra_tick(self) -> None: - instructions = [sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0), silent_pulse()] - assert len(encode_pulse(instructions, TIMER_TABLE)) == 2 - - def test_no_instructions_encode_to_no_ticks(self) -> None: - assert encode_pulse([], TIMER_TABLE) == [] - - -class TestEncodeTriangle: - """The triangle states whether it sounds through the linear counter's reload value. - - The control bit stays set so the counter reloads every frame, and a reload of zero is - what holds the channel silent. - """ - - def test_sounding_tick_reloads_the_counter_fully(self) -> None: - registers = encode_triangle([TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], TIMER_TABLE) - assert registers[0].linear_counter == 0xFF - - def test_resting_tick_reloads_the_counter_to_zero(self) -> None: - instructions = [ - TriangleInstruction(on=True, pitch=REFERENCE_PITCH), - TriangleInstruction.null_instruction(), - ] - registers = encode_triangle(instructions, TIMER_TABLE) - assert registers[1].linear_counter == 0x80 - - def test_rest_keeps_the_timer(self) -> None: - instructions = [ - TriangleInstruction(on=True, pitch=REFERENCE_PITCH), - TriangleInstruction.null_instruction(), - ] - sounding, resting = encode_triangle(instructions, TIMER_TABLE)[:2] - assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high) - - def test_triangle_shares_the_pulse_timer(self) -> None: - triangle = encode_triangle([TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], TIMER_TABLE) - pulse = encode_pulse([sounding_pulse(REFERENCE_PITCH, MAX_VOLUME, 0)], TIMER_TABLE) - assert (triangle[0].timer_low, triangle[0].timer_high) == (pulse[0].timer_low, pulse[0].timer_high) - - -class TestEncodeNoise(BaseTestSuite): - """The project counts noise periods from the slowest and the register from the fastest.""" - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseAutolabelTestCase): - expected: Tuple[int, int] - period: int - short: bool - - @property - def label(self) -> str: - mode = "short" if self.short else "normal" - return f"period_{self.period}_{mode}" - - test_cases = ( - TestCase(period=0, short=False, expected=(0x3F, MAX_PERIOD)), - TestCase(period=MAX_PERIOD, short=False, expected=(0x3F, 0)), - TestCase(period=0, short=True, expected=(0x3F, 0x80 | MAX_PERIOD)), - TestCase(period=MAX_PERIOD, short=True, expected=(0x3F, 0x80)), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_registers_match(self, test_case: TestCase) -> None: - instruction = NoiseInstruction( - on=True, - period=test_case.period, - volume=MAX_VOLUME, - short=test_case.short, - ) - registers = encode_noise([instruction]) - assert registers[0].values == test_case.expected - - def test_rest_clears_the_volume_and_keeps_the_period(self) -> None: - instructions = [ - NoiseInstruction(on=True, period=4, volume=MAX_VOLUME, short=False), - NoiseInstruction.null_instruction(), - ] - sounding, resting = encode_noise(instructions)[:2] - assert resting.control & 0x0F == 0 - assert resting.period == sounding.period diff --git a/tests/unit/sampletones_player/test_song.py b/tests/unit/sampletones_player/test_song.py new file mode 100644 index 000000000..407312f11 --- /dev/null +++ b/tests/unit/sampletones_player/test_song.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_player.song import Song +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + player_song, + pulse_tick, + resting_streams, +) + +NTSC_FREQUENCY: Final[int] = 60 +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + + +class TestSongLoopBounds: + """A loop point names a tick the song actually plays.""" + + def test_a_loop_within_the_song_is_accepted(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=1) + assert song.loop_tick == 1 + + def test_a_song_may_stand_without_a_loop(self) -> None: + song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None) + assert song.loop_tick is None + + def test_a_loop_at_the_songs_length_raises(self) -> None: + with pytest.raises(ValueError): + player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=2) + + def test_a_negative_loop_raises(self) -> None: + with pytest.raises(ValueError): + player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=-1) + + +class TestSongPlayback(BaseTestSuite): + """The tick each call lands on, read across a song that ends and one that repeats.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[Optional[int], ...] + name: str + loop_tick: Optional[int] + + @property + def label(self) -> str: + return self.name + + @property + def song(self) -> Song: + return player_song( + resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), + NTSC_FREQUENCY, + self.loop_tick, + ) + + test_cases: Tuple[TestCase, ...] = ( + TestCase(name="stops", loop_tick=None, expected=(0, 1, 2, 3, None, None, None, None)), + TestCase(name="repeats-from-the-start", loop_tick=0, expected=(0, 1, 2, 3, 0, 1, 2, 3)), + TestCase(name="repeats-from-the-middle", loop_tick=2, expected=(0, 1, 2, 3, 2, 3, 2, 3)), + TestCase(name="repeats-one-tick", loop_tick=3, expected=(0, 1, 2, 3, 3, 3, 3, 3)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_calls_land_where_expected(self, test_case: TestCase) -> None: + song = test_case.song + ticks = tuple(song.tick_at(play_call) for play_call in range(len(test_case.expected))) + assert ticks == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_tick_played_lies_within_the_song(self, test_case: TestCase) -> None: + song = test_case.song + played = [song.tick_at(play_call) for play_call in range(len(test_case.expected))] + assert all(0 <= tick < song.ticks for tick in played if tick is not None) + + def test_the_song_lasts_as_long_as_its_streams(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=None) + assert song.ticks == song.streams.ticks + + def test_a_slow_stream_holds_its_tick_between_calls(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), 30, loop_tick=None) + assert tuple(song.tick_at(play_call) for play_call in range(6)) == (0, 0, 1, 1, 2, 2) diff --git a/tests/unit/sampletones_player/trace/__init__.py b/tests/unit/sampletones_player/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/trace/test_trace.py b/tests/unit/sampletones_player/trace/test_trace.py new file mode 100644 index 000000000..c77668f59 --- /dev/null +++ b/tests/unit/sampletones_player/trace/test_trace.py @@ -0,0 +1,183 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + APU_STATUS, + CHANNELS_ENABLED, + FIRST_CHANNEL_REGISTER, + FRAME_COUNTER_SEQUENCE, + LAST_CHANNEL_REGISTER, + PULSE1_CONTROL, + PULSE1_SWEEP, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_SWEEP, + REGISTERS_WRITTEN_ON_CHANGE, + SILENCED_REGISTER, + SWEEP_DISABLED, + TRIANGLE_TIMER_HIGH, +) +from sampletones_player.trace.trace import RegisterTrace +from sampletones_player.trace.write import RegisterWrite +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + player_song, + pulse_tick, + resting_streams, +) + +NTSC_FREQUENCY: Final[int] = 60 +HALF_RATE_FREQUENCY: Final[int] = 30 +WRITES_PER_TICK: Final[int] = 11 + +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + + +def addresses(writes: Tuple[RegisterWrite, ...]) -> Tuple[int, ...]: + return tuple(write.address for write in writes) + + +class TestInitialisation: + """The init routine leaves a silent console enabled and sounding the song's first tick.""" + + SONG: Final = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + + @property + def initialisation(self) -> Tuple[RegisterWrite, ...]: + return RegisterTrace.from_song(self.SONG, play_calls=0).initialisation + + def test_every_channel_register_is_cleared_first(self) -> None: + cleared = self.initialisation[: LAST_CHANNEL_REGISTER - FIRST_CHANNEL_REGISTER + 1] + assert addresses(cleared) == tuple(range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1)) + assert all(write.value == SILENCED_REGISTER for write in cleared) + + def test_the_channels_are_enabled(self) -> None: + assert RegisterWrite(APU_STATUS, CHANNELS_ENABLED) in self.initialisation + + def test_the_frame_counter_runs_without_an_interrupt(self) -> None: + assert RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE) in self.initialisation + + def test_both_sweep_units_are_disabled(self) -> None: + assert RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED) in self.initialisation + assert RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED) in self.initialisation + + def test_the_sweep_survives_the_clearing_pass(self) -> None: + sweeps = [write.value for write in self.initialisation if write.address == PULSE1_SWEEP] + assert sweeps[-1] == SWEEP_DISABLED + + def test_the_first_tick_sounds_from_initialisation(self) -> None: + first_tick = self.initialisation[-WRITES_PER_TICK:] + assert len(first_tick) == WRITES_PER_TICK + assert first_tick[0] == RegisterWrite(PULSE1_CONTROL, self.SONG.streams.pulse1[0].control) + + def test_the_first_tick_writes_the_registers_that_reset_a_channel(self) -> None: + first_tick = self.initialisation[-WRITES_PER_TICK:] + assert REGISTERS_WRITTEN_ON_CHANGE.issubset(set(addresses(first_tick))) + + +class TestChangeSuppression: + """The three registers that disturb a running channel are written only where they change.""" + + def test_a_held_pitch_leaves_the_timer_high_byte_alone(self) -> None: + song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + trace = RegisterTrace.from_song(song, play_calls=2) + assert PULSE1_TIMER_HIGH not in addresses(trace.play_calls[1]) + + def test_a_held_pitch_still_writes_the_timer_low_byte(self) -> None: + song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + trace = RegisterTrace.from_song(song, play_calls=2) + assert PULSE1_TIMER_LOW in addresses(trace.play_calls[1]) + + def test_a_pitch_change_writes_the_timer_high_byte(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=None) + trace = RegisterTrace.from_song(song, play_calls=2) + assert PULSE1_TIMER_HIGH in addresses(trace.play_calls[1]) + + def test_a_returning_pitch_writes_the_timer_high_byte_again(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP, SOUNDING)), NTSC_FREQUENCY, loop_tick=None) + trace = RegisterTrace.from_song(song, play_calls=3) + assert PULSE1_TIMER_HIGH in addresses(trace.play_calls[2]) + + def test_an_unchanging_channel_never_rewrites_its_high_byte(self) -> None: + song = player_song(resting_streams((SOUNDING, RESTING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + trace = RegisterTrace.from_song(song, play_calls=3) + rewritten = [write for writes in trace.play_calls for write in writes if write.address == TRIANGLE_TIMER_HIGH] + assert rewritten == [] + + +class TestPlaySchedule(BaseTestSuite): + """Which calls write and which leave the console alone, across the stream rates.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[bool, ...] + nes_frequency: int + + @property + def label(self) -> str: + return f"{self.nes_frequency}hz" + + test_cases: Tuple[TestCase, ...] = ( + TestCase(nes_frequency=NTSC_FREQUENCY, expected=(False, True, True, True)), + TestCase(nes_frequency=HALF_RATE_FREQUENCY, expected=(False, False, True, False)), + TestCase(nes_frequency=15, expected=(False, False, False, False)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_only_the_calls_that_advance_write(self, test_case: TestCase) -> None: + song = player_song( + resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), + test_case.nes_frequency, + loop_tick=None, + ) + trace = RegisterTrace.from_song(song, play_calls=len(test_case.expected)) + assert tuple(bool(writes) for writes in trace.play_calls) == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_call_that_writes_touches_every_channel(self, test_case: TestCase) -> None: + song = player_song( + resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), + test_case.nes_frequency, + loop_tick=None, + ) + trace = RegisterTrace.from_song(song, play_calls=len(test_case.expected)) + for writes in trace.play_calls: + assert not writes or PULSE1_CONTROL in addresses(writes) + + +class TestEndOfSong: + """A song that ends stops writing, and one that repeats keeps playing from its loop tick.""" + + def test_a_song_without_a_loop_stops_writing(self) -> None: + song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + trace = RegisterTrace.from_song(song, play_calls=6) + assert all(writes == () for writes in trace.play_calls[3:]) + + def test_a_song_with_a_loop_keeps_writing(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=0) + trace = RegisterTrace.from_song(song, play_calls=6) + assert all(writes != () for writes in trace.play_calls[1:]) + + def test_a_loop_replays_the_ticks_it_returns_to(self) -> None: + song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=0) + trace = RegisterTrace.from_song(song, play_calls=4) + assert addresses(trace.play_calls[1]) == addresses(trace.play_calls[3]) + + def test_no_calls_produce_no_writes(self) -> None: + song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None) + assert RegisterTrace.from_song(song, play_calls=0).play_calls == () + + def test_a_negative_call_count_raises(self) -> None: + song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None) + with pytest.raises(ValueError): + RegisterTrace.from_song(song, play_calls=-1) From 51b30064fdfeee72e35b4ee12fb904d88775000a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 08:56:04 +0200 Subject: [PATCH 004/142] Added: a CC65 driver stub --- Makefile | 6 +- docs/development/dependencies.md | 19 +++ src/sampletones_player/clock/schedule.py | 52 ++++---- src/sampletones_player/driver/__init__.py | 0 src/sampletones_player/driver/addresses.py | 48 ++++++++ src/sampletones_player/driver/build.sh | 57 +++++++++ src/sampletones_player/driver/channels.s | 116 ++++++++++++++++++ src/sampletones_player/driver/clock.s | 102 +++++++++++++++ src/sampletones_player/driver/driver.bin | Bin 0 -> 339 bytes src/sampletones_player/driver/driver.s | 55 +++++++++ src/sampletones_player/driver/image.py | 80 ++++++++++++ src/sampletones_player/driver/nes.inc | 23 ++++ src/sampletones_player/driver/nsf.cfg | 10 ++ src/sampletones_player/driver/song.inc | 17 +++ .../specification/driver.py | 14 +++ src/sampletones_player/specification/nsf.py | 4 + src/sampletones_player/trace/trace.py | 9 ++ .../sampletones_player/clock/test_schedule.py | 42 +++---- .../sampletones_player/driver/__init__.py | 0 .../driver/test_addresses.py | 37 ++++++ .../sampletones_player/driver/test_image.py | 99 +++++++++++++++ .../sampletones_player/trace/test_trace.py | 16 +++ 22 files changed, 758 insertions(+), 48 deletions(-) create mode 100644 src/sampletones_player/driver/__init__.py create mode 100644 src/sampletones_player/driver/addresses.py create mode 100755 src/sampletones_player/driver/build.sh create mode 100644 src/sampletones_player/driver/channels.s create mode 100644 src/sampletones_player/driver/clock.s create mode 100644 src/sampletones_player/driver/driver.bin create mode 100644 src/sampletones_player/driver/driver.s create mode 100644 src/sampletones_player/driver/image.py create mode 100644 src/sampletones_player/driver/nes.inc create mode 100644 src/sampletones_player/driver/nsf.cfg create mode 100644 src/sampletones_player/driver/song.inc create mode 100644 src/sampletones_player/specification/driver.py create mode 100644 src/sampletones_player/specification/nsf.py create mode 100644 tests/unit/sampletones_player/driver/__init__.py create mode 100644 tests/unit/sampletones_player/driver/test_addresses.py create mode 100644 tests/unit/sampletones_player/driver/test_image.py diff --git a/Makefile b/Makefile index 7ab6b93d5..64a67eac3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples icons check-import-boundary check-tag-names check-unused-tags \ + ftm-samples icons player check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -71,6 +71,7 @@ help: @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) + @echo $(Q) make player - Assemble the NES player driver with cc65$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) @@ -114,6 +115,9 @@ ftm-samples: icons: uv run --group assets python scripts/assets/icons.py +player: + bash src/sampletones_player/driver/build.sh + check-import-boundary: uv run scripts/checks/import_boundary.py --all diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index e0de82837..ebf357173 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -72,6 +72,25 @@ installed, and PyInstaller follows that import into the bundle. The application files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it. +## NES player driver + +The player that runs on the console is 6502 assembly, held in `src/sampletones_player/driver` +beside the linker configuration and the build script that assembles it. `make player` runs that +script, which needs `ca65` and `ld65` from [cc65](https://cc65.github.io/) — on Debian and Ubuntu, +`sudo apt install cc65`. + +The assembled `driver.bin` is committed, so a checkout carries the player and exporting an NSF +needs no assembler. A jump table leads the image, which fixes the addresses an NSF header names +whatever the driver's length, so the exporter states them from `specification/driver.py`. cc65 is +a build-time tool for the driver alone, which is why it belongs neither in the requirements a user +installs nor in `scripts/linux/build/dependencies.sh`. Editing the assembly means running +`make player` again and committing what it writes; the driver's test suite rebuilds the sources +and holds the committed image to them wherever cc65 is installed. + +cc65 is distributed under the zlib licence, and the driver stays clear of it: the link line names +our own object files and our own `nsf.cfg`, so nothing of cc65's start-up code or libraries reaches +the committed image. That keeps the blob entirely ours to ship under the project's MIT licence. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py index 1281029a7..348b41655 100644 --- a/src/sampletones_player/clock/schedule.py +++ b/src/sampletones_player/clock/schedule.py @@ -16,7 +16,7 @@ @dataclass(frozen=True) class PlaySchedule: - """The engine ticks each play call advances a stream by, held exact so a stream keeps its rate. + """The engine ticks each play call advances a stream by, counted the way the console counts them. An NSF asks the console to call its play routine at one fixed rate, and a reconstruction is built at whatever rate its ``nes_frequency`` states. The two meet here: the schedule states how @@ -25,6 +25,10 @@ class PlaySchedule: out. One data set plays at every stream rate, and a stream slower than the play rate simply stands still on the calls between its ticks. + Every answer about where the stream stands comes from that same rounded step, so the schedule + states what the assembly does rather than what an unrounded clock would do. The exact rate + stays as :attr:`ticks_per_play_call`, which is what :meth:`maximum_drift` measures against. + This is the rule :class:`~sampletones_core.timing.clock.TickClock` applies one level down: spread the fractional part across consecutive units so the running total tracks the exact clock. There it is audio samples per engine tick; here it is engine ticks per play call. @@ -75,6 +79,10 @@ def from_parameters(cls, nes_frequency: int) -> PlaySchedule: def ticks_at(self, play_calls: int) -> int: """The tick the stream stands on once ``play_calls`` calls have been made. + Counts the way the driver counts: the rounded step added once per call, with the whole + ticks read off the top of the running total. The console has only this arithmetic, so it + is the schedule a song plays on, and :meth:`exact_ticks_at` is what it is measured against. + Args: play_calls: How many play calls have been made, at least 0. @@ -87,6 +95,23 @@ def ticks_at(self, play_calls: int) -> int: if play_calls < 0: raise ValueError(f"play_calls must be at least 0, got {play_calls}") + return (play_calls * self.fixed_point_step.value) >> FIXED_POINT_BITS + + def exact_ticks_at(self, play_calls: int) -> int: + """The tick an unrounded clock puts the stream on once ``play_calls`` calls have been made. + + Args: + play_calls: How many play calls have been made, at least 0. + + Returns: + int: The tick's index, held in exact arithmetic. + + Raises: + ValueError: If ``play_calls`` is negative. + """ + if play_calls < 0: + raise ValueError(f"play_calls must be at least 0, got {play_calls}") + return floor(self.ticks_per_play_call * play_calls) def advance_at(self, play_call: int) -> int: @@ -113,27 +138,6 @@ def fixed_point_step(self) -> FixedPointStep: whole, fraction = divmod(round(self.ticks_per_play_call * FIXED_POINT_SCALE), FIXED_POINT_SCALE) return FixedPointStep(whole=whole, fraction=fraction) - def fixed_point_ticks_at(self, play_calls: int) -> int: - """The tick the driver's own arithmetic stands on once ``play_calls`` calls have been made. - - Reproduces the accumulator in full: the rounded step added once per call, with the whole - ticks read off the top of the running total. Holding this beside :meth:`ticks_at` is what - shows the rounding staying within a tick of the exact clock. - - Args: - play_calls: How many play calls have been made, at least 0. - - Returns: - int: The tick's index as the driver counts it. - - Raises: - ValueError: If ``play_calls`` is negative. - """ - if play_calls < 0: - raise ValueError(f"play_calls must be at least 0, got {play_calls}") - - return (play_calls * self.fixed_point_step.value) >> FIXED_POINT_BITS - def maximum_drift(self, play_calls: int) -> int: """The furthest the driver's schedule stands from the exact one across a run of calls. @@ -149,8 +153,6 @@ def maximum_drift(self, play_calls: int) -> int: if play_calls < 0: raise ValueError(f"play_calls must be at least 0, got {play_calls}") - step = self.fixed_point_step.value return max( - abs((play_call * step >> FIXED_POINT_BITS) - self.ticks_at(play_call)) - for play_call in range(play_calls + 1) + abs(self.ticks_at(play_call) - self.exact_ticks_at(play_call)) for play_call in range(play_calls + 1) ) diff --git a/src/sampletones_player/driver/__init__.py b/src/sampletones_player/driver/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/driver/addresses.py b/src/sampletones_player/driver/addresses.py new file mode 100644 index 000000000..3e23b2926 --- /dev/null +++ b/src/sampletones_player/driver/addresses.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_player.specification.driver import ( + INIT_ADDRESS, + LOAD_ADDRESS, + MAX_ADDRESS, + PLAY_ADDRESS, +) + + +class DriverAddresses(BaseModel): + """Where the driver and the song it carries sit in the console's address space. + + Three of the four are settled before anything is assembled: the image loads at the start of + the program area, and a jump table leads it so both routines answer at a fixed address + whatever the driver's length. The song follows the code, so its address is the one value a + build decides, and it decides it by the number of bytes it produced. + + Attributes: + load: Where the console loads the image. + init: The routine that readies the APU and sounds the song's first tick. + play: The routine the console calls at the rate the NSF header asks for. + song: Where the song block belongs, directly behind the code. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + load: int = Field(default=LOAD_ADDRESS, ge=0, le=MAX_ADDRESS) + init: int = Field(default=INIT_ADDRESS, ge=0, le=MAX_ADDRESS) + play: int = Field(default=PLAY_ADDRESS, ge=0, le=MAX_ADDRESS) + song: int = Field(..., ge=0, le=MAX_ADDRESS) + + @classmethod + def for_code(cls, code_length: int) -> DriverAddresses: + """The addresses a driver of ``code_length`` bytes is built to answer at. + + A build holds its linker's own labels against these, and the exporter reads them without + one, which is what keeps the committed image and the addresses that describe it in step. + + Args: + code_length: The assembled driver's length in bytes. + + Returns: + DriverAddresses: The addresses the image is expected to lay out. + """ + return cls(song=LOAD_ADDRESS + code_length) diff --git a/src/sampletones_player/driver/build.sh b/src/sampletones_player/driver/build.sh new file mode 100755 index 000000000..4a1efb55a --- /dev/null +++ b/src/sampletones_player/driver/build.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +driver_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +output_directory="${1:-$driver_directory}" +sources=(driver.s clock.s channels.s) + +for tool in ca65 ld65; do + if ! command -v "$tool" > /dev/null 2>&1; then + echo "$tool is missing: the player driver is built with cc65 (sudo apt install cc65)" >&2 + exit 1 + fi +done + +work_directory="$(mktemp -d)" +trap 'rm -rf "$work_directory"' EXIT + +objects=() +for source in "${sources[@]}"; do + object="$work_directory/${source%.s}.o" + ca65 --cpu 6502 --include-dir "$driver_directory" -o "$object" "$driver_directory/$source" + objects+=("$object") +done + +ld65 \ + --config "$driver_directory/nsf.cfg" \ + --mapfile "$work_directory/driver.map" \ + -Ln "$work_directory/driver.labels" \ + -o "$output_directory/driver.bin" \ + "${objects[@]}" + +address() { + local symbol="$1" + local value + value="$(awk -v name=".$symbol" '$3 == name { print $2 }' "$work_directory/driver.labels")" + if [[ -z "$value" ]]; then + echo "the linker reported no address for $symbol" >&2 + exit 1 + fi + echo $((16#$value)) +} + +load_address="$(address __PRG_START__)" +init_address="$(address nsf_init)" +play_address="$(address nsf_play)" +song_address="$(address song_data)" +driver_length="$(wc -c < "$output_directory/driver.bin")" + +if ((song_address - load_address != driver_length)); then + echo "the song starts at $((song_address - load_address)) bytes and the driver is $driver_length long" >&2 + exit 1 +fi + +printf 'driver.bin %d bytes, $%04X-$%04X\n' "$driver_length" "$load_address" "$((song_address - 1))" +printf 'init $%04X\n' "$init_address" +printf 'play $%04X\n' "$play_address" +printf 'song $%04X\n' "$song_address" diff --git a/src/sampletones_player/driver/channels.s b/src/sampletones_player/driver/channels.s new file mode 100644 index 000000000..c271c1629 --- /dev/null +++ b/src/sampletones_player/driver/channels.s @@ -0,0 +1,116 @@ +.setcpu "6502" + +.include "nes.inc" +.include "song.inc" + +.export channels_reset +.export channels_write_tick + +.import song_data +.importzp current_tick + +SHADOW_UNWRITTEN = $FF + +.segment "ZEROPAGE" + +pointer: .res 2 +record_offset: .res 2 +timer_high_shadows: .res TRIANGLE_REGISTERS + 1 + +.segment "CODE" + +channels_reset: + lda #SHADOW_UNWRITTEN + sta timer_high_shadows + PULSE1_REGISTERS + sta timer_high_shadows + PULSE2_REGISTERS + sta timer_high_shadows + TRIANGLE_REGISTERS + rts + +channels_write_tick: + jsr set_tone_offset + ldx #PULSE1_REGISTERS + ldy #PULSE1_STREAM + jsr write_tone_channel + ldx #PULSE2_REGISTERS + ldy #PULSE2_STREAM + jsr write_tone_channel + ldx #TRIANGLE_REGISTERS + ldy #TRIANGLE_STREAM + jsr write_tone_channel + + jsr set_noise_offset + ldx #NOISE_REGISTERS + ldy #NOISE_STREAM + jmp write_noise_channel + +.assert NOISE_RECORD_SIZE = 2, error, "the noise record is reached by one doubling" +.assert TONE_RECORD_SIZE = 3, error, "the tone record is reached by a doubling and one more tick" + +set_noise_offset: + lda current_tick + asl + sta record_offset + lda current_tick + 1 + rol + sta record_offset + 1 + rts + +set_tone_offset: + jsr set_noise_offset + clc + lda record_offset + adc current_tick + sta record_offset + lda record_offset + 1 + adc current_tick + 1 + sta record_offset + 1 + rts + +; Points at the current tick's record in the stream whose header offset lies at Y. +set_pointer: + clc + lda song_data + STREAM_OFFSETS_OFFSET,y + adc record_offset + sta pointer + lda song_data + STREAM_OFFSETS_OFFSET + 1,y + adc record_offset + 1 + sta pointer + 1 + + clc + lda pointer + adc #song_data + sta pointer + 1 + rts + +; Writes one tick to a channel, with the channel's register base in X and its header offset in Y. +; A timer's high half reaches the register only where it differs from the last one written, since +; storing it restarts a pulse waveform and reloads the triangle's counter. +write_tone_channel: + jsr set_pointer + ldy #$00 + lda (pointer),y + sta CHANNEL_CONTROL,x + iny + lda (pointer),y + sta CHANNEL_TIMER_LOW,x + iny + lda (pointer),y + cmp timer_high_shadows,x + beq @held + sta timer_high_shadows,x + sta CHANNEL_TIMER_HIGH,x +@held: + rts + +write_noise_channel: + jsr set_pointer + ldy #$00 + lda (pointer),y + sta CHANNEL_CONTROL,x + iny + lda (pointer),y + sta CHANNEL_TIMER_LOW,x + rts diff --git a/src/sampletones_player/driver/clock.s b/src/sampletones_player/driver/clock.s new file mode 100644 index 000000000..8cdd9e086 --- /dev/null +++ b/src/sampletones_player/driver/clock.s @@ -0,0 +1,102 @@ +.setcpu "6502" + +.include "song.inc" + +.export clock_reset +.export clock_advance +.exportzp current_tick + +.import song_data + +.segment "ZEROPAGE" + +accumulator: .res 2 +current_tick: .res 2 +finished: .res 1 + +.segment "CODE" + +clock_reset: + lda #$00 + sta accumulator + sta accumulator + 1 + sta current_tick + sta current_tick + 1 + sta finished + rts + +; Advances the stream by one play call's worth of ticks. +; Answers with A = 0 where the console is to be left alone, either because the stream holds its +; tick through this call or because the song has ended. +clock_advance: + lda finished + bne @hold + + clc + lda accumulator + adc song_data + STEP_FRACTION_OFFSET + sta accumulator + lda accumulator + 1 + adc song_data + STEP_FRACTION_OFFSET + 1 + sta accumulator + 1 + lda song_data + STEP_WHOLE_OFFSET + adc #$00 + beq @hold + + clc + adc current_tick + sta current_tick + bcc @wrap + inc current_tick + 1 +@wrap: + jsr wrap_tick + lda finished + bne @hold + + lda #$01 + rts +@hold: + lda #$00 + rts + +; Brings a tick that has run past the song's end back to where the song repeats, or marks the song +; finished where it has no loop. Each pass takes off the whole of the looping part, so a call that +; advances by more ticks than the loop is long still lands inside it. +wrap_tick: + lda current_tick + 1 + cmp song_data + TOTAL_TICKS_OFFSET + 1 + bcc @within + bne @past + lda current_tick + cmp song_data + TOTAL_TICKS_OFFSET + bcc @within +@past: + lda song_data + LOOP_TICK_OFFSET + cmp #NO_LOOP + bne @rewind + + lda #$01 + sta finished + rts +@rewind: + sec + lda current_tick + sbc song_data + TOTAL_TICKS_OFFSET + sta current_tick + lda current_tick + 1 + sbc song_data + TOTAL_TICKS_OFFSET + 1 + sta current_tick + 1 + + clc + lda current_tick + adc song_data + LOOP_TICK_OFFSET + sta current_tick + lda current_tick + 1 + adc song_data + LOOP_TICK_OFFSET + 1 + sta current_tick + 1 + jmp wrap_tick +@within: + rts diff --git a/src/sampletones_player/driver/driver.bin b/src/sampletones_player/driver/driver.bin new file mode 100644 index 0000000000000000000000000000000000000000..b327ac9bf4ec2dfad4db02dc401d782bc09e28ba GIT binary patch literal 339 zcmZXPtxp3%5XJX)Z||<8q#-~!xdu@rL0||Q^tnPmz^pk@asNTZju?4K4YJAQk`XH^ zkd-)S(&P*tMG!Qp16Q_K%>44+eB@0Q*{o~TxpkfLSUX*wXqS|ME~R#?>}Yju)w9i( zR%Lc0JdI;Dn=$?Qe$@PUfeZuWC@?e>HrWO?y7tLAt=7yd=Kv19B4U0iZ~U7sEeUjJ(VyKF$_f zxQ_=$ecPH{G>*2;vVuC<>CSI l1f!JXxQCLWF<@j=0u_6b{_7WC`L4dTHuAAG6Z&7%`~p6xdl~=$ literal 0 HcmV?d00001 diff --git a/src/sampletones_player/driver/driver.s b/src/sampletones_player/driver/driver.s new file mode 100644 index 000000000..533e17ed3 --- /dev/null +++ b/src/sampletones_player/driver/driver.s @@ -0,0 +1,55 @@ +.setcpu "6502" + +.include "nes.inc" + +.export nsf_init +.export nsf_play +.export song_data + +.import clock_reset +.import clock_advance +.import channels_reset +.import channels_write_tick + +.segment "CODE" + +nsf_init: + jmp start_song +nsf_play: + jmp advance_song + +start_song: + jsr silence_channels + lda #CHANNELS_ENABLED + sta APU_STATUS + lda #FRAME_COUNTER_SEQUENCE + sta APU_FRAME_COUNTER + lda #SWEEP_DISABLED + sta PULSE1_SWEEP + sta PULSE2_SWEEP + lda #NOISE_LENGTH_COUNTER_LOAD + sta NOISE_LENGTH_COUNTER + jsr clock_reset + jsr channels_reset + jmp channels_write_tick + +advance_song: + jsr clock_advance + beq @held + jmp channels_write_tick +@held: + rts + +silence_channels: + lda #SILENCED_REGISTER + ldx #$00 +@next: + sta FIRST_CHANNEL_REGISTER,x + inx + cpx #CHANNEL_REGISTER_COUNT + bne @next + rts + +.segment "SONG" + +song_data: diff --git a/src/sampletones_player/driver/image.py b/src/sampletones_player/driver/image.py new file mode 100644 index 000000000..125e9c70b --- /dev/null +++ b/src/sampletones_player/driver/image.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from importlib import resources + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.specification.driver import ( + DRIVER_CODE_NAME, + DRIVER_PACKAGE, + JUMP_ABSOLUTE_OPCODE, +) + + +class DriverImage(BaseModel): + """The assembled player, paired with the addresses it lays out. + + The 6502 program that plays a song is written in assembly, built once by ``make player`` and + committed beside its sources, so exporting an NSF needs no assembler. Pairing the bytes with + their addresses is what lets the exporter name the routines in an NSF header and place the + song where the driver looks for it. + + Attributes: + code: The assembled bytes, loaded at the image's load address. + addresses: Where the code and the song behind it sit. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + code: bytes = Field(..., min_length=1) + addresses: DriverAddresses + + @model_validator(mode="after") + def _validate_the_song_follows_the_code(self) -> DriverImage: + expected = self.addresses.load + len(self.code) + if self.addresses.song != expected: + raise ValueError( + f"the song belongs at {expected:#06x}, directly behind {len(self.code)} bytes of " + f"code loaded at {self.addresses.load:#06x}, and the image states " + f"{self.addresses.song:#06x}" + ) + + return self + + @model_validator(mode="after") + def _validate_the_routines_lie_in_the_code(self) -> DriverImage: + for name, address in (("init", self.addresses.init), ("play", self.addresses.play)): + if not self.addresses.load <= address < self.addresses.song: + raise ValueError( + f"the {name} routine lies at {address:#06x}, outside the code between " + f"{self.addresses.load:#06x} and {self.addresses.song:#06x}" + ) + + return self + + @model_validator(mode="after") + def _validate_the_image_leads_with_its_entry_points(self) -> DriverImage: + for name, address in (("init", self.addresses.init), ("play", self.addresses.play)): + opcode = self.code[address - self.addresses.load] + if opcode != JUMP_ABSOLUTE_OPCODE: + raise ValueError( + f"the {name} routine answers at {address:#06x}, where the image holds " + f"{opcode:#04x} rather than the jump the entry points lead with" + ) + + return self + + @classmethod + def load(cls) -> DriverImage: + """Reads the driver committed in the package. + + Returns: + DriverImage: The assembled bytes and the addresses they lay out. + + Raises: + ValueError: If the committed bytes lay out something other than the addresses the + driver is built to answer at. + """ + code = (resources.files(DRIVER_PACKAGE) / DRIVER_CODE_NAME).read_bytes() + return cls(code=code, addresses=DriverAddresses.for_code(len(code))) diff --git a/src/sampletones_player/driver/nes.inc b/src/sampletones_player/driver/nes.inc new file mode 100644 index 000000000..02073cb05 --- /dev/null +++ b/src/sampletones_player/driver/nes.inc @@ -0,0 +1,23 @@ +CHANNEL_CONTROL = $4000 +CHANNEL_TIMER_LOW = $4002 +CHANNEL_TIMER_HIGH = $4003 + +PULSE1_REGISTERS = $00 +PULSE2_REGISTERS = $04 +TRIANGLE_REGISTERS = $08 +NOISE_REGISTERS = $0C + +PULSE1_SWEEP = $4001 +PULSE2_SWEEP = $4005 +NOISE_LENGTH_COUNTER = $400F +APU_STATUS = $4015 +APU_FRAME_COUNTER = $4017 + +FIRST_CHANNEL_REGISTER = $4000 +CHANNEL_REGISTER_COUNT = $14 + +SILENCED_REGISTER = $00 +CHANNELS_ENABLED = $0F +FRAME_COUNTER_SEQUENCE = $40 +SWEEP_DISABLED = $08 +NOISE_LENGTH_COUNTER_LOAD = $00 diff --git a/src/sampletones_player/driver/nsf.cfg b/src/sampletones_player/driver/nsf.cfg new file mode 100644 index 000000000..f27d950a8 --- /dev/null +++ b/src/sampletones_player/driver/nsf.cfg @@ -0,0 +1,10 @@ +MEMORY { + ZP: start = $0000, size = $0100, type = rw, define = yes; + PRG: start = $8000, size = $8000, type = ro, define = yes, file = %O, fill = no; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp; + CODE: load = PRG, type = ro; + SONG: load = PRG, type = ro, optional = yes; +} diff --git a/src/sampletones_player/driver/song.inc b/src/sampletones_player/driver/song.inc new file mode 100644 index 000000000..e24a5ec6f --- /dev/null +++ b/src/sampletones_player/driver/song.inc @@ -0,0 +1,17 @@ +WORD_SIZE = 2 + +STEP_WHOLE_OFFSET = 0 +STEP_FRACTION_OFFSET = STEP_WHOLE_OFFSET + 1 +TOTAL_TICKS_OFFSET = STEP_FRACTION_OFFSET + WORD_SIZE +LOOP_TICK_OFFSET = TOTAL_TICKS_OFFSET + WORD_SIZE +STREAM_OFFSETS_OFFSET = LOOP_TICK_OFFSET + WORD_SIZE + +NO_LOOP = $FFFF + +PULSE1_STREAM = 0 * WORD_SIZE +PULSE2_STREAM = 1 * WORD_SIZE +TRIANGLE_STREAM = 2 * WORD_SIZE +NOISE_STREAM = 3 * WORD_SIZE + +TONE_RECORD_SIZE = 3 +NOISE_RECORD_SIZE = 2 diff --git a/src/sampletones_player/specification/driver.py b/src/sampletones_player/specification/driver.py new file mode 100644 index 000000000..e9f004690 --- /dev/null +++ b/src/sampletones_player/specification/driver.py @@ -0,0 +1,14 @@ +from typing import Final + +from sampletones_player.specification.nsf import PROGRAM_START + +DRIVER_PACKAGE: Final[str] = "sampletones_player.driver" +DRIVER_CODE_NAME: Final[str] = "driver.bin" + +MAX_ADDRESS: Final[int] = 0xFFFF + +JUMP_ABSOLUTE_OPCODE: Final[int] = 0x4C +JUMP_INSTRUCTION_SIZE: Final[int] = 3 +LOAD_ADDRESS: Final[int] = PROGRAM_START +INIT_ADDRESS: Final[int] = LOAD_ADDRESS +PLAY_ADDRESS: Final[int] = LOAD_ADDRESS + JUMP_INSTRUCTION_SIZE diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py new file mode 100644 index 000000000..e770f79f7 --- /dev/null +++ b/src/sampletones_player/specification/nsf.py @@ -0,0 +1,4 @@ +from typing import Final + +PROGRAM_START: Final[int] = 0x8000 +PROGRAM_SIZE: Final[int] = 0x8000 diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py index f463665a3..922458c58 100644 --- a/src/sampletones_player/trace/trace.py +++ b/src/sampletones_player/trace/trace.py @@ -12,6 +12,8 @@ FIRST_CHANNEL_REGISTER, FRAME_COUNTER_SEQUENCE, LAST_CHANNEL_REGISTER, + NOISE_LENGTH_COUNTER, + NOISE_LENGTH_COUNTER_LOAD, PULSE1_SWEEP, PULSE2_SWEEP, REGISTERS_WRITTEN_ON_CHANGE, @@ -33,6 +35,12 @@ class RegisterTrace: that reset a running channel are written only where their value changes, which is what keeps a pulse waveform's phase running across a rest the way a rendered channel does. + A channel sounds only while its length counter stands above zero, and the counter loads from a + write to the register carrying the length index once the channel is enabled. Initialisation + therefore reaches those registers after :data:`APU_STATUS`: the noise channel's directly, and + the three that carry a timer through the first tick's high byte. Halting every counter is what + holds them there for the rest of the song. + Attributes: initialisation: The writes the init routine makes, leaving the console on the song's first tick. @@ -72,6 +80,7 @@ def _initialisation_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[Re writes.append(RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE)) writes.append(RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED)) writes.append(RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED)) + writes.append(RegisterWrite(NOISE_LENGTH_COUNTER, NOISE_LENGTH_COUNTER_LOAD)) writes.extend(cls._tick_writes(song, FIRST_TICK, shadows)) return tuple(writes) diff --git a/tests/unit/sampletones_player/clock/test_schedule.py b/tests/unit/sampletones_player/clock/test_schedule.py index 9ea3664c4..ce81f0680 100644 --- a/tests/unit/sampletones_player/clock/test_schedule.py +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -8,6 +8,7 @@ from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.specification.clock import ( + FIXED_POINT_BITS, FIXED_POINT_SCALE, MAX_STEP_WHOLE, MICROSECONDS_PER_SECOND, @@ -94,22 +95,7 @@ def test_the_stream_only_moves_forward(self, test_case: TestCase) -> None: class TestTheScheduleHoldsTheRate(BaseTestSuite): - """The property the whole schedule exists for: a run of calls lands on its exact tick.""" - - @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) - def test_a_long_run_lands_on_the_exact_tick_count(self, nes_frequency: int) -> None: - schedule = PlaySchedule.from_parameters(nes_frequency) - exact = exact_rate(nes_frequency) * LONG_RUN_PLAY_CALLS - assert abs(schedule.ticks_at(LONG_RUN_PLAY_CALLS) - exact) < 1 - - @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) - def test_the_cumulative_count_never_drifts_past_one_tick(self, nes_frequency: int) -> None: - schedule = PlaySchedule.from_parameters(nes_frequency) - rate = exact_rate(nes_frequency) - assert all( - abs(schedule.ticks_at(play_calls) - rate * play_calls) < 1 - for play_calls in range(0, LONG_RUN_PLAY_CALLS, 97) - ) + """The rate a stream is read at, and the advances a rate dividing the play period produces.""" @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) def test_the_rate_is_the_stream_measured_against_the_play_period(self, nes_frequency: int) -> None: @@ -180,14 +166,26 @@ def test_the_driver_stays_within_a_tick_of_the_exact_schedule(self, nes_frequenc @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) def test_the_driver_starts_on_the_first_tick(self, nes_frequency: int) -> None: schedule = PlaySchedule.from_parameters(nes_frequency) - assert schedule.fixed_point_ticks_at(0) == 0 + assert schedule.ticks_at(0) == 0 assert schedule.maximum_drift(0) == 0 @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) - def test_the_driver_only_moves_forward(self, nes_frequency: int) -> None: + def test_the_accumulator_reaches_the_same_ticks(self, nes_frequency: int) -> None: + """The driver's own loop, held against the schedule it is written from. + + The schedule multiplies the step by the call count, and the 6502 adds the step to a + 16-bit accumulator once a call and reads the ticks off the carry. Both must count the + same, since the assembly follows the second and the golden trace follows the first. + """ schedule = PlaySchedule.from_parameters(nes_frequency) - ticks = [schedule.fixed_point_ticks_at(play_calls) for play_calls in range(1024)] - assert all(later >= earlier for earlier, later in zip(ticks, ticks[1:])) + step = schedule.fixed_point_step + + accumulator = 0 + for play_call in range(LONG_RUN_PLAY_CALLS): + accumulator += step.fraction + advance = step.whole + (accumulator >> FIXED_POINT_BITS) + accumulator %= FIXED_POINT_SCALE + assert advance == schedule.advance_at(play_call) def test_a_whole_step_needs_no_fraction(self) -> None: step = PlaySchedule(ticks_per_play_call=Fraction(3)).fixed_point_step @@ -228,10 +226,10 @@ def test_a_negative_call_count_is_rejected(self) -> None: with pytest.raises(ValueError, match="play_calls must be at least 0"): schedule.ticks_at(-1) - def test_a_negative_call_count_is_rejected_by_the_driver_schedule(self) -> None: + def test_a_negative_call_count_is_rejected_by_the_exact_schedule(self) -> None: schedule = PlaySchedule.from_parameters(60) with pytest.raises(ValueError, match="play_calls must be at least 0"): - schedule.fixed_point_ticks_at(-1) + schedule.exact_ticks_at(-1) def test_a_negative_run_is_rejected_by_the_drift(self) -> None: schedule = PlaySchedule.from_parameters(60) diff --git a/tests/unit/sampletones_player/driver/__init__.py b/tests/unit/sampletones_player/driver/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/driver/test_addresses.py b/tests/unit/sampletones_player/driver/test_addresses.py new file mode 100644 index 000000000..91d4b195d --- /dev/null +++ b/tests/unit/sampletones_player/driver/test_addresses.py @@ -0,0 +1,37 @@ +from typing import Final + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.specification.driver import ( + INIT_ADDRESS, + JUMP_INSTRUCTION_SIZE, + LOAD_ADDRESS, + PLAY_ADDRESS, +) +from sampletones_player.specification.nsf import PROGRAM_START +from tests.suite.base import BaseTestSuite + +CODE_LENGTH: Final[int] = 512 + + +class TestTheDeclaredAddresses(BaseTestSuite): + """The addresses a driver answers at, which hold whatever the build produces.""" + + def test_the_image_loads_where_the_program_area_begins(self) -> None: + assert DriverAddresses.for_code(CODE_LENGTH).load == PROGRAM_START + + def test_the_routines_answer_where_the_specification_states(self) -> None: + addresses = DriverAddresses.for_code(CODE_LENGTH) + assert (addresses.init, addresses.play) == (INIT_ADDRESS, PLAY_ADDRESS) + + def test_the_entry_points_sit_one_jump_apart(self) -> None: + addresses = DriverAddresses.for_code(CODE_LENGTH) + assert addresses.play - addresses.init == JUMP_INSTRUCTION_SIZE + + def test_the_song_follows_the_code(self) -> None: + assert DriverAddresses.for_code(CODE_LENGTH).song == LOAD_ADDRESS + CODE_LENGTH + + def test_a_longer_driver_moves_the_song_alone(self) -> None: + shorter = DriverAddresses.for_code(CODE_LENGTH) + longer = DriverAddresses.for_code(CODE_LENGTH * 2) + assert (longer.load, longer.init, longer.play) == (shorter.load, shorter.init, shorter.play) + assert longer.song > shorter.song diff --git a/tests/unit/sampletones_player/driver/test_image.py b/tests/unit/sampletones_player/driver/test_image.py new file mode 100644 index 000000000..187281d6a --- /dev/null +++ b/tests/unit/sampletones_player/driver/test_image.py @@ -0,0 +1,99 @@ +import shutil +import subprocess +from importlib import resources +from pathlib import Path +from typing import Any, Dict, Final + +import pytest + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import ( + DRIVER_CODE_NAME, + DRIVER_PACKAGE, + INIT_ADDRESS, + JUMP_ABSOLUTE_OPCODE, + LOAD_ADDRESS, + MAX_ADDRESS, + PLAY_ADDRESS, +) +from tests.suite.base import BaseTestSuite + +BUILD_SCRIPT_NAME: Final[str] = "build.sh" +RETURN_OPCODE: Final[int] = 0x60 +JUMP_TABLE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80)) +CODE: Final[bytes] = JUMP_TABLE + bytes((RETURN_OPCODE,)) + + +def image_fields(code: bytes = CODE, **overrides: int) -> Dict[str, Any]: + addresses = { + "load": LOAD_ADDRESS, + "init": INIT_ADDRESS, + "play": PLAY_ADDRESS, + "song": LOAD_ADDRESS + len(code), + } + addresses.update(overrides) + return {"code": code, "addresses": DriverAddresses(**addresses)} + + +class TestTheCommittedDriver(BaseTestSuite): + """The driver the package ships, held to the contract the exporter reads it through.""" + + def test_the_driver_loads(self) -> None: + assert DriverImage.load().code + + def test_the_song_begins_where_the_code_ends(self) -> None: + image = DriverImage.load() + assert image.addresses.song == image.addresses.load + len(image.code) + + def test_the_routines_answer_where_the_specification_states(self) -> None: + image = DriverImage.load() + assert (image.addresses.init, image.addresses.play) == (INIT_ADDRESS, PLAY_ADDRESS) + + def test_the_image_leads_with_a_jump_to_each_routine(self) -> None: + assert DriverImage.load().code[: len(JUMP_TABLE) : 3] == bytes((JUMP_ABSOLUTE_OPCODE,) * 2) + + +class TestTheImageContract(BaseTestSuite): + """What an image must lay out for the exporter to place a song behind the driver.""" + + def test_a_song_address_past_the_code_is_rejected(self) -> None: + with pytest.raises(ValueError, match="the song belongs at"): + DriverImage(**image_fields(song=LOAD_ADDRESS + len(CODE) + 1)) + + def test_a_play_routine_outside_the_code_is_rejected(self) -> None: + with pytest.raises(ValueError, match="the play routine lies at"): + DriverImage(**image_fields(play=LOAD_ADDRESS + len(CODE))) + + def test_an_init_routine_before_the_load_address_is_rejected(self) -> None: + with pytest.raises(ValueError, match="the init routine lies at"): + DriverImage(**image_fields(init=LOAD_ADDRESS - 1)) + + def test_an_image_that_leads_with_anything_but_a_jump_is_rejected(self) -> None: + with pytest.raises(ValueError, match="rather than the jump"): + DriverImage(**image_fields(code=bytes((RETURN_OPCODE,)) * len(CODE))) + + def test_an_empty_image_is_rejected(self) -> None: + with pytest.raises(ValueError): + DriverImage(**image_fields(code=b"")) + + def test_an_address_past_the_bus_is_rejected(self) -> None: + with pytest.raises(ValueError): + DriverImage(**image_fields(play=MAX_ADDRESS + 1)) + + +class TestTheDriverBuild(BaseTestSuite): + """The committed driver against the sources it is built from.""" + + @staticmethod + def build(destination: Path) -> None: + with resources.as_file(resources.files(DRIVER_PACKAGE) / BUILD_SCRIPT_NAME) as script: + subprocess.run(["bash", str(script), str(destination)], check=True, capture_output=True) + + @pytest.mark.skipif(shutil.which("ca65") is None, reason="cc65 assembles the driver") + def test_the_committed_driver_matches_its_sources(self, tmp_path: Path) -> None: + self.build(tmp_path) + committed = (resources.files(DRIVER_PACKAGE) / DRIVER_CODE_NAME).read_bytes() + assert ( + tmp_path / DRIVER_CODE_NAME + ).read_bytes() == committed, f"{DRIVER_CODE_NAME} is behind its sources: run `make player`" diff --git a/tests/unit/sampletones_player/trace/test_trace.py b/tests/unit/sampletones_player/trace/test_trace.py index c77668f59..0f666be5f 100644 --- a/tests/unit/sampletones_player/trace/test_trace.py +++ b/tests/unit/sampletones_player/trace/test_trace.py @@ -10,11 +10,13 @@ FIRST_CHANNEL_REGISTER, FRAME_COUNTER_SEQUENCE, LAST_CHANNEL_REGISTER, + NOISE_LENGTH_COUNTER, PULSE1_CONTROL, PULSE1_SWEEP, PULSE1_TIMER_HIGH, PULSE1_TIMER_LOW, PULSE2_SWEEP, + PULSE2_TIMER_HIGH, REGISTERS_WRITTEN_ON_CHANGE, SILENCED_REGISTER, SWEEP_DISABLED, @@ -75,6 +77,20 @@ def test_the_sweep_survives_the_clearing_pass(self) -> None: sweeps = [write.value for write in self.initialisation if write.address == PULSE1_SWEEP] assert sweeps[-1] == SWEEP_DISABLED + def test_every_length_counter_loads_once_the_channels_are_enabled(self) -> None: + """A channel sounds only while its length counter stands above zero. + + The counter loads from a write to the register carrying its length index, and only while + the channel is enabled, so every one of those registers has to be reached after + :data:`APU_STATUS`. The noise channel's is written for that alone; the other three carry + the first tick's timer high byte. + """ + writes = self.initialisation + enabled = writes.index(RegisterWrite(APU_STATUS, CHANNELS_ENABLED)) + for address in (PULSE1_TIMER_HIGH, PULSE2_TIMER_HIGH, TRIANGLE_TIMER_HIGH, NOISE_LENGTH_COUNTER): + loaded = max(index for index, write in enumerate(writes) if write.address == address) + assert loaded > enabled + def test_the_first_tick_sounds_from_initialisation(self) -> None: first_tick = self.initialisation[-WRITES_PER_TICK:] assert len(first_tick) == WRITES_PER_TICK From e273545bf2f1d9c41cc4f97c3cc4246a91529f50 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 09:20:57 +0200 Subject: [PATCH 005/142] Refactored: the NES driver --- Makefile | 2 +- docs/development/dependencies.md | 24 +-- pyproject.toml | 4 + scripts/player.py | 43 +++++ .../ui/panels/sequencer/input/order.py | 3 +- .../ui/panels/sequencer/input/tracker.py | 7 +- .../driver/assembler/__init__.py | 0 .../driver/assembler/builder.py | 107 +++++++++++++ .../driver/assembler/labels.py | 83 ++++++++++ .../driver/assembler/layout.py | 14 ++ .../driver/assembler/toolchain.py | 148 ++++++++++++++++++ .../driver/{ => assembly/include}/nes.inc | 0 .../driver/{ => assembly/include}/song.inc | 0 .../driver/{ => assembly}/nsf.cfg | 0 .../driver/{ => assembly/source}/channels.s | 0 .../driver/{ => assembly/source}/clock.s | 0 .../driver/{ => assembly/source}/driver.s | 0 .../driver/{ => binary}/driver.bin | Bin src/sampletones_player/driver/build.sh | 57 ------- src/sampletones_player/driver/image.py | 3 +- .../specification/driver.py | 1 + src/sampletones_shared/constants/general.py | 3 + src/sampletones_shared/exceptions/__init__.py | 9 +- src/sampletones_shared/exceptions/player.py | 8 + src/sampletones_shared/utils/color.py | 11 +- tests/suite/sequencer.py | 20 ++- .../reconstruction/test_instruments_panel.py | 24 ++- .../driver/assembler/__init__.py | 0 .../driver/assembler/test_builder.py | 68 ++++++++ .../driver/assembler/test_labels.py | 65 ++++++++ .../driver/assembler/test_toolchain.py | 44 ++++++ .../sampletones_player/driver/test_image.py | 24 --- 32 files changed, 659 insertions(+), 113 deletions(-) create mode 100755 scripts/player.py create mode 100644 src/sampletones_player/driver/assembler/__init__.py create mode 100644 src/sampletones_player/driver/assembler/builder.py create mode 100644 src/sampletones_player/driver/assembler/labels.py create mode 100644 src/sampletones_player/driver/assembler/layout.py create mode 100644 src/sampletones_player/driver/assembler/toolchain.py rename src/sampletones_player/driver/{ => assembly/include}/nes.inc (100%) rename src/sampletones_player/driver/{ => assembly/include}/song.inc (100%) rename src/sampletones_player/driver/{ => assembly}/nsf.cfg (100%) rename src/sampletones_player/driver/{ => assembly/source}/channels.s (100%) rename src/sampletones_player/driver/{ => assembly/source}/clock.s (100%) rename src/sampletones_player/driver/{ => assembly/source}/driver.s (100%) rename src/sampletones_player/driver/{ => binary}/driver.bin (100%) delete mode 100755 src/sampletones_player/driver/build.sh create mode 100644 src/sampletones_shared/constants/general.py create mode 100644 tests/unit/sampletones_player/driver/assembler/__init__.py create mode 100644 tests/unit/sampletones_player/driver/assembler/test_builder.py create mode 100644 tests/unit/sampletones_player/driver/assembler/test_labels.py create mode 100644 tests/unit/sampletones_player/driver/assembler/test_toolchain.py diff --git a/Makefile b/Makefile index 64a67eac3..6ae9b288a 100644 --- a/Makefile +++ b/Makefile @@ -116,7 +116,7 @@ icons: uv run --group assets python scripts/assets/icons.py player: - bash src/sampletones_player/driver/build.sh + uv run scripts/player.py check-import-boundary: uv run scripts/checks/import_boundary.py --all diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index ebf357173..a5e733fdd 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -74,18 +74,24 @@ that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to ## NES player driver -The player that runs on the console is 6502 assembly, held in `src/sampletones_player/driver` -beside the linker configuration and the build script that assembles it. `make player` runs that -script, which needs `ca65` and `ld65` from [cc65](https://cc65.github.io/) — on Debian and Ubuntu, -`sudo apt install cc65`. +The player that runs on the console is 6502 assembly, and `src/sampletones_player/driver` holds it +in three parts: `assembly/` carries the sources, their includes and the linker configuration, +`binary/` carries the assembled `driver.bin`, and `assembler/` carries the Python that turns one +into the other. `make player` runs `scripts/player.py` over that package, so the build behaves the +same on every system the project supports. + +Assembling needs `ca65` and `ld65` from [cc65](https://cc65.github.io/) — on Debian and Ubuntu, +`sudo apt install cc65`, and a build names the equivalent for whichever system it runs on when the +programs are absent. cc65 is a build-time tool for the driver alone, which is why it belongs +neither in the requirements a user installs nor in `scripts/linux/build/dependencies.sh`. The assembled `driver.bin` is committed, so a checkout carries the player and exporting an NSF needs no assembler. A jump table leads the image, which fixes the addresses an NSF header names -whatever the driver's length, so the exporter states them from `specification/driver.py`. cc65 is -a build-time tool for the driver alone, which is why it belongs neither in the requirements a user -installs nor in `scripts/linux/build/dependencies.sh`. Editing the assembly means running -`make player` again and committing what it writes; the driver's test suite rebuilds the sources -and holds the committed image to them wherever cc65 is installed. +whatever the driver's length, so the exporter states them from `specification/driver.py` and a +build holds the linker's own labels to them before it writes anything. Editing the assembly means +running `make player` again and committing what it writes; the driver's test suite rebuilds the +sources and holds the committed image to them wherever cc65 is installed. The wheel carries the +assembled image alone, which is all an installed copy reads. cc65 is distributed under the zlib licence, and the driver stays clear of it: the link line names our own object files and our own `nsf.cfg`, so nothing of cc65's start-up code or libraries reaches diff --git a/pyproject.toml b/pyproject.toml index 024f48a5b..476f4e8a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,10 @@ build-backend = "hatchling.build" conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] [tool.hatch.build.targets.wheel] +exclude = [ + "src/sampletones_player/driver/assembler", + "src/sampletones_player/driver/assembly", +] packages = [ "src/sampletones", "src/sampletones_application", diff --git a/scripts/player.py b/scripts/player.py new file mode 100755 index 000000000..d96e2847c --- /dev/null +++ b/scripts/player.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import argparse +import sys +from pathlib import Path +from typing import Sequence + +from sampletones_player.driver.assembler.builder import build_driver +from sampletones_player.driver.assembler.layout import BINARY_DIRECTORY +from sampletones_player.specification.driver import DRIVER_CODE_NAME +from sampletones_shared.exceptions import DriverBuildError + + +def main(argv: Sequence[str]) -> int: + """Assembles the NES player driver and reports the layout the build produced.""" + + parser = argparse.ArgumentParser( + description="Assemble the NES player driver with cc65.", + ) + parser.add_argument( + "--directory", + type=Path, + default=BINARY_DIRECTORY, + help="directory receiving the assembled driver", + ) + arguments = parser.parse_args(list(argv)) + + try: + image = build_driver(arguments.directory) + except DriverBuildError as error: + print(error, file=sys.stderr) + return 1 + + addresses = image.addresses + print(f"{DRIVER_CODE_NAME} {len(image.code)} bytes, ${addresses.load:04X}-${addresses.song - 1:04X}") + print(f"init ${addresses.init:04X}") + print(f"play ${addresses.play:04X}") + print(f"song ${addresses.song:04X}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 43333b5b5..f54cb2029 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -7,6 +7,7 @@ from sampletones_application.ui.panels.sequencer.input.state import GridInputState from sampletones_application.view_model.sequencer.region import OrderRegion from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.constants.general import HEXADECIMAL_BASE INDEX_DIGITS: Final[int] = 2 @@ -19,7 +20,7 @@ class OrderCursor: def _parse(pending: str) -> Optional[int]: try: - return int(pending, 16) + return int(pending, HEXADECIMAL_BASE) except ValueError: return None diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py index 665e0c51a..80117343d 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -20,6 +20,7 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_shared.constants.general import HEXADECIMAL_BASE from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS DIGIT_COUNT: Final[Dict[SubColumn, int]] = { @@ -43,7 +44,7 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: return EditAction( row=cursor.row, generator=cursor.generator, - sample_index=int(pending, 16), + sample_index=int(pending, HEXADECIMAL_BASE), transpose=None, volume=None, ) @@ -53,7 +54,7 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: generator=cursor.generator, sample_index=None, transpose=None, - volume=min(int(pending, 16), MAX_VOLUME), + volume=min(int(pending, HEXADECIMAL_BASE), MAX_VOLUME), ) case SubColumn.TRANSPOSE: sign = -1 if pending.startswith(MINUS) else 1 @@ -65,7 +66,7 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: row=cursor.row, generator=cursor.generator, sample_index=None, - transpose=sign * int(magnitude, 16), + transpose=sign * int(magnitude, HEXADECIMAL_BASE), volume=None, ) except ValueError: diff --git a/src/sampletones_player/driver/assembler/__init__.py b/src/sampletones_player/driver/assembler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/driver/assembler/builder.py b/src/sampletones_player/driver/assembler/builder.py new file mode 100644 index 000000000..aae212b2c --- /dev/null +++ b/src/sampletones_player/driver/assembler/builder.py @@ -0,0 +1,107 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Final, List + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.driver.assembler.labels import read_addresses +from sampletones_player.driver.assembler.layout import ( + INCLUDE_DIRECTORY, + LINKER_CONFIGURATION, + SOURCE_DIRECTORY, + SOURCE_NAMES, +) +from sampletones_player.driver.assembler.toolchain import Toolchain +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import DRIVER_CODE_NAME +from sampletones_shared.exceptions import DriverBuildError + +LABELS_NAME: Final[str] = "driver.labels" +OBJECT_SUFFIX: Final[str] = ".o" + + +def build_driver(destination: Path) -> DriverImage: + """Assembles the driver and writes the image into ``destination``. + + The image is held to the addresses the exporter reads it through before it reaches the + directory, so a build either produces the driver the package ships or produces nothing. + ``driver.s`` leads the sources because the linker lays a segment out in the order it receives + the object files, and the entry points are the first bytes of the image. + + Args: + destination: The directory receiving ``driver.bin``. + + Returns: + DriverImage: The assembled bytes and the addresses the linker laid them out at. + + Raises: + ToolchainMissingError: If the cc65 programs are absent from the system. + DriverBuildError: If a program fails, or the layout departs from the one the driver is + built to answer at. + """ + toolchain = Toolchain.locate() + with TemporaryDirectory() as directory: + work_directory = Path(directory) + objects = assemble_sources(toolchain, work_directory) + assembled = work_directory / DRIVER_CODE_NAME + labels = work_directory / LABELS_NAME + toolchain.link(LINKER_CONFIGURATION, objects, assembled, labels) + image = DriverImage( + code=assembled.read_bytes(), + addresses=read_addresses(labels), + ) + + verify_addresses(image) + destination.mkdir(parents=True, exist_ok=True) + (destination / DRIVER_CODE_NAME).write_bytes(image.code) + return image + + +def assemble_sources(toolchain: Toolchain, work_directory: Path) -> List[Path]: + """Assembles every source the driver is built from. + + Args: + toolchain: The cc65 programs the build runs. + work_directory: The directory receiving the object files. + + Returns: + List[Path]: The object files, in the order they take in the image. + + Raises: + DriverBuildError: If the assembler reports a failure. + """ + objects: List[Path] = [] + for name in SOURCE_NAMES: + object_file = (work_directory / name).with_suffix(OBJECT_SUFFIX) + toolchain.assemble(SOURCE_DIRECTORY / name, INCLUDE_DIRECTORY, object_file) + objects.append(object_file) + + return objects + + +def verify_addresses(image: DriverImage) -> None: + """Holds a build's own layout to the addresses the driver is built to answer at. + + The exporter states those addresses without an assembler, so a build that laid the image out + elsewhere would leave the committed driver and the header describing it telling different + stories. + + Args: + image: The assembled bytes and the addresses the linker reported for them. + + Raises: + DriverBuildError: If any address departs from the one a driver of that length answers at. + """ + expected = DriverAddresses.for_code(len(image.code)) + mismatches = [ + f"{name} at {reported:#06x} where the driver answers at {stated:#06x}" + for name, reported, stated in ( + ("load", image.addresses.load, expected.load), + ("init", image.addresses.init, expected.init), + ("play", image.addresses.play, expected.play), + ("song", image.addresses.song, expected.song), + ) + if reported != stated + ] + + if mismatches: + raise DriverBuildError(f"the linker laid the driver out with {', '.join(mismatches)}") diff --git a/src/sampletones_player/driver/assembler/labels.py b/src/sampletones_player/driver/assembler/labels.py new file mode 100644 index 000000000..32f7a9c4b --- /dev/null +++ b/src/sampletones_player/driver/assembler/labels.py @@ -0,0 +1,83 @@ +from pathlib import Path +from typing import Dict, Final + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_shared.constants.general import HEXADECIMAL_BASE +from sampletones_shared.exceptions import DriverBuildError + +LABEL_MARKER: Final[str] = "al" +LABEL_FIELDS: Final[int] = 3 +SYMBOL_PREFIX: Final[str] = "." + +LOAD_SYMBOL: Final[str] = "__PRG_START__" +INIT_SYMBOL: Final[str] = "nsf_init" +PLAY_SYMBOL: Final[str] = "nsf_play" +SONG_SYMBOL: Final[str] = "song_data" + + +def read_labels(path: Path) -> Dict[str, int]: + """Reads the addresses a linker reported for its symbols. + + The label file lists one symbol per line as ``al
.``, the format debuggers + read a build's symbols through. Lines of that shape carry the addresses; the rest of the file + describes the build itself. + + Args: + path: The label file the linker wrote. + + Returns: + Dict[str, int]: Each symbol's address, keyed by the symbol's own name. + """ + labels: Dict[str, int] = {} + for line in path.read_text().splitlines(): + fields = line.split() + if len(fields) == LABEL_FIELDS and fields[0] == LABEL_MARKER: + labels[fields[2].removeprefix(SYMBOL_PREFIX)] = int( + fields[1], + HEXADECIMAL_BASE, + ) + + return labels + + +def read_addresses(path: Path) -> DriverAddresses: + """Reads where a linker laid the driver and the song behind it. + + A build states its own layout this way, so what the exporter reads about the image comes from + the program that produced it. + + Args: + path: The label file the linker wrote. + + Returns: + DriverAddresses: The addresses the linker reported. + + Raises: + DriverBuildError: If the linker reported no address for one of the four symbols. + """ + labels = read_labels(path) + return DriverAddresses( + load=address_of(labels, LOAD_SYMBOL), + init=address_of(labels, INIT_SYMBOL), + play=address_of(labels, PLAY_SYMBOL), + song=address_of(labels, SONG_SYMBOL), + ) + + +def address_of(labels: Dict[str, int], symbol: str) -> int: + """The address a linker reported for one symbol. + + Args: + labels: Each symbol's address, as :func:`read_labels` answers. + symbol: The symbol to look up. + + Returns: + int: Where the symbol lies. + + Raises: + DriverBuildError: If the linker reported no address for the symbol. + """ + if symbol not in labels: + raise DriverBuildError(f"the linker reported no address for {symbol}") + + return labels[symbol] diff --git a/src/sampletones_player/driver/assembler/layout.py b/src/sampletones_player/driver/assembler/layout.py new file mode 100644 index 000000000..6e312bbb7 --- /dev/null +++ b/src/sampletones_player/driver/assembler/layout.py @@ -0,0 +1,14 @@ +from pathlib import Path +from typing import Final, Tuple + +from sampletones_player.specification.driver import DRIVER_BINARY_DIRECTORY +from sampletones_shared.paths.source import SOURCE_ROOT + +DRIVER_DIRECTORY: Final[Path] = SOURCE_ROOT / "sampletones_player" / "driver" +ASSEMBLY_DIRECTORY: Final[Path] = DRIVER_DIRECTORY / "assembly" +INCLUDE_DIRECTORY: Final[Path] = ASSEMBLY_DIRECTORY / "include" +SOURCE_DIRECTORY: Final[Path] = ASSEMBLY_DIRECTORY / "source" +LINKER_CONFIGURATION: Final[Path] = ASSEMBLY_DIRECTORY / "nsf.cfg" +BINARY_DIRECTORY: Final[Path] = DRIVER_DIRECTORY / DRIVER_BINARY_DIRECTORY + +SOURCE_NAMES: Final[Tuple[str, ...]] = ("driver.s", "clock.s", "channels.s") diff --git a/src/sampletones_player/driver/assembler/toolchain.py b/src/sampletones_player/driver/assembler/toolchain.py new file mode 100644 index 000000000..0f70e1e74 --- /dev/null +++ b/src/sampletones_player/driver/assembler/toolchain.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Sequence + +from sampletones_shared.exceptions import DriverBuildError, ToolchainMissingError +from sampletones_shared.utils.system.system import System + +ASSEMBLER: Final[str] = "ca65" +LINKER: Final[str] = "ld65" +TARGET_CPU: Final[str] = "6502" + +INSTALL_HINTS: Final[Dict[System, str]] = { + System.LINUX: "sudo apt install cc65", + System.MACOS: "brew install cc65", + System.WINDOWS: "install cc65 from https://cc65.github.io and add its bin directory to PATH", +} + + +@dataclass(frozen=True) +class Toolchain: + """The cc65 programs a driver build runs. + + Locating both programs up front is what lets a build fail with an install hint before it + writes anything, and holding them as paths keeps every later call pointed at the same pair. + + Attributes: + assembler: The ``ca65`` program, which turns one assembly source into an object file. + linker: The ``ld65`` program, which lays the object files out into the driver image. + """ + + assembler: Path + linker: Path + + @classmethod + def locate(cls) -> Toolchain: + """The cc65 programs installed on this system. + + Returns: + Toolchain: The assembler and the linker, each resolved to its path. + + Raises: + ToolchainMissingError: If either program is absent, naming the way this system + installs cc65. + """ + return cls(assembler=cls.find(ASSEMBLER), linker=cls.find(LINKER)) + + @staticmethod + def find(program: str) -> Path: + """Resolves one cc65 program to its path. + + Args: + program: The program's name, as it answers on the command line. + + Returns: + Path: Where the program is installed. + + Raises: + ToolchainMissingError: If the program is absent from the system. + """ + located = shutil.which(program) + if located is None: + hint = INSTALL_HINTS[System.current()] + raise ToolchainMissingError(f"{program} is missing: the player driver is assembled with cc65 ({hint})") + + return Path(located) + + def assemble(self, source: Path, include_directory: Path, destination: Path) -> None: + """Assembles one 6502 source into an object file. + + Args: + source: The assembly source to translate. + include_directory: Where the sources' ``.include`` files are found. + destination: The object file the assembler writes. + + Raises: + DriverBuildError: If the assembler reports a failure. + """ + self.run( + [ + str(self.assembler), + "--cpu", + TARGET_CPU, + "--include-dir", + str(include_directory), + "-o", + str(destination), + str(source), + ], + ) + + def link( + self, + configuration: Path, + objects: Sequence[Path], + destination: Path, + labels: Path, + ) -> None: + """Lays the object files out into the driver image. + + The line names our own configuration and our own object files alone, which is what keeps + the image entirely ours to ship: reaching for a cc65 target or library would place that + project's start-up code and routines in the bytes the package commits. + + Args: + configuration: The linker configuration stating the memory layout. + objects: The object files, in the order they take in the image. + destination: The driver image the linker writes. + labels: The label file the linker writes, holding each symbol's address. + + Raises: + DriverBuildError: If the linker reports a failure. + """ + self.run( + [ + str(self.linker), + "--config", + str(configuration), + "-Ln", + str(labels), + "-o", + str(destination), + *[str(object_file) for object_file in objects], + ], + ) + + @staticmethod + def run(command: Sequence[str]) -> None: + """Runs one cc65 program and answers for what it reported. + + Args: + command: The program and its arguments. + + Raises: + DriverBuildError: If the program exits with a failure, carrying what it printed. + """ + arguments: List[str] = list(command) + completed = subprocess.run( + arguments, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise DriverBuildError(f"{Path(arguments[0]).name} failed: {completed.stderr.strip()}") diff --git a/src/sampletones_player/driver/nes.inc b/src/sampletones_player/driver/assembly/include/nes.inc similarity index 100% rename from src/sampletones_player/driver/nes.inc rename to src/sampletones_player/driver/assembly/include/nes.inc diff --git a/src/sampletones_player/driver/song.inc b/src/sampletones_player/driver/assembly/include/song.inc similarity index 100% rename from src/sampletones_player/driver/song.inc rename to src/sampletones_player/driver/assembly/include/song.inc diff --git a/src/sampletones_player/driver/nsf.cfg b/src/sampletones_player/driver/assembly/nsf.cfg similarity index 100% rename from src/sampletones_player/driver/nsf.cfg rename to src/sampletones_player/driver/assembly/nsf.cfg diff --git a/src/sampletones_player/driver/channels.s b/src/sampletones_player/driver/assembly/source/channels.s similarity index 100% rename from src/sampletones_player/driver/channels.s rename to src/sampletones_player/driver/assembly/source/channels.s diff --git a/src/sampletones_player/driver/clock.s b/src/sampletones_player/driver/assembly/source/clock.s similarity index 100% rename from src/sampletones_player/driver/clock.s rename to src/sampletones_player/driver/assembly/source/clock.s diff --git a/src/sampletones_player/driver/driver.s b/src/sampletones_player/driver/assembly/source/driver.s similarity index 100% rename from src/sampletones_player/driver/driver.s rename to src/sampletones_player/driver/assembly/source/driver.s diff --git a/src/sampletones_player/driver/driver.bin b/src/sampletones_player/driver/binary/driver.bin similarity index 100% rename from src/sampletones_player/driver/driver.bin rename to src/sampletones_player/driver/binary/driver.bin diff --git a/src/sampletones_player/driver/build.sh b/src/sampletones_player/driver/build.sh deleted file mode 100755 index 4a1efb55a..000000000 --- a/src/sampletones_player/driver/build.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -driver_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -output_directory="${1:-$driver_directory}" -sources=(driver.s clock.s channels.s) - -for tool in ca65 ld65; do - if ! command -v "$tool" > /dev/null 2>&1; then - echo "$tool is missing: the player driver is built with cc65 (sudo apt install cc65)" >&2 - exit 1 - fi -done - -work_directory="$(mktemp -d)" -trap 'rm -rf "$work_directory"' EXIT - -objects=() -for source in "${sources[@]}"; do - object="$work_directory/${source%.s}.o" - ca65 --cpu 6502 --include-dir "$driver_directory" -o "$object" "$driver_directory/$source" - objects+=("$object") -done - -ld65 \ - --config "$driver_directory/nsf.cfg" \ - --mapfile "$work_directory/driver.map" \ - -Ln "$work_directory/driver.labels" \ - -o "$output_directory/driver.bin" \ - "${objects[@]}" - -address() { - local symbol="$1" - local value - value="$(awk -v name=".$symbol" '$3 == name { print $2 }' "$work_directory/driver.labels")" - if [[ -z "$value" ]]; then - echo "the linker reported no address for $symbol" >&2 - exit 1 - fi - echo $((16#$value)) -} - -load_address="$(address __PRG_START__)" -init_address="$(address nsf_init)" -play_address="$(address nsf_play)" -song_address="$(address song_data)" -driver_length="$(wc -c < "$output_directory/driver.bin")" - -if ((song_address - load_address != driver_length)); then - echo "the song starts at $((song_address - load_address)) bytes and the driver is $driver_length long" >&2 - exit 1 -fi - -printf 'driver.bin %d bytes, $%04X-$%04X\n' "$driver_length" "$load_address" "$((song_address - 1))" -printf 'init $%04X\n' "$init_address" -printf 'play $%04X\n' "$play_address" -printf 'song $%04X\n' "$song_address" diff --git a/src/sampletones_player/driver/image.py b/src/sampletones_player/driver/image.py index 125e9c70b..f16292b57 100644 --- a/src/sampletones_player/driver/image.py +++ b/src/sampletones_player/driver/image.py @@ -6,6 +6,7 @@ from sampletones_player.driver.addresses import DriverAddresses from sampletones_player.specification.driver import ( + DRIVER_BINARY_DIRECTORY, DRIVER_CODE_NAME, DRIVER_PACKAGE, JUMP_ABSOLUTE_OPCODE, @@ -76,5 +77,5 @@ def load(cls) -> DriverImage: ValueError: If the committed bytes lay out something other than the addresses the driver is built to answer at. """ - code = (resources.files(DRIVER_PACKAGE) / DRIVER_CODE_NAME).read_bytes() + code = (resources.files(DRIVER_PACKAGE) / DRIVER_BINARY_DIRECTORY / DRIVER_CODE_NAME).read_bytes() return cls(code=code, addresses=DriverAddresses.for_code(len(code))) diff --git a/src/sampletones_player/specification/driver.py b/src/sampletones_player/specification/driver.py index e9f004690..a68a8593b 100644 --- a/src/sampletones_player/specification/driver.py +++ b/src/sampletones_player/specification/driver.py @@ -3,6 +3,7 @@ from sampletones_player.specification.nsf import PROGRAM_START DRIVER_PACKAGE: Final[str] = "sampletones_player.driver" +DRIVER_BINARY_DIRECTORY: Final[str] = "binary" DRIVER_CODE_NAME: Final[str] = "driver.bin" MAX_ADDRESS: Final[int] = 0xFFFF diff --git a/src/sampletones_shared/constants/general.py b/src/sampletones_shared/constants/general.py new file mode 100644 index 000000000..aa60b07bd --- /dev/null +++ b/src/sampletones_shared/constants/general.py @@ -0,0 +1,3 @@ +from typing import Final + +HEXADECIMAL_BASE: Final[int] = 16 diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 9ae9b5c06..3183e4ad7 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -15,7 +15,12 @@ NoLibraryDataError, UnhandledLibraryError, ) -from .player import PlayerError, SongTooLargeError +from .player import ( + DriverBuildError, + PlayerError, + SongTooLargeError, + ToolchainMissingError, +) from .project import ( IncompatibleProjectVersionError, IncorrectReconstructionDataError, @@ -47,6 +52,7 @@ "CallbackQueueStop", "CuPyNotInstalledWarning", "DeserializationError", + "DriverBuildError", "FileDialogUnavailableError", "IncompatibleLibraryDataVersionError", "IncompatibleProjectVersionError", @@ -78,6 +84,7 @@ "SampleToNESError", "SerializationError", "SongTooLargeError", + "ToolchainMissingError", "UnhandledLibraryError", "UnhandledProjectError", "UnhandledReconstructionError", diff --git a/src/sampletones_shared/exceptions/player.py b/src/sampletones_shared/exceptions/player.py index bbeab7cf8..419771fc1 100644 --- a/src/sampletones_shared/exceptions/player.py +++ b/src/sampletones_shared/exceptions/player.py @@ -7,3 +7,11 @@ class PlayerError(SampleToNESError): class SongTooLargeError(PlayerError): """Raised when a song's data outgrows the space the player has for it.""" + + +class DriverBuildError(PlayerError): + """Raised when a driver build produces something other than the image the exporter reads.""" + + +class ToolchainMissingError(DriverBuildError): + """Raised when the programs a driver build runs are absent from the system.""" diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index 63f2a2507..dbc220c5e 100644 --- a/src/sampletones_shared/utils/color.py +++ b/src/sampletones_shared/utils/color.py @@ -3,6 +3,7 @@ import numpy as np from pydantic import BeforeValidator +from sampletones_shared.constants.general import HEXADECIMAL_BASE from sampletones_shared.types.application import ColorRGBA from sampletones_shared.utils.arrays import clamp @@ -87,14 +88,14 @@ def parse_hex_color(value: str) -> ColorRGBA: raise ValueError(f"Color must have 6 or 8 hex digits after '#', got {len(hex_part)}: {value!r}") try: - int(hex_part, 16) + int(hex_part, HEXADECIMAL_BASE) except ValueError as exception: raise ValueError(f"Color contains non-hex characters: {value!r}") from exception - r = int(hex_part[0:2], 16) - g = int(hex_part[2:4], 16) - b = int(hex_part[4:6], 16) - a = int(hex_part[6:8], 16) if len(hex_part) == 8 else 255 + r = int(hex_part[0:2], HEXADECIMAL_BASE) + g = int(hex_part[2:4], HEXADECIMAL_BASE) + b = int(hex_part[4:6], HEXADECIMAL_BASE) + a = int(hex_part[6:8], HEXADECIMAL_BASE) if len(hex_part) == 8 else 255 return (r, g, b, a) diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index ff4b13f71..b33e270fe 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -4,9 +4,16 @@ import numpy as np from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.sequencer.order import OrderBlock, SequencerOrderLogic +from sampletones_application.logic.sequencer.order import ( + OrderBlock, + SequencerOrderLogic, +) from sampletones_application.logic.sequencer.order.block import BlockKey as OrderBlockKey -from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock +from sampletones_application.logic.sequencer.tracker import ( + BlockNote, + SequencerTrackerLogic, + TrackerBlock, +) from sampletones_application.logic.sequencer.tracker.block import BlockKey from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS from sampletones_application.view_model.sequencer.subcolumn import SubColumn @@ -28,6 +35,7 @@ NOTE_OFF, display_id, ) +from sampletones_shared.constants.general import HEXADECIMAL_BASE from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS SAMPLE_LENGTH: Final[int] = 64 @@ -152,7 +160,7 @@ def parse_index(token: str) -> Optional[int]: if token == display_id(None): return None - return int(token, 16) + return int(token, HEXADECIMAL_BASE) def parse_block( @@ -242,14 +250,14 @@ def parse_note( if token == UNKNOWN_SAMPLE: return UNKNOWN_SAMPLE_ID - return sample_ids[int(token, 16)] + return sample_ids[int(token, HEXADECIMAL_BASE)] def parse_transpose(token: str) -> Optional[int]: if token == NOTE_BLANK: return None - magnitude = int(token[1:], 16) + magnitude = int(token[1:], HEXADECIMAL_BASE) return -magnitude if token.startswith(MINUS) else magnitude @@ -257,7 +265,7 @@ def parse_volume(token: str) -> Optional[int]: if token == BLANK: return None - return int(token, 16) + return int(token, HEXADECIMAL_BASE) def _instruction(generator: GeneratorName) -> InstructionUnion: diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index 9b9104241..26881292e 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -24,16 +24,23 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle from sampletones_application.ui.panels.reconstruction.instruments import instruments as instruments_module -from sampletones_application.ui.panels.reconstruction.instruments.instruments import GUIReconstructionInstrumentsPanel +from sampletones_application.ui.panels.reconstruction.instruments.instruments import ( + GUIReconstructionInstrumentsPanel, +) from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource -from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel +from sampletones_application.view_model.reconstruction.instruments import ( + ReconstructionInstrumentsViewModel, +) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint -from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.formats.famitracker.specification.sequences import ( + MAX_SEQUENCE_ITEMS, +) +from sampletones_shared.constants.general import HEXADECIMAL_BASE from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -215,8 +222,15 @@ def test_a_sequence_within_the_limit_describes_editing( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, 16) - message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME) + panel._apply_input_theme( + GeneratorName.PULSE1, + FeatureKey.VOLUME, + HEXADECIMAL_BASE, + ) + message = panel._sequence_status_message( + GeneratorName.PULSE1, + FeatureKey.VOLUME, + ) assert message == panel._language_manager[SEQUENCE_STATUS_KEY].format( instrument_feature=FeatureKey.VOLUME.capitalized ) diff --git a/tests/unit/sampletones_player/driver/assembler/__init__.py b/tests/unit/sampletones_player/driver/assembler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/driver/assembler/test_builder.py b/tests/unit/sampletones_player/driver/assembler/test_builder.py new file mode 100644 index 000000000..0c8479146 --- /dev/null +++ b/tests/unit/sampletones_player/driver/assembler/test_builder.py @@ -0,0 +1,68 @@ +import shutil +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.driver.assembler.builder import build_driver, verify_addresses +from sampletones_player.driver.assembler.toolchain import ASSEMBLER +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import ( + DRIVER_CODE_NAME, + JUMP_ABSOLUTE_OPCODE, + JUMP_INSTRUCTION_SIZE, + LOAD_ADDRESS, +) +from sampletones_shared.exceptions import DriverBuildError +from tests.suite.base import BaseTestSuite + +DISPLACEMENT: Final[int] = 0x0100 +RETURN_OPCODE: Final[int] = 0x60 +CODE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, RETURN_OPCODE)) + +cc65_installed = pytest.mark.skipif(shutil.which(ASSEMBLER) is None, reason="cc65 assembles the driver") + + +@pytest.fixture(name="built_driver", scope="module") +def fixture_built_driver(tmp_path_factory: pytest.TempPathFactory) -> DriverImage: + return build_driver(tmp_path_factory.mktemp("driver")) + + +class TestTheLayoutABuildProduces(BaseTestSuite): + """A build's own layout, held to the addresses the exporter reads the driver through.""" + + @staticmethod + def displaced_image(displacement: int) -> DriverImage: + load = LOAD_ADDRESS + displacement + addresses = DriverAddresses( + load=load, + init=load, + play=load + JUMP_INSTRUCTION_SIZE, + song=load + len(CODE), + ) + return DriverImage(code=CODE, addresses=addresses) + + def test_a_displaced_image_names_the_load_address_that_moved(self) -> None: + with pytest.raises(DriverBuildError, match="load at"): + verify_addresses(self.displaced_image(DISPLACEMENT)) + + def test_a_displaced_image_names_the_song_address_that_moved(self) -> None: + with pytest.raises(DriverBuildError, match="song at"): + verify_addresses(self.displaced_image(DISPLACEMENT)) + + +@cc65_installed +class TestTheDriverBuild(BaseTestSuite): + """The committed driver against the sources it is built from.""" + + def test_the_committed_driver_matches_its_sources(self, built_driver: DriverImage) -> None: + message = f"{DRIVER_CODE_NAME} is behind its sources: run `make player`" + assert built_driver.code == DriverImage.load().code, message + + def test_the_linker_lays_the_driver_out_where_it_is_declared(self, built_driver: DriverImage) -> None: + assert built_driver.addresses == DriverImage.load().addresses + + def test_the_build_writes_the_image_it_answers_with(self, tmp_path: Path) -> None: + built = build_driver(tmp_path) + assert (tmp_path / DRIVER_CODE_NAME).read_bytes() == built.code diff --git a/tests/unit/sampletones_player/driver/assembler/test_labels.py b/tests/unit/sampletones_player/driver/assembler/test_labels.py new file mode 100644 index 000000000..411d50765 --- /dev/null +++ b/tests/unit/sampletones_player/driver/assembler/test_labels.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_player.driver.assembler.labels import ( + INIT_SYMBOL, + LOAD_SYMBOL, + PLAY_SYMBOL, + SONG_SYMBOL, + read_addresses, + read_labels, +) +from sampletones_player.specification.driver import INIT_ADDRESS, LOAD_ADDRESS, PLAY_ADDRESS +from sampletones_shared.exceptions import DriverBuildError +from tests.suite.base import BaseTestSuite + +LABELS_NAME: Final[str] = "driver.labels" +CODE_LENGTH: Final[int] = 512 +SONG_ADDRESS: Final[int] = LOAD_ADDRESS + CODE_LENGTH +LABEL_FILE: Final[str] = "\n".join( + ( + f"al {LOAD_ADDRESS:06X} .{LOAD_SYMBOL}", + f"al {INIT_ADDRESS:06X} .{INIT_SYMBOL}", + f"al {PLAY_ADDRESS:06X} .{PLAY_SYMBOL}", + f"al {SONG_ADDRESS:06X} .{SONG_SYMBOL}", + "al 000002 .current_tick", + ) +) + + +def write_labels(directory: Path, text: str) -> Path: + path = directory / LABELS_NAME + path.write_text(text) + return path + + +class TestTheLabelsALinkerReports(BaseTestSuite): + """What a build reads back out of the label file its linker wrote.""" + + def test_every_symbol_carries_its_address(self, tmp_path: Path) -> None: + labels = read_labels(write_labels(tmp_path, LABEL_FILE)) + assert labels[INIT_SYMBOL] == INIT_ADDRESS + assert labels[SONG_SYMBOL] == SONG_ADDRESS + + def test_a_zero_page_symbol_is_read_alongside_the_program(self, tmp_path: Path) -> None: + assert read_labels(write_labels(tmp_path, LABEL_FILE))["current_tick"] == 2 + + def test_a_line_of_another_shape_carries_no_symbol(self, tmp_path: Path) -> None: + text = f"{LABEL_FILE}\nbuilt with ld65\n" + assert set(read_labels(write_labels(tmp_path, text))) == set(read_labels(write_labels(tmp_path, LABEL_FILE))) + + def test_the_reported_layout_is_the_driver_s_own(self, tmp_path: Path) -> None: + addresses = read_addresses(write_labels(tmp_path, LABEL_FILE)) + assert (addresses.load, addresses.init, addresses.play, addresses.song) == ( + LOAD_ADDRESS, + INIT_ADDRESS, + PLAY_ADDRESS, + SONG_ADDRESS, + ) + + def test_a_missing_symbol_is_reported(self, tmp_path: Path) -> None: + text = "\n".join(line for line in LABEL_FILE.splitlines() if SONG_SYMBOL not in line) + with pytest.raises(DriverBuildError, match=SONG_SYMBOL): + read_addresses(write_labels(tmp_path, text)) diff --git a/tests/unit/sampletones_player/driver/assembler/test_toolchain.py b/tests/unit/sampletones_player/driver/assembler/test_toolchain.py new file mode 100644 index 000000000..89602b787 --- /dev/null +++ b/tests/unit/sampletones_player/driver/assembler/test_toolchain.py @@ -0,0 +1,44 @@ +import shutil +import sys +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_player.driver.assembler.toolchain import ( + ASSEMBLER, + INSTALL_HINTS, + LINKER, + Toolchain, +) +from sampletones_shared.exceptions import DriverBuildError, ToolchainMissingError +from sampletones_shared.utils.system.system import System +from tests.suite.base import BaseTestSuite + +FAILING_PROGRAM: Final[str] = "import sys; sys.stderr.write('boom'); sys.exit(1)" + + +class TestTheToolchainALocatedBuildRuns(BaseTestSuite): + """The cc65 programs a build resolves before it produces anything.""" + + def test_every_system_states_how_it_installs_cc65(self) -> None: + assert set(INSTALL_HINTS) == set(System) + + def test_a_located_program_answers_with_its_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + installed = Path("/usr/local/bin") / ASSEMBLER + monkeypatch.setattr(shutil, "which", lambda program: str(installed)) + assert Toolchain.locate() == Toolchain(assembler=installed, linker=installed) + + def test_a_missing_program_names_itself(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shutil, "which", lambda program: None) + with pytest.raises(ToolchainMissingError, match=ASSEMBLER): + Toolchain.locate() + + def test_a_missing_program_states_how_this_system_installs_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shutil, "which", lambda program: None) + with pytest.raises(ToolchainMissingError, match=INSTALL_HINTS[System.current()]): + Toolchain.find(LINKER) + + def test_a_program_that_fails_carries_what_it_printed(self) -> None: + with pytest.raises(DriverBuildError, match="boom"): + Toolchain.run([sys.executable, "-c", FAILING_PROGRAM]) diff --git a/tests/unit/sampletones_player/driver/test_image.py b/tests/unit/sampletones_player/driver/test_image.py index 187281d6a..a9151e1b0 100644 --- a/tests/unit/sampletones_player/driver/test_image.py +++ b/tests/unit/sampletones_player/driver/test_image.py @@ -1,7 +1,3 @@ -import shutil -import subprocess -from importlib import resources -from pathlib import Path from typing import Any, Dict, Final import pytest @@ -9,8 +5,6 @@ from sampletones_player.driver.addresses import DriverAddresses from sampletones_player.driver.image import DriverImage from sampletones_player.specification.driver import ( - DRIVER_CODE_NAME, - DRIVER_PACKAGE, INIT_ADDRESS, JUMP_ABSOLUTE_OPCODE, LOAD_ADDRESS, @@ -19,7 +13,6 @@ ) from tests.suite.base import BaseTestSuite -BUILD_SCRIPT_NAME: Final[str] = "build.sh" RETURN_OPCODE: Final[int] = 0x60 JUMP_TABLE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80)) CODE: Final[bytes] = JUMP_TABLE + bytes((RETURN_OPCODE,)) @@ -80,20 +73,3 @@ def test_an_empty_image_is_rejected(self) -> None: def test_an_address_past_the_bus_is_rejected(self) -> None: with pytest.raises(ValueError): DriverImage(**image_fields(play=MAX_ADDRESS + 1)) - - -class TestTheDriverBuild(BaseTestSuite): - """The committed driver against the sources it is built from.""" - - @staticmethod - def build(destination: Path) -> None: - with resources.as_file(resources.files(DRIVER_PACKAGE) / BUILD_SCRIPT_NAME) as script: - subprocess.run(["bash", str(script), str(destination)], check=True, capture_output=True) - - @pytest.mark.skipif(shutil.which("ca65") is None, reason="cc65 assembles the driver") - def test_the_committed_driver_matches_its_sources(self, tmp_path: Path) -> None: - self.build(tmp_path) - committed = (resources.files(DRIVER_PACKAGE) / DRIVER_CODE_NAME).read_bytes() - assert ( - tmp_path / DRIVER_CODE_NAME - ).read_bytes() == committed, f"{DRIVER_CODE_NAME} is behind its sources: run `make player`" From 863ddd0fc230c24cb4f1167f48b13ab81407a2ad Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 09:26:32 +0200 Subject: [PATCH 006/142] Bumped: project version --- CHANGELOG.md | 4 ++++ README.md | 5 ++++- pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 607071e29..5fc0c47df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # SampleToNES +## v0.3.2 + +* Added NSF player and export. + ## v0.3.1 [2026-08-18] * Added support to [Bitphase](https://github.com/paator/bitphase). diff --git a/README.md b/README.md index 9d505bfb6..d80a99a01 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,16 @@
SampleToNES +

SampleToNES v0.3.2

## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). -SampleToNES +
+ SampleToNES +
The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. diff --git a/pyproject.toml b/pyproject.toml index 476f4e8a9..44ade8cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sampletones" -version = "0.3.1" +version = "0.3.2" description = "Approximate audio samples with the NES 2A03 oscillators and export them as FamiTracker instruments" readme = "README.md" requires-python = ">=3.12" From 8afd28674f62496739d18d1d1f32f0235dddd3f5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 10:46:30 +0200 Subject: [PATCH 007/142] Refactored: the player's song and schedule as models --- src/sampletones_player/clock/schedule.py | 23 +++++++++---------- src/sampletones_player/nsf/song.py | 4 ++-- src/sampletones_player/registers/streams.py | 6 ++--- src/sampletones_player/song.py | 15 ++++++++---- .../specification/channels.py | 7 ------ src/sampletones_player/specification/song.py | 4 ++-- src/sampletones_player/trace/trace.py | 5 ++-- .../sampletones_player/clock/test_schedule.py | 14 +++++++++-- .../unit/sampletones_player/nsf/test_song.py | 5 ++-- .../registers/test_streams.py | 1 - tests/unit/sampletones_player/test_song.py | 10 ++++++-- uv.lock | 2 +- 12 files changed, 55 insertions(+), 41 deletions(-) diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py index 348b41655..3c539d639 100644 --- a/src/sampletones_player/clock/schedule.py +++ b/src/sampletones_player/clock/schedule.py @@ -1,9 +1,11 @@ from __future__ import annotations -from dataclasses import dataclass from fractions import Fraction +from functools import cached_property from math import floor +from pydantic import BaseModel, ConfigDict, Field + from sampletones_player.clock.step import FixedPointStep from sampletones_player.specification.clock import ( FIXED_POINT_BITS, @@ -14,8 +16,7 @@ ) -@dataclass(frozen=True) -class PlaySchedule: +class PlaySchedule(BaseModel): """The engine ticks each play call advances a stream by, counted the way the console counts them. An NSF asks the console to call its play routine at one fixed rate, and a reconstruction is @@ -40,14 +41,9 @@ class PlaySchedule: ticks_per_play_call: The exact ticks one play call advances the stream by. """ - ticks_per_play_call: Fraction - - def __post_init__(self) -> None: - if self.ticks_per_play_call <= 0: - raise ValueError(f"ticks_per_play_call must be above 0, got {self.ticks_per_play_call}") + model_config = ConfigDict(extra="forbid", frozen=True) - if self.ticks_per_play_call > MAX_STEP_WHOLE: - raise ValueError(f"ticks_per_play_call must be at most {MAX_STEP_WHOLE}, got {self.ticks_per_play_call}") + ticks_per_play_call: Fraction = Field(..., gt=0, le=MAX_STEP_WHOLE) @classmethod def from_parameters(cls, nes_frequency: int) -> PlaySchedule: @@ -132,9 +128,12 @@ def advance_at(self, play_call: int) -> int: """ return self.ticks_at(play_call + 1) - self.ticks_at(play_call) - @property + @cached_property def fixed_point_step(self) -> FixedPointStep: - """The exact step rounded to the nearest unit the driver's accumulator counts in.""" + """The exact step rounded to the nearest unit the driver's accumulator counts in. + + Every answer the schedule gives counts in this step, so it is computed once and held. + """ whole, fraction = divmod(round(self.ticks_per_play_call * FIXED_POINT_SCALE), FIXED_POINT_SCALE) return FixedPointStep(whole=whole, fraction=fraction) diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py index 0a43d46e0..78addd2e7 100644 --- a/src/sampletones_player/nsf/song.py +++ b/src/sampletones_player/nsf/song.py @@ -1,9 +1,9 @@ from typing import Sequence, Tuple +from sampletones_core.constants.enums import GeneratorName from sampletones_core.formats.binary import BinaryWriter from sampletones_player.registers.base import ChannelRegisters from sampletones_player.song import Song -from sampletones_player.specification.channels import CHANNEL_ORDER from sampletones_player.specification.song import ( MAX_STREAM_OFFSET, NO_LOOP, @@ -51,7 +51,7 @@ def _validate_space(size: int, available_bytes: int) -> None: def _validate_offsets(offsets: Sequence[int]) -> None: - for channel, offset in zip(CHANNEL_ORDER, offsets): + for channel, offset in zip(GeneratorName.items(), offsets): if offset > MAX_STREAM_OFFSET: raise SongTooLargeError( f"the {channel.value} stream starts {offset} bytes into the song " diff --git a/src/sampletones_player/registers/streams.py b/src/sampletones_player/registers/streams.py index 2a55c19e3..29e1726fa 100644 --- a/src/sampletones_player/registers/streams.py +++ b/src/sampletones_player/registers/streams.py @@ -4,12 +4,12 @@ from pydantic import BaseModel, ConfigDict, model_validator +from sampletones_core.constants.enums import GeneratorName from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.hold import hold from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.triangle import TriangleRegisters -from sampletones_player.specification.channels import CHANNEL_ORDER class ChannelStreams(BaseModel): @@ -37,7 +37,7 @@ class ChannelStreams(BaseModel): @model_validator(mode="after") def _validate_every_channel_reaches_a_tick(self) -> ChannelStreams: - empty = tuple(channel.value for channel, stream in zip(CHANNEL_ORDER, self.ordered) if not stream) + empty = tuple(channel.value for channel, stream in zip(GeneratorName.items(), self.ordered) if not stream) if empty: raise ValueError(f"every channel needs at least one tick, and {', '.join(empty)} has none") @@ -45,7 +45,7 @@ def _validate_every_channel_reaches_a_tick(self) -> ChannelStreams: @property def ordered(self) -> Tuple[Tuple[ChannelRegisters, ...], ...]: - """The four streams in the order :data:`CHANNEL_ORDER` states.""" + """The four streams in the order the generator names run.""" return (self.pulse1, self.pulse2, self.triangle, self.noise) @property diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index c82588f9c..8cebc8f90 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -1,12 +1,14 @@ -from dataclasses import dataclass +from __future__ import annotations + from typing import Optional +from pydantic import BaseModel, ConfigDict, model_validator + from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.registers.streams import ChannelStreams -@dataclass(frozen=True) -class Song: +class Song(BaseModel): """A reconstruction as the player holds it: the four streams, the clock, and where it repeats. Attributes: @@ -15,14 +17,19 @@ class Song: loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. """ + model_config = ConfigDict(extra="forbid", frozen=True) + streams: ChannelStreams schedule: PlaySchedule loop_tick: Optional[int] - def __post_init__(self) -> None: + @model_validator(mode="after") + def _validate_the_loop_lies_within_the_song(self) -> Song: if self.loop_tick is not None and not 0 <= self.loop_tick < self.ticks: raise ValueError(f"loop_tick must lie within the song's {self.ticks} ticks, got {self.loop_tick}") + return self + @property def ticks(self) -> int: """The ticks the song lasts.""" diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py index 9c8df5f45..f036fabe2 100644 --- a/src/sampletones_player/specification/channels.py +++ b/src/sampletones_player/specification/channels.py @@ -15,13 +15,6 @@ TRIANGLE_TIMER_LOW, ) -CHANNEL_ORDER: Final[Tuple[GeneratorName, ...]] = ( - GeneratorName.PULSE1, - GeneratorName.PULSE2, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, -) - CHANNEL_REGISTER_ADDRESSES: Final[Dict[GeneratorName, Tuple[int, ...]]] = { GeneratorName.PULSE1: (PULSE1_CONTROL, PULSE1_TIMER_LOW, PULSE1_TIMER_HIGH), GeneratorName.PULSE2: (PULSE2_CONTROL, PULSE2_TIMER_LOW, PULSE2_TIMER_HIGH), diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py index bb2c8ed07..524dd4644 100644 --- a/src/sampletones_player/specification/song.py +++ b/src/sampletones_player/specification/song.py @@ -1,6 +1,6 @@ from typing import Final -from sampletones_player.specification.channels import CHANNEL_ORDER +from sampletones_core.constants.enums import GeneratorName WORD_SIZE: Final[int] = 2 @@ -9,7 +9,7 @@ TOTAL_TICKS_OFFSET: Final[int] = STEP_FRACTION_OFFSET + WORD_SIZE LOOP_TICK_OFFSET: Final[int] = TOTAL_TICKS_OFFSET + WORD_SIZE STREAM_OFFSETS_OFFSET: Final[int] = LOOP_TICK_OFFSET + WORD_SIZE -SONG_HEADER_SIZE: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * len(CHANNEL_ORDER) +SONG_HEADER_SIZE: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * len(GeneratorName) NO_LOOP: Final[int] = 0xFFFF MAX_STREAM_OFFSET: Final[int] = 0xFFFF diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py index 922458c58..e81c399f4 100644 --- a/src/sampletones_player/trace/trace.py +++ b/src/sampletones_player/trace/trace.py @@ -3,8 +3,9 @@ from dataclasses import dataclass from typing import Dict, Final, List, Tuple +from sampletones_core.constants.enums import GeneratorName from sampletones_player.song import Song -from sampletones_player.specification.channels import CHANNEL_ORDER, CHANNEL_REGISTER_ADDRESSES +from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES from sampletones_player.specification.registers import ( APU_FRAME_COUNTER, APU_STATUS, @@ -58,7 +59,7 @@ def _tick_writes( shadows: Dict[int, int], ) -> Tuple[RegisterWrite, ...]: writes: List[RegisterWrite] = [] - for channel, registers in zip(CHANNEL_ORDER, song.streams.at(tick)): + for channel, registers in zip(GeneratorName.items(), song.streams.at(tick)): for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], registers.values): if address in REGISTERS_WRITTEN_ON_CHANGE: if shadows.get(address) == value: diff --git a/tests/unit/sampletones_player/clock/test_schedule.py b/tests/unit/sampletones_player/clock/test_schedule.py index ce81f0680..da134f9df 100644 --- a/tests/unit/sampletones_player/clock/test_schedule.py +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -5,6 +5,7 @@ from typing import Final, Tuple import pytest +from pydantic import ValidationError from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.specification.clock import ( @@ -197,11 +198,20 @@ def test_a_step_a_hair_under_a_whole_tick_carries_into_the_whole_byte(self) -> N step = PlaySchedule(ticks_per_play_call=rate).fixed_point_step assert (step.whole, step.fraction) == (2, 0) + def test_the_step_is_derived_once_and_held(self) -> None: + schedule = PlaySchedule.from_parameters(50) + assert schedule.fixed_point_step is schedule.fixed_point_step + class TestPlayScheduleBounds(BaseTestSuite): def test_the_stream_starts_on_its_first_tick(self) -> None: assert PlaySchedule.from_parameters(60).ticks_at(0) == 0 + def test_the_schedule_stays_as_built(self) -> None: + schedule = PlaySchedule.from_parameters(60) + with pytest.raises(ValidationError): + schedule.ticks_per_play_call = Fraction(1) + @pytest.mark.parametrize("nes_frequency", (MIN_NES_FREQUENCY, MAX_NES_FREQUENCY)) def test_the_engine_range_fits_the_step(self, nes_frequency: int) -> None: step = PlaySchedule.from_parameters(nes_frequency).fixed_point_step @@ -214,11 +224,11 @@ def test_a_tick_rate_below_one_is_rejected(self, nes_frequency: int) -> None: @pytest.mark.parametrize("ticks_per_play_call", (Fraction(0), Fraction(-1, 2))) def test_a_stream_that_never_advances_is_rejected(self, ticks_per_play_call: Fraction) -> None: - with pytest.raises(ValueError, match="ticks_per_play_call must be above 0"): + with pytest.raises(ValidationError, match="ticks_per_play_call"): PlaySchedule(ticks_per_play_call=ticks_per_play_call) def test_a_step_past_the_whole_byte_is_rejected(self) -> None: - with pytest.raises(ValueError, match="ticks_per_play_call must be at most"): + with pytest.raises(ValidationError, match="ticks_per_play_call"): PlaySchedule(ticks_per_play_call=Fraction(MAX_STEP_WHOLE + 1)) def test_a_negative_call_count_is_rejected(self) -> None: diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index c2ec7bbe1..80da90062 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -7,7 +7,6 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song -from sampletones_player.specification.channels import CHANNEL_ORDER from sampletones_player.specification.song import ( LOOP_TICK_OFFSET, MAX_STREAM_OFFSET, @@ -35,7 +34,7 @@ NTSC_FREQUENCY: Final[int] = 60 HALF_RATE_FREQUENCY: Final[int] = 30 PROGRAM_AREA_BYTES: Final[int] = 0x8000 -UNBOUNDED_SPACE: Final[int] = MAX_STREAM_OFFSET * len(CHANNEL_ORDER) +UNBOUNDED_SPACE: Final[int] = MAX_STREAM_OFFSET * len(GeneratorName) SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) @@ -47,7 +46,7 @@ def read_word(data: bytes, offset: int) -> int: def stream_offsets(data: bytes) -> Tuple[int, ...]: - return tuple(read_word(data, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(CHANNEL_ORDER))) + return tuple(read_word(data, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(GeneratorName))) def two_tick_song(nes_frequency: int) -> Song: diff --git a/tests/unit/sampletones_player/registers/test_streams.py b/tests/unit/sampletones_player/registers/test_streams.py index 70d2433ec..69a1cb7de 100644 --- a/tests/unit/sampletones_player/registers/test_streams.py +++ b/tests/unit/sampletones_player/registers/test_streams.py @@ -3,7 +3,6 @@ import pytest from pydantic import ValidationError -from sampletones_player.specification.channels import CHANNEL_ORDER from tests.suite.player import ( PLAYER_FULL_VOLUME, PLAYER_OCTAVE_UP_TIMER, diff --git a/tests/unit/sampletones_player/test_song.py b/tests/unit/sampletones_player/test_song.py index 407312f11..133dc586a 100644 --- a/tests/unit/sampletones_player/test_song.py +++ b/tests/unit/sampletones_player/test_song.py @@ -2,6 +2,7 @@ from typing import Final, Optional, Tuple import pytest +from pydantic import ValidationError from sampletones_player.song import Song from tests.suite.base import BaseTestSuite @@ -34,13 +35,18 @@ def test_a_song_may_stand_without_a_loop(self) -> None: assert song.loop_tick is None def test_a_loop_at_the_songs_length_raises(self) -> None: - with pytest.raises(ValueError): + with pytest.raises(ValidationError, match="loop_tick must lie within"): player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=2) def test_a_negative_loop_raises(self) -> None: - with pytest.raises(ValueError): + with pytest.raises(ValidationError, match="loop_tick must lie within"): player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=-1) + def test_the_song_stays_as_built(self) -> None: + song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None) + with pytest.raises(ValidationError): + song.loop_tick = 0 + class TestSongPlayback(BaseTestSuite): """The tick each call lands on, read across a song that ends and one that repeats.""" diff --git a/uv.lock b/uv.lock index 4699d98d5..d28681761 100644 --- a/uv.lock +++ b/uv.lock @@ -1788,7 +1788,7 @@ wheels = [ [[package]] name = "sampletones" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "anytree" }, From 6228ea4b29f2293b5e5a1514de2797c33385e287 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 12:34:21 +0200 Subject: [PATCH 008/142] Updated: glossary --- docs/glossary.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 8c53c90bd..8e9780790 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -11,10 +11,12 @@ The NES's sound chip (Ricoh 2A03). Its audio portion, the APU (Audio Processing Unit), generates all of the console's sound. _SampleToNES_ emulates its four melodic/percussive channels and no sampled-audio (DPCM) playback. -### Channel / oscillator +### Channel -One of the 2A03's sound-producing units. There are four: two pulse, one -triangle, one noise. The two words are used interchangeably here. +One of the 2A03's four sound-producing units: `pulse1`, `pulse2`, +`triangle`, and `noise`. The word *generator* names a distinct concept: +the oscillator kinds an instruction library covers (`pulse`, `triangle`, +`noise`) and the classes that implement them. ### Pulse (square) From ec9f42c829770e8551f4a5a979171cc078ba665f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 13:07:04 +0200 Subject: [PATCH 009/142] Added: a declared import graph for the packages and the player --- docs/development/architecture.md | 4 +- docs/development/bugs-and-todos.md | 1 + docs/development/packages.md | 104 +++++++ docs/index.md | 1 + scripts/checks/import_boundary.py | 284 ++++++++++++++---- .../scripts/checks/test_import_boundary.py | 213 +++++++++++-- 6 files changed, 518 insertions(+), 89 deletions(-) create mode 100644 docs/development/packages.md diff --git a/docs/development/architecture.md b/docs/development/architecture.md index fceecb7c5..6223feff7 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -2,7 +2,7 @@ This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs. -Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, and the YAML configuration package has `docs/development/config-organization.md`. +Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, the YAML configuration package has `docs/development/config-organization.md`, and the packages the repository divides into have `docs/development/packages.md`. --- @@ -178,7 +178,7 @@ What DearPyGui has already taken a copy of is registered rather than remembered Two mechanisms keep the codebase aligned with this document. -**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. +**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. The same script holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. **The identifier vocabularies are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target: diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index b5b2a61c7..c12cc4d7b 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -38,6 +38,7 @@ * Per-tab undo routing * In-application console * Improve performance of browser favorite scan of the entire tree per click +* `sampletones_synthesis` reaching back into `sampletones_core` for the pitch limits and `pitch_to_frequency` ## Bugs diff --git a/docs/development/packages.md b/docs/development/packages.md new file mode 100644 index 000000000..d105caeb5 --- /dev/null +++ b/docs/development/packages.md @@ -0,0 +1,104 @@ +# Package Layers + +_SampleToNES_ is one repository holding several packages under `src/`, ordered so that dependencies +run one way. This document states that order, what each package is for, and how the console player +is layered inside it. It is prescriptive: `scripts/checks/import_boundary.py` holds the source tree +to these tables on every commit, and a divergence between them and the script is itself a defect. + +The layering of `sampletones_application` has its own document, +[`architecture.md`](architecture.md), which the same check enforces. + +--- + +## The package graph + +```mermaid +graph TD + ENTRY["sampletones\n(entry point)"] + APP["sampletones_application\n(GUI)"] + PLAYER["sampletones_player\n(NES player)"] + CORE["sampletones_core\n(reconstruction engine)"] + SYNTH["sampletones_synthesis\n(waveform synthesis)"] + ASSETS["sampletones_assets\n(mark and fonts)"] + SHARED["sampletones_shared\n(facts and helpers)"] + CONFIG["sampletones_config\n(shipped YAML)"] + + ENTRY --> APP + ENTRY --> CORE + APP --> PLAYER + APP --> CORE + PLAYER --> CORE + CORE --> SYNTH + ASSETS --> SHARED + SYNTH --> SHARED + CORE --> SHARED + PLAYER --> SHARED + APP --> SHARED + ENTRY --> SHARED + SYNTH -.->|"pitch limits"| CORE +``` + +| Package | Purpose | May import | +|---------|---------|------------| +| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the AST layer the checks read source through | — | +| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, calibration — reached as package data rather than by import | — | +| `sampletones_assets` | The application mark and the bundled fonts, with the code that draws the mark | `sampletones_shared` | +| `sampletones_synthesis` | Analytic waveform synthesis: oscillators, envelopes, layers and voices | `sampletones_shared` | +| `sampletones_core` | The reconstruction engine, the project model, and the tracker export formats | `sampletones_shared`, `sampletones_synthesis` | +| `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file | `sampletones_shared`, `sampletones_core` | +| `sampletones_application` | The DearPyGui front end | `sampletones_shared`, `sampletones_core`, `sampletones_player` | +| `sampletones` | The command-line entry point and the startup self-check | `sampletones_shared`, `sampletones_core`, `sampletones_application` | + +Third-party imports are the package author's own choice and stand outside this table. + +**The reconstruction engine stands below the console player.** A reconstruction is produced, saved +and exported to a tracker with `sampletones_player` absent from the process, which is what lets the +player's format move while the engine holds still. The consequence is that an export backend +reaching the console — the seam `sampletones_core/trackers/backend.py` describes — is registered +from above rather than from the engine's own registry. + +### The pitch back-edge + +`sampletones_synthesis/frequency.py` reaches back up to `sampletones_core` for the pitch limits and +the pitch-to-frequency conversion, which is the one edge running against the order above. The check +narrows it to exactly the two modules it needs, `sampletones_core.constants.general` and +`sampletones_core.utils.frequencies`, so the rest of the engine stays out of reach from below. +Moving those facts into `sampletones_shared` closes the edge; it is listed in +[`bugs-and-todos.md`](bugs-and-todos.md) until then. + +--- + +## Inside `sampletones_player` + +The player divides into units layered the same way, and for the same reason: a register value, a +clock and a song exist independently of the file they are written into or the driver that reads +them. + +| Unit | Purpose | May import | +|------|---------|------------| +| `specification/` | The register addresses, control bits, offsets and address constants the format is written by, one module per subject | — | +| `clock/` | `PlaySchedule` and `FixedPointStep` — the engine ticks one play call advances a stream by | `specification/` | +| `registers/` | The per-tick register values each channel plays, and the four streams together | `specification/` | +| `song.py` | `Song` — the streams, the schedule and the loop point as one value | `clock/`, `registers/` | +| `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` | +| `nsf/` | The song block and the NSF file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | +| `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | +| `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` | + +### The build toolchain is a developer tool + +`driver/assembler/` runs `ca65` and `ld65` over `driver/assembly/` to produce the committed +`driver/binary/driver.bin`. It is reached from `scripts/player.py` and from the tests, and the wheel +carries the binary alone — so a module of the shipped tree that imported it would break an installed +copy, and no unit above declares it. The developer toolchain it needs is described in +[`dependencies.md`](dependencies.md). + +--- + +## Enforcement + +`scripts/checks/import_boundary.py` declares both graphs as layer tables — each unit and the units +it may import — and derives the rule it runs from them: every unit a table leaves out is out of +reach, so an edge is declared before it is taken. The hook audits the whole source tree on every +commit (`make check-import-boundary`), which means adding an edge to a table is how a new dependency +is opened, and removing one enumerates the work of closing it. diff --git a/docs/index.md b/docs/index.md index 1b02957d0..38d5204c0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ worked examples. The [**development**](development/) section is for contributors. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. +- [Package layers](development/packages.md) — the packages the repository divides into, and the order they import each other in. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 14c61f15a..1540e270f 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -1,17 +1,23 @@ #!/usr/bin/env python3 """ -Enforces layer-boundary import rules across the sampletones_application package. +Enforces the import boundaries the source tree is layered by. -Each import rule names a file glob, the import prefixes forbidden there, and the -contract modules exempt from those prefixes: a layer may consume another -layer's data contract (e.g. a service's result types) while its implementation -modules stay out of reach. The script checks every Python source file matched -by the glob and reports any import that begins with a forbidden prefix. +A layer graph names a tree of modules, the units it divides into, and the units each one may +import; every other unit is out of reach, so an edge across the graph is declared before it is +taken. The packages under `src/` are one such graph — `sampletones_core` sits below +`sampletones_player`, which is what keeps the reconstruction engine clear of the console player — +and the player's own subpackages are another, where `driver/assembler/` reaches the cc65 toolchain +and stays outside the wheel, so no shipped module imports it. -Token rules additionally forbid a regex within a file glob, enforcing contracts -a prefix cannot express — e.g. that panels never compose a column suffix -(`SUF_PANEL_*`) or parent into another panel's container. +Boundary rules state a contract the other way round, by the import prefixes a directory stays clear +of, and carry the contract modules exempt from those prefixes: a layer may consume another layer's +data contract (e.g. a service's result types) while its implementation modules stay out of reach. +`sampletones_application` is layered that way. + +Token rules additionally forbid a regex within a file glob, enforcing contracts a prefix cannot +express — e.g. that panels never compose a column suffix (`SUF_PANEL_*`) or parent into another +panel's container. Usage: python scripts/checks/import_boundary.py [files...] # check specific files @@ -22,35 +28,103 @@ import re import sys from pathlib import Path -from typing import Final, List, NamedTuple, Optional, Sequence, Set - -from sampletones_shared.meta.source.modules import source_paths -from sampletones_shared.meta.source.packages import package_directory +from typing import Dict, Final, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple -APP_ROOT: Final[Path] = package_directory("sampletones_application") +from sampletones_shared.meta.source.modules import MODULE_SEPARATOR, source_paths +from sampletones_shared.paths.source import SOURCE_ROOT IMPORT_RE = re.compile(r"^\s*(import|from)\s+([\w.]+)") -VISUAL = [ +MODULE_SUFFIX: Final[str] = ".py" +PATH_SEPARATOR: Final[str] = "/" +UNIT_GLOB: Final[str] = "**/*.py" + +APPLICATION: Final[str] = "sampletones_application" +PLAYER: Final[str] = "sampletones_player" + +VISUAL: Final[Tuple[str, ...]] = ( "dearpygui", "sampletones_application.ui", "sampletones_application.utils.gui", -] +) -SERVICE_CONTRACTS = [ +SERVICE_CONTRACTS: Final[Tuple[str, ...]] = ( "sampletones_application.services.result", "sampletones_application.services.render.result", "sampletones_application.services.song_player.result", -] +) + +PACKAGE_LAYERS: Final[Dict[str, Tuple[str, ...]]] = { + "sampletones_shared": (), + "sampletones_config": (), + "sampletones_assets": ("sampletones_shared",), + "sampletones_synthesis": ("sampletones_shared",), + "sampletones_core": ("sampletones_shared", "sampletones_synthesis"), + "sampletones_player": ("sampletones_shared", "sampletones_core"), + "sampletones_application": ("sampletones_shared", "sampletones_core", "sampletones_player"), + "sampletones": ("sampletones_shared", "sampletones_core", "sampletones_application"), +} + +SYNTHESIS_PITCH_CONTRACT: Final[Tuple[str, ...]] = ( + "sampletones_core.constants.general", + "sampletones_core.utils.frequencies", +) + +PACKAGE_CONTRACTS: Final[Dict[str, Tuple[str, ...]]] = { + "sampletones_synthesis": SYNTHESIS_PITCH_CONTRACT, +} + +PLAYER_LAYERS: Final[Dict[str, Tuple[str, ...]]] = { + "__init__.py": (), + "specification": (), + "clock": ("specification",), + "registers": ("specification",), + "song.py": ("clock", "registers"), + "trace": ("song.py", "specification"), + "nsf": ("song.py", "registers", "specification", "driver"), + "driver": ("specification",), + "driver/assembler": ("driver", "specification"), +} + + +class LayerGraph(NamedTuple): + """A tree of modules, the units it divides into, and what each unit may import. + + Attributes: + root: Directory under the source root the units are named within. + package: Import prefix the units sit under, empty where the units are packages themselves. + layers: Each unit and the units it may import. + contracts: Import prefixes a unit reaches past its layers. + """ + + root: str + package: str + layers: Dict[str, Tuple[str, ...]] + contracts: Dict[str, Tuple[str, ...]] class BoundaryRule(NamedTuple): + """One tree of modules and the imports it stays clear of. + + Attributes: + root: Directory under the source root the pattern is written against. + pattern: Glob naming the modules the rule reaches. + forbidden: Import prefixes out of reach in them. + contracts: Import prefixes exempt from the forbidden ones. + excluding: Globs naming the modules a rule of their own owns instead. + """ + + root: str pattern: str - forbidden: List[str] - contracts: List[str] = [] + forbidden: Tuple[str, ...] + contracts: Tuple[str, ...] = () + excluding: Tuple[str, ...] = () class TokenRule(NamedTuple): + """One tree of modules and a spelling that stays out of them.""" + + root: str pattern: str forbidden: str message: str @@ -63,61 +137,132 @@ class Violation(NamedTuple): location: str -RULES: List[BoundaryRule] = [ +def unit_prefix(package: str, unit: str) -> str: + """The import prefix a unit is reached by. + + Args: + package: Import prefix the unit sits under, empty where the unit is a package itself. + unit: Unit named as a path under the graph's root. + + Returns: + str: The dotted prefix an import of that unit begins with. + """ + name = unit.removesuffix(MODULE_SUFFIX).replace(PATH_SEPARATOR, MODULE_SEPARATOR) + return f"{package}{MODULE_SEPARATOR}{name}" if package else name + + +def unit_glob(unit: str) -> str: + """The glob naming the modules a unit holds, whether the unit is a module or a directory.""" + return unit if unit.endswith(MODULE_SUFFIX) else f"{unit}{PATH_SEPARATOR}{UNIT_GLOB}" + + +def nested_globs(unit: str, units: Iterable[str]) -> Tuple[str, ...]: + """The globs of the units declared inside another one, which own their modules instead.""" + return tuple(unit_glob(other) for other in units if other.startswith(f"{unit}{PATH_SEPARATOR}")) + + +def layer_rules(graph: LayerGraph) -> List[BoundaryRule]: + """One rule per unit of a layer graph, forbidding every unit its layers leave out. + + Declaring what a unit may import states the graph once, and the rule the check runs is what + remains — so an edge the graph leaves out is reported wherever it is taken. + + Args: + graph: The tree, its units, and the units each one may import. + + Returns: + List[BoundaryRule]: The rules the graph amounts to, in declaration order. + """ + return [ + BoundaryRule( + root=graph.root, + pattern=unit_glob(unit), + forbidden=tuple( + unit_prefix(graph.package, other) for other in graph.layers if other != unit and other not in allowed + ), + contracts=graph.contracts.get(unit, ()), + excluding=nested_globs(unit, graph.layers), + ) + for unit, allowed in graph.layers.items() + ] + + +PACKAGES: Final[LayerGraph] = LayerGraph( + root="", + package="", + layers=PACKAGE_LAYERS, + contracts=PACKAGE_CONTRACTS, +) + +PLAYER_GRAPH: Final[LayerGraph] = LayerGraph( + root=PLAYER, + package=PLAYER, + layers=PLAYER_LAYERS, + contracts={}, +) + +APPLICATION_RULES: Final[Tuple[BoundaryRule, ...]] = ( BoundaryRule( + APPLICATION, "config/**/*.py", - [ + ( *VISUAL, "sampletones_application.coordinators", "sampletones_application.application", - ], + ), ), BoundaryRule( + APPLICATION, "logic/**/*.py", - [ + ( *VISUAL, "sampletones_application.coordinators", "sampletones_application.services", - ], + ), contracts=SERVICE_CONTRACTS, ), BoundaryRule( + APPLICATION, "view_model/**/*.py", - [ + ( *VISUAL, "sampletones_application.coordinators", "sampletones_application.config", "sampletones_application.logic", "sampletones_application.services", - ], + ), ), BoundaryRule( + APPLICATION, "services/**/*.py", - [ + ( *VISUAL, "sampletones_application.view_model", "sampletones_application.coordinators", "sampletones_application.config", "sampletones_application.logic", - ], + ), ), BoundaryRule( + APPLICATION, "shell.py", - [ + ( "sampletones_application.logic", "sampletones_application.services", - ], + ), ), BoundaryRule( + APPLICATION, "coordinators/**/*.py", - [ + ( "sampletones_application.application", "sampletones_application.shell", - ], + ), ), BoundaryRule( + APPLICATION, "ui/**/*.py", - [ + ( "sampletones_application.coordinators", "sampletones_application.logic", "sampletones_application.services", @@ -125,36 +270,46 @@ class Violation(NamedTuple): "sampletones_application.application", "sampletones_application.shell", "sampletones_application.utils.gui.dialogs", - ], + ), ), -] +) + + +RULES: Final[Tuple[BoundaryRule, ...]] = ( + *layer_rules(PACKAGES), + *layer_rules(PLAYER_GRAPH), + *APPLICATION_RULES, +) -TOKEN_RULES: List[TokenRule] = [ +TOKEN_RULES: Final[Tuple[TokenRule, ...]] = ( TokenRule( + APPLICATION, "ui/panels/**/*.py", r"\bSUF_PANEL_", "ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its " "parent through create_panel(parent), set by the coordinator that owns the layout", ), TokenRule( + APPLICATION, "ui/panels/**/*.py", r"parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b", "ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); " "the coordinator injects the parent through create_panel(parent)", ), TokenRule( + APPLICATION, "ui/panels/**/*.py", r"\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b", "ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/" "GROUND); only the layout primitives own depth (TabColumns binds the column, card() " "binds the card), and a panel binds only semantic themes", ), -] +) def _matches_prefix(module: str, prefix: str) -> bool: - return module == prefix or module.startswith(prefix + ".") + return module == prefix or module.startswith(prefix + MODULE_SEPARATOR) def find_token_violations( @@ -211,60 +366,65 @@ def find_violations( def rule_modules( - package: Path, + root: Path, pattern: str, + excluding: Tuple[str, ...], swept: Set[Path], selection: Optional[Set[Path]], ) -> List[Path]: """The modules a rule reaches, in path order. - A rule names its files by one glob whether the check runs over the whole package or over the - files a hook lists, so the two entry points read the same rule the same way. + A rule names its files by one glob whether the check runs over the whole tree or over the files + a hook lists, so the two entry points read the same rule the same way. A module a nested rule + owns belongs to that rule alone, which is how a subpackage states a boundary of its own inside + the one around it. Args: - package: Package the rule globs are written against. + root: Directory the rule globs are written against. pattern: Glob the rule names its files by. - swept: Visible modules the package holds, which the glob is held to. + excluding: Globs naming the modules a rule of their own owns instead. + swept: Visible modules the tree holds, which the glob is held to. selection: Resolved paths to narrow the rule to, or `None` to reach every module it names. Returns: List[Path]: The modules the rule applies to. """ - matched = {path.resolve() for path in package.glob(pattern)} & swept + owned = {path.resolve() for nested in excluding for path in root.glob(nested)} + matched = ({path.resolve() for path in root.glob(pattern)} & swept) - owned if selection is not None: matched &= selection return sorted(matched) -def check_boundaries(package: Path, selection: Optional[Set[Path]]) -> List[Violation]: - """Every import and token the rules forbid in the package. +def check_boundaries(source: Path, selection: Optional[Set[Path]]) -> List[Violation]: + """Every import and token the rules forbid under a source root. - The package is swept first, so the rules run over the modules it holds and a root reading as - empty stops the check where it would otherwise report a clean tree. + The tree is swept first, so the rules run over the modules it holds and a root reading as empty + stops the check where it would otherwise report a clean tree. Args: - package: Package the rule globs are written against. - selection: Resolved paths to narrow the check to, or `None` to check the whole package. + source: Source root the rule roots are named within. + selection: Resolved paths to narrow the check to, or `None` to check the whole tree. Returns: List[Violation]: What the rules report, boundary rules first. Raises: - NotADirectoryError: If the package names no directory. - FileNotFoundError: If the package holds no module to read. + NotADirectoryError: If the source root names no directory. + FileNotFoundError: If the source root holds no module to read. """ - swept = {path.resolve() for path in source_paths([package])} + swept = {path.resolve() for path in source_paths([source])} violations = [ violation for rule in RULES - for filepath in rule_modules(package, rule.pattern, swept, selection) + for filepath in rule_modules(source / rule.root, rule.pattern, rule.excluding, swept, selection) for violation in find_violations(filepath, rule) ] violations.extend( violation for token_rule in TOKEN_RULES - for filepath in rule_modules(package, token_rule.pattern, swept, selection) + for filepath in rule_modules(source / token_rule.root, token_rule.pattern, (), swept, selection) for violation in find_token_violations(filepath, token_rule) ) return violations @@ -273,7 +433,7 @@ def check_boundaries(package: Path, selection: Optional[Set[Path]]) -> List[Viol def main(argv: Sequence[str]) -> int: """Report every import and token the layer boundaries forbid.""" parser = argparse.ArgumentParser( - description="Check layer-boundary import rules across the application package.", + description="Check the import boundaries the source tree is layered by.", ) parser.add_argument( "files", @@ -284,19 +444,19 @@ def main(argv: Sequence[str]) -> int: parser.add_argument( "--all", action="store_true", - help=f"check every module under {APP_ROOT.name}/ instead of named files", + help=f"check every module under {SOURCE_ROOT.name}/ instead of named files", ) parser.add_argument( - "--package", + "--source", type=Path, - default=APP_ROOT, - help="package the rule globs are written against", + default=SOURCE_ROOT, + help="source root the rule roots are named within", ) arguments = parser.parse_args(list(argv)) files: List[Path] = arguments.files selection = None if arguments.all else {path.resolve() for path in files} - violations = check_boundaries(arguments.package, selection) + violations = check_boundaries(arguments.source, selection) if not violations: return 0 diff --git a/tests/unit/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py index e2aa92fc7..25e53947c 100644 --- a/tests/unit/scripts/checks/test_import_boundary.py +++ b/tests/unit/scripts/checks/test_import_boundary.py @@ -1,18 +1,24 @@ +from collections import Counter from pathlib import Path -from typing import Final, List +from typing import Dict, Final, List, Set, Tuple import pytest from sampletones_shared.meta.source.modules import source_paths +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.scripts import load_script check_import_boundary = load_script("checks/import_boundary.py") +APPLICATION: Final[str] = "sampletones_application" +PLAYER: Final[str] = "sampletones_player" LOGIC_RULE: Final[str] = "logic/**/*.py" FORBIDDEN_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n" CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n" PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n" +PLAYER_IMPORT: Final[str] = "from sampletones_player.song import Song\n" +ASSEMBLER_IMPORT: Final[str] = "from sampletones_player.driver.assembler.builder import build_driver\n" PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n" @@ -27,26 +33,163 @@ def swept(package: Path) -> List[Path]: return [path.resolve() for path in source_paths([package])] +def reached_modules(rule: check_import_boundary.BoundaryRule) -> List[Path]: + """The modules a rule of the real source tree applies to.""" + root = SOURCE_ROOT / rule.root + return check_import_boundary.rule_modules( + root, + rule.pattern, + rule.excluding, + {path.resolve() for path in source_paths([root])}, + None, + ) + + +def reaches(layers: Dict[str, Tuple[str, ...]], unit: str, seen: Set[str]) -> Set[str]: + """Every unit a unit imports, directly or through the units it imports.""" + for allowed in layers[unit]: + if allowed not in seen: + seen.add(allowed) + reaches(layers, allowed, seen) + + return seen + + +class TestUnitGlobs: + """A unit names either one module or a directory of them, and reads as both.""" + + def test_a_directory_unit_reaches_every_module_below_it(self) -> None: + assert check_import_boundary.unit_glob("driver") == "driver/**/*.py" + + def test_a_module_unit_names_itself(self) -> None: + assert check_import_boundary.unit_glob("song.py") == "song.py" + + def test_a_unit_under_a_package_is_reached_by_a_dotted_prefix(self) -> None: + assert check_import_boundary.unit_prefix(PLAYER, "driver/assembler") == "sampletones_player.driver.assembler" + + def test_a_module_unit_drops_its_suffix(self) -> None: + assert check_import_boundary.unit_prefix(PLAYER, "song.py") == "sampletones_player.song" + + def test_a_package_unit_is_reached_by_its_own_name(self) -> None: + assert check_import_boundary.unit_prefix("", "sampletones_core") == "sampletones_core" + + def test_a_nested_unit_is_named_as_the_glob_it_owns(self) -> None: + nested = check_import_boundary.nested_globs("driver", ("driver", "driver/assembler", "clock")) + assert nested == ("driver/assembler/**/*.py",) + + +class TestLayerRules: + """A graph states what a unit may import, and the rule the check runs is what remains.""" + + GRAPH: Final = check_import_boundary.LayerGraph( + root="package", + package="package", + layers={"low": (), "high": ("low",)}, + contracts={"low": ("package.high.contract",)}, + ) + + def test_a_unit_forbids_the_units_its_layers_leave_out(self) -> None: + low, _ = check_import_boundary.layer_rules(self.GRAPH) + assert low.forbidden == ("package.high",) + + def test_a_unit_stays_free_of_the_units_it_may_import(self) -> None: + _, high = check_import_boundary.layer_rules(self.GRAPH) + assert high.forbidden == () + + def test_a_unit_carries_the_contracts_declared_for_it(self) -> None: + low, _ = check_import_boundary.layer_rules(self.GRAPH) + assert low.contracts == ("package.high.contract",) + + def test_every_rule_is_written_against_the_graphs_root(self) -> None: + assert all(rule.root == "package" for rule in check_import_boundary.layer_rules(self.GRAPH)) + + +class TestPackageGraph: + """The packages under the source root, and the order they may reach each other in.""" + + LAYERS: Final[Dict[str, Tuple[str, ...]]] = check_import_boundary.PACKAGE_LAYERS + + def test_every_package_of_the_source_tree_is_declared(self) -> None: + directories = {path.name for path in SOURCE_ROOT.iterdir() if (path / "__init__.py").is_file()} + + assert set(self.LAYERS) == directories + + def test_every_layer_a_package_may_import_is_a_declared_package(self) -> None: + assert all(allowed in self.LAYERS for layers in self.LAYERS.values() for allowed in layers) + + def test_the_package_graph_is_acyclic(self) -> None: + assert all(package not in reaches(self.LAYERS, package, set()) for package in self.LAYERS) + + def test_the_reconstruction_engine_stays_clear_of_the_console_player(self) -> None: + assert PLAYER not in reaches(self.LAYERS, "sampletones_core", set()) + + def test_the_console_player_reads_the_reconstruction_engine(self) -> None: + assert "sampletones_core" in self.LAYERS[PLAYER] + + +class TestPlayerGraph: + """The player's own subpackages, and the order they may reach each other in.""" + + LAYERS: Final[Dict[str, Tuple[str, ...]]] = check_import_boundary.PLAYER_LAYERS + + def test_every_layer_a_unit_may_import_is_a_declared_unit(self) -> None: + assert all(allowed in self.LAYERS for layers in self.LAYERS.values() for allowed in layers) + + def test_the_player_graph_is_acyclic(self) -> None: + assert all(unit not in reaches(self.LAYERS, unit, set()) for unit in self.LAYERS) + + def test_the_specification_is_the_layer_everything_stands_on(self) -> None: + assert self.LAYERS["specification"] == () + + def test_the_build_toolchain_is_reached_from_no_shipped_module(self) -> None: + """`driver/assembler/` stays outside the wheel, so an import of it breaks an installed copy.""" + assert all("driver/assembler" not in layers for layers in self.LAYERS.values()) + + def test_the_driver_is_reached_through_the_file_that_writes_the_nsf(self) -> None: + assert "driver" in self.LAYERS["nsf"] + + def test_every_module_of_the_player_belongs_to_one_unit(self) -> None: + rules = check_import_boundary.layer_rules(check_import_boundary.PLAYER_GRAPH) + owners = Counter(path for rule in rules for path in reached_modules(rule)) + + assert set(owners) == set(swept(SOURCE_ROOT / PLAYER)) + assert set(owners.values()) == {1} + + class TestRuleModules: def test_a_module_directly_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None: """`logic/**/*.py` names `logic/direct.py` as surely as `logic/inner/deep.py`.""" direct = write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT) - reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None) + reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, (), set(swept(tmp_path)), None) assert reached == [direct.resolve()] def test_a_module_nested_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None: deep = write_module(tmp_path / "logic" / "inner", "deep.py", PLAIN_IMPORT) - reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None) + reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, (), set(swept(tmp_path)), None) assert reached == [deep.resolve()] def test_a_module_outside_the_rule_directory_stays_aside(self, tmp_path: Path) -> None: write_module(tmp_path / "services", "conversion.py", PLAIN_IMPORT) - assert check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None) == [] + assert check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, (), set(swept(tmp_path)), None) == [] + + def test_a_module_a_nested_rule_owns_is_left_to_it(self, tmp_path: Path) -> None: + direct = write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT) + write_module(tmp_path / "logic" / "inner", "deep.py", PLAIN_IMPORT) + + reached = check_import_boundary.rule_modules( + tmp_path, + LOGIC_RULE, + ("logic/inner/**/*.py",), + set(swept(tmp_path)), + None, + ) + + assert reached == [direct.resolve()] def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path) -> None: named = write_module(tmp_path / "logic", "named.py", PLAIN_IMPORT) @@ -55,6 +198,7 @@ def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path reached = check_import_boundary.rule_modules( tmp_path, LOGIC_RULE, + (), set(swept(tmp_path)), {named.resolve()}, ) @@ -64,14 +208,14 @@ def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path class TestCheckBoundaries: def test_a_forbidden_import_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT) + write_module(tmp_path / APPLICATION / "logic", "direct.py", FORBIDDEN_IMPORT) violations = check_import_boundary.check_boundaries(tmp_path, None) assert [violation.kind for violation in violations] == ["dearpygui"] def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None: - path = write_module(tmp_path / "logic", "direct.py", f"{PLAIN_IMPORT}{FORBIDDEN_IMPORT}") + path = write_module(tmp_path / APPLICATION / "logic", "direct.py", f"{PLAIN_IMPORT}{FORBIDDEN_IMPORT}") violations = check_import_boundary.check_boundaries(tmp_path, None) @@ -79,25 +223,45 @@ def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> N def test_a_contract_module_stays_reachable(self, tmp_path: Path) -> None: """A layer reads another layer's data contract while its implementation stays out of reach.""" - write_module(tmp_path / "logic", "direct.py", CONTRACT_IMPORT) + write_module(tmp_path / APPLICATION / "logic", "direct.py", CONTRACT_IMPORT) assert check_import_boundary.check_boundaries(tmp_path, None) == [] def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None: - write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT) + write_module(tmp_path / APPLICATION / "logic", "direct.py", PLAIN_IMPORT) + + assert check_import_boundary.check_boundaries(tmp_path, None) == [] + + def test_a_package_reaching_across_the_graph_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / "sampletones_core" / "formats", "player.py", PLAYER_IMPORT) + + violations = check_import_boundary.check_boundaries(tmp_path, None) + + assert [violation.kind for violation in violations] == [PLAYER] + + def test_a_shipped_module_reaching_the_build_toolchain_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / PLAYER / "nsf", "file.py", ASSEMBLER_IMPORT) + + violations = check_import_boundary.check_boundaries(tmp_path, None) + + assert [violation.kind for violation in violations] == ["sampletones_player.driver.assembler"] + + def test_the_build_toolchain_reads_the_driver_it_assembles(self, tmp_path: Path) -> None: + body = "from sampletones_player.driver.image import DriverImage\n" + write_module(tmp_path / PLAYER / "driver" / "assembler", "builder.py", body) assert check_import_boundary.check_boundaries(tmp_path, None) == [] def test_a_forbidden_token_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / "ui" / "panels", "left.py", PANEL_SUFFIX) + write_module(tmp_path / APPLICATION / "ui" / "panels", "left.py", PANEL_SUFFIX) violations = check_import_boundary.check_boundaries(tmp_path, None) assert len(violations) == 1 def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None: - checked = write_module(tmp_path / "logic", "checked.py", FORBIDDEN_IMPORT) - write_module(tmp_path / "logic", "other.py", FORBIDDEN_IMPORT) + checked = write_module(tmp_path / APPLICATION / "logic", "checked.py", FORBIDDEN_IMPORT) + write_module(tmp_path / APPLICATION / "logic", "other.py", FORBIDDEN_IMPORT) violations = check_import_boundary.check_boundaries(tmp_path, {checked.resolve()}) @@ -107,28 +271,27 @@ def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None: class TestSweptRoots: """A root the sweep reads nothing under reports nothing, which reads as a clean tree.""" - def test_the_application_package_holds_modules(self) -> None: - assert source_paths([check_import_boundary.APP_ROOT]) + def test_the_source_root_holds_modules(self) -> None: + assert source_paths([SOURCE_ROOT]) def test_every_boundary_rule_reaches_a_module(self) -> None: - package = check_import_boundary.APP_ROOT - assert all(list(package.glob(rule.pattern)) for rule in check_import_boundary.RULES) + assert all(reached_modules(rule) for rule in check_import_boundary.RULES) def test_every_token_rule_reaches_a_module(self) -> None: - package = check_import_boundary.APP_ROOT - assert all(list(package.glob(rule.pattern)) for rule in check_import_boundary.TOKEN_RULES) + rules = check_import_boundary.TOKEN_RULES + assert all(list((SOURCE_ROOT / rule.root).glob(rule.pattern)) for rule in rules) - def test_a_package_holding_no_module_stops_the_check(self, tmp_path: Path) -> None: + def test_a_root_holding_no_module_stops_the_check(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): check_import_boundary.check_boundaries(tmp_path, None) - def test_an_absent_package_stops_the_check(self, tmp_path: Path) -> None: + def test_an_absent_root_stops_the_check(self, tmp_path: Path) -> None: with pytest.raises(NotADirectoryError): check_import_boundary.check_boundaries(tmp_path / "absent", None) class TestMain: - def test_the_repository_holds_its_layer_boundaries(self) -> None: + def test_the_repository_holds_its_import_boundaries(self) -> None: assert check_import_boundary.main(["--all"]) == 0 def test_a_forbidden_import_is_reported_where_it_sits( @@ -136,9 +299,9 @@ def test_a_forbidden_import_is_reported_where_it_sits( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - path = write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT) + path = write_module(tmp_path / APPLICATION / "logic", "direct.py", FORBIDDEN_IMPORT) - exit_code = check_import_boundary.main(["--all", "--package", str(tmp_path)]) + exit_code = check_import_boundary.main(["--all", "--source", str(tmp_path)]) assert exit_code == 1 error = capsys.readouterr().err @@ -150,8 +313,8 @@ def test_named_files_narrow_the_run_to_themselves( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - write_module(tmp_path / "logic", "reported.py", FORBIDDEN_IMPORT) - clean = write_module(tmp_path / "logic", "clean.py", PLAIN_IMPORT) + write_module(tmp_path / APPLICATION / "logic", "reported.py", FORBIDDEN_IMPORT) + clean = write_module(tmp_path / APPLICATION / "logic", "clean.py", PLAIN_IMPORT) - assert check_import_boundary.main([str(clean), "--package", str(tmp_path)]) == 0 + assert check_import_boundary.main([str(clean), "--source", str(tmp_path)]) == 0 assert capsys.readouterr().err == "" From 2b50d2c2bece99b29977fb8c79fc849062b4e958 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 13:27:36 +0200 Subject: [PATCH 010/142] Added: version-upgrade engine for stored data formats --- docs/development/compatibility.md | 107 ++++++++ .../compatibility/library/__init__.py | 5 + .../compatibility/project/__init__.py | 5 + .../compatibility/reconstruction/__init__.py | 5 + src/sampletones_core/compatibility/update.py | 8 +- src/sampletones_core/compatibility/upgrade.py | 249 ++++++++++++++++++ src/sampletones_core/library/data.py | 3 + src/sampletones_core/project/container.py | 9 +- .../reconstruction/reconstruction.py | 3 + .../compatibility/__init__.py | 0 .../compatibility/test_binary.py | 41 +++ .../compatibility/test_json.py | 27 ++ .../compatibility/test_upgrade.py | 93 +++++++ 13 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 docs/development/compatibility.md create mode 100644 src/sampletones_core/compatibility/library/__init__.py create mode 100644 src/sampletones_core/compatibility/project/__init__.py create mode 100644 src/sampletones_core/compatibility/reconstruction/__init__.py create mode 100644 src/sampletones_core/compatibility/upgrade.py create mode 100644 tests/unit/sampletones_core/compatibility/__init__.py create mode 100644 tests/unit/sampletones_core/compatibility/test_binary.py create mode 100644 tests/unit/sampletones_core/compatibility/test_json.py create mode 100644 tests/unit/sampletones_core/compatibility/test_upgrade.py diff --git a/docs/development/compatibility.md b/docs/development/compatibility.md new file mode 100644 index 000000000..e6e6fdb49 --- /dev/null +++ b/docs/development/compatibility.md @@ -0,0 +1,107 @@ +# Data Compatibility + +This document governs the version upgrades applied to the stored data formats of +_SampleToNES_: reconstruction files (`.stn`), instruction libraries (`.ins`), and +project documents (`project.json`). Consult it when changing a serialized shape, +adding a format version, or diagnosing a file that loads as incompatible. + +The upgrades live in `sampletones_core/compatibility` and run at the load +boundary of each format, before deserialization. The formats' own documents +describe their stored shape and versioning: + +- [`formats/reconstructions.md`](../formats/reconstructions.md) +- [`formats/instruction-libraries.md`](../formats/instruction-libraries.md) +- [`formats/projects.md`](../formats/projects.md) + +## Principles + +### A format reads and writes one data version + +Each format states the single data version this build produces, held in +`SAMPLETONES_LIBRARY_DATA_VERSION`, `SAMPLETONES_RECONSTRUCTION_DATA_VERSION`, +and `SAMPLETONES_PROJECT_DATA_VERSION` (`sampletones_shared/application.py`). +The version travels inside every stored file, and the format's load contract +holds each file to it: `MetadataContract` for the binary formats, the +`format_version` check for projects. + +### An upgrade is one version step + +A stored shape changes in small, named steps. Each step is a `VersionUpdate`: +the version the payload reads at, the version it writes after the transform, and +the transform itself. The steps of one format form a chain, registered in +`compatibility//__init__.py`, and each step lives in a module named +after the version it writes — `compatibility/reconstruction/v2_2.py` carries +the step that writes reconstruction data version 2.2. + +### A chain applies whole or not at all + +An upgrade runs only when the registered steps form a complete path from the +file's version to the version this build writes. A file whose version no chain +reaches comes back unchanged, and the format's load contract refuses it, exactly +as it refuses any version this build does not support. A partial path leaves a +file entirely untouched. + +### Upgrades run on the raw payload + +Upgrades apply to the serialized payload before any model sees it: the msgpack +mapping for `.stn` and `.ins`, the JSON document for `project.json`. The +transform steps reshape that payload — renaming the fields whose names changed +between versions, and adjusting the values they hold where the shape demands it. + +### A completed upgrade stamps the version it reached + +A payload whose chain ran carries the new version in the same field it declares +it with, so the file states the version its shape now matches and a later save +writes that version. The load path leaves the bytes of every other payload +untouched. + +## Mechanics + +### Package layout + +- `compatibility/kind.py` — `ObjectKind`, the format an upgrade belongs to + (`LIBRARY`, `RECONSTRUCTION`, `PROJECT`). +- `compatibility/update.py` — `VersionUpdate`, one named version step. +- `compatibility/upgrade.py` — the engine: `upgrade`, `upgrade_binary`, + `upgrade_json`, and the per-format registries `CURRENT_VERSIONS` and `UPDATES`. +- `compatibility//__init__.py` — that format's `UPDATES` tuple, empty + until the format's first shape change. + +### Version fields + +- `.stn` — `metadata.reconstruction_data_version` +- `.ins` — `metadata.library_data_version` +- `project.json` — `format_version` at the document root + +### Load boundaries + +`Reconstruction.deserialize_data` and `InstructionLibraryData.load` pass their +payload through `upgrade_binary`; `ProjectContainer.load` passes the document +through `upgrade_json`. Each wrapper parses the payload, reads the format's +version field, runs the chain, and re-encodes the upgraded payload. A payload +that stays as it is — no chain applies, no version field, or a payload that does +not parse to a mapping — returns as the same bytes, so the load path behaves for +it exactly as it did before the upgrades existed. A file whose version no chain reaches arrives at the format's load contract +unchanged, which refuses it with the format's `Incompatible*VersionError`, as it +always did. + +### Adding an upgrade + +1. Bump the format's version constant in `sampletones_shared/application.py`. +2. Add the step module named after the new version — e.g. + `compatibility/reconstruction/v2_2.py` — with a transform that takes the + payload at the previous version and returns it at the new one. +3. Append the step to the format's `UPDATES` tuple. +4. Cover the step with unit tests under + `tests/unit/sampletones_core/compatibility/`, and with a loader test that + opens a payload written at the previous version. + +The engine stamps the new version once the chain runs, so a step module declares +only its own transform. + +## Verification + +- `uv run pytest tests/unit/sampletones_core/compatibility` covers the engine and + every registered step. +- The format load tests open payloads at previous versions and hold the loaded + models against the current shape. diff --git a/src/sampletones_core/compatibility/library/__init__.py b/src/sampletones_core/compatibility/library/__init__.py new file mode 100644 index 000000000..81c9116f6 --- /dev/null +++ b/src/sampletones_core/compatibility/library/__init__.py @@ -0,0 +1,5 @@ +from typing import Final, Tuple + +from sampletones_core.compatibility.update import VersionUpdate + +UPDATES: Final[Tuple[VersionUpdate, ...]] = () diff --git a/src/sampletones_core/compatibility/project/__init__.py b/src/sampletones_core/compatibility/project/__init__.py new file mode 100644 index 000000000..81c9116f6 --- /dev/null +++ b/src/sampletones_core/compatibility/project/__init__.py @@ -0,0 +1,5 @@ +from typing import Final, Tuple + +from sampletones_core.compatibility.update import VersionUpdate + +UPDATES: Final[Tuple[VersionUpdate, ...]] = () diff --git a/src/sampletones_core/compatibility/reconstruction/__init__.py b/src/sampletones_core/compatibility/reconstruction/__init__.py new file mode 100644 index 000000000..81c9116f6 --- /dev/null +++ b/src/sampletones_core/compatibility/reconstruction/__init__.py @@ -0,0 +1,5 @@ +from typing import Final, Tuple + +from sampletones_core.compatibility.update import VersionUpdate + +UPDATES: Final[Tuple[VersionUpdate, ...]] = () diff --git a/src/sampletones_core/compatibility/update.py b/src/sampletones_core/compatibility/update.py index fdc41e654..8a63adeca 100644 --- a/src/sampletones_core/compatibility/update.py +++ b/src/sampletones_core/compatibility/update.py @@ -1,10 +1,16 @@ -from typing import NamedTuple +from typing import Callable, NamedTuple from sampletones_core.compatibility.kind import ObjectKind from sampletones_shared.deployment.version import Version +from sampletones_shared.types.data import SerializedData + +UpdateFunction = Callable[[SerializedData], SerializedData] class VersionUpdate(NamedTuple): + """One step of a format upgrade: the version it reads, the one it writes, and the transform.""" + kind: ObjectKind base: Version target: Version + apply: UpdateFunction diff --git a/src/sampletones_core/compatibility/upgrade.py b/src/sampletones_core/compatibility/upgrade.py new file mode 100644 index 000000000..e98940df6 --- /dev/null +++ b/src/sampletones_core/compatibility/upgrade.py @@ -0,0 +1,249 @@ +import json +from typing import Any, Dict, Final, List, NamedTuple, Optional, Tuple + +import msgpack + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.library import UPDATES as LIBRARY_UPDATES +from sampletones_core.compatibility.project import UPDATES as PROJECT_UPDATES +from sampletones_core.compatibility.reconstruction import UPDATES as RECONSTRUCTION_UPDATES +from sampletones_core.compatibility.update import VersionUpdate +from sampletones_shared.application import ( + SAMPLETONES_LIBRARY_DATA_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + SAMPLETONES_RECONSTRUCTION_DATA_VERSION, +) +from sampletones_shared.deployment.version import Version, compare_versions +from sampletones_shared.types.data import SerializedData + +CURRENT_VERSIONS: Final[Dict[ObjectKind, str]] = { + ObjectKind.LIBRARY: SAMPLETONES_LIBRARY_DATA_VERSION, + ObjectKind.RECONSTRUCTION: SAMPLETONES_RECONSTRUCTION_DATA_VERSION, + ObjectKind.PROJECT: SAMPLETONES_PROJECT_DATA_VERSION, +} + +UPDATES: Final[Dict[ObjectKind, Tuple[VersionUpdate, ...]]] = { + ObjectKind.LIBRARY: LIBRARY_UPDATES, + ObjectKind.RECONSTRUCTION: RECONSTRUCTION_UPDATES, + ObjectKind.PROJECT: PROJECT_UPDATES, +} + + +class _VersionPath(NamedTuple): + """Where a format stores its version: the section enclosing the field, and the field itself.""" + + section: Optional[str] + field: str + + +def upgrade( + kind: ObjectKind, + version: str, + data: SerializedData, + updates: Tuple[VersionUpdate, ...], + current_version: str, +) -> SerializedData: + """Brings one format's serialized data to the version this build reads and writes. + + A format stores the version its file was written at; the load path hands that + version and the payload here before deserialization, so a file written by an + older build loads correctly even after the format's stored shape changed. + + The engine walks the registered steps of ``kind``, applying the step whose + base version matches the position reached, until ``current_version`` is met. + The walk follows these rules: + + - a step runs only inside a complete path from ``version`` to + ``current_version``; when a position has no continuing step, the data + comes back unchanged and the format's load contract refuses the file, the + same outcome as for any version this build does not support; + - each step transforms the payload the previous step produced; + - a walk that ran stamps the format's version field with ``current_version``, + so the data states the version its shape now matches and a later save + writes that version; + - data already at ``current_version`` returns as the same object, which is + how the byte-level wrappers keep their path of skipping re-encoding. + + Args: + kind: The format whose update chain applies. + version: The version the data states. + data: The serialized data to upgrade. + updates: The registered steps for the format. + current_version: The version this build reads and writes. + + Returns: + SerializedData: The data upgraded and stamped with ``current_version``, + or the input unchanged when the chain does not reach it. + """ + chain = _resolve_chain( + version, + updates, + current_version, + ) + + if not chain: + return data + + upgraded = data + for update in chain: + upgraded = update.apply(upgraded) + + return _stamp(kind, upgraded, current_version) + + +def upgrade_binary(kind: ObjectKind, binary: bytes) -> bytes: + """Upgrades a msgpack payload at the load boundary of a binary format. + + The reconstruction and library formats store their data as msgpack mappings + whose ``metadata`` section carries ``_data_version``. This wrapper + unpacks the payload, reads that version, runs :func:`upgrade`, and re-encodes + the upgraded mapping. It returns the input bytes unchanged whenever the + payload stays as it is: when it does not unpack to a mapping, when it lacks + the version field, or when the chain does not apply. + """ + try: + data = msgpack.unpackb(binary, raw=False) + except ValueError: + return binary + + upgraded = _upgrade_payload(kind, data) + if upgraded is None: + return binary + + return bytes(msgpack.packb(upgraded, use_bin_type=True)) + + +def upgrade_json(kind: ObjectKind, raw: bytes) -> bytes: + """Upgrades a JSON document at the load boundary of a JSON format. + + The project format stores ``format_version`` at the document root. This + wrapper parses the document, reads that version, runs :func:`upgrade`, and + re-encodes the upgraded document. It returns the input bytes unchanged + whenever the document stays as it is: when it does not parse to a mapping, + when it lacks the version field, or when the chain does not apply. + """ + try: + data = json.loads(raw) + except ValueError: + return raw + + upgraded = _upgrade_payload(kind, data) + if upgraded is None: + return raw + + return json.dumps(upgraded).encode("utf-8") + + +def _upgrade_kind( + kind: ObjectKind, + version: str, + data: SerializedData, +) -> SerializedData: + return upgrade( + kind, + version, + data, + UPDATES[kind], + CURRENT_VERSIONS[kind], + ) + + +def _upgrade_payload( + kind: ObjectKind, + payload: Any, +) -> Optional[SerializedData]: + """Runs the format's chain over a parsed payload. + + Returns the upgraded mapping, or ``None`` when the payload stays as it is: + when it is not a mapping, when it lacks the format's version field, or when + the chain does not apply. + """ + if not isinstance(payload, dict): + return None + + version = _read_version(kind, payload) + if version is None: + return None + + upgraded = _upgrade_kind(kind, version, payload) + return None if upgraded is payload else upgraded + + +def _version_path(kind: ObjectKind) -> _VersionPath: + if kind is ObjectKind.PROJECT: + return _VersionPath( + section=None, + field="format_version", + ) + + return _VersionPath( + section="metadata", + field=f"{kind.value}_data_version", + ) + + +def _read_version(kind: ObjectKind, data: SerializedData) -> Optional[str]: + path = _version_path(kind) + section_name = path.section + if section_name is None: + value = data.get(path.field) + else: + section = data.get(section_name) + if not isinstance(section, dict): + return None + + value = section.get(path.field) + + return value if isinstance(value, str) else None + + +def _stamp( + kind: ObjectKind, + data: SerializedData, + version: str, +) -> SerializedData: + path = _version_path(kind) + stamped = dict(data) + section_name = path.section + if section_name is None: + stamped[path.field] = version + return stamped + + section = data.get(section_name) + if isinstance(section, dict): + stamped[section_name] = {**section, path.field: version} + + return stamped + + +def _resolve_chain( + version: str, + updates: Tuple[VersionUpdate, ...], + current_version: str, +) -> Tuple[VersionUpdate, ...]: + by_base: Dict[str, VersionUpdate] = {} + for update in updates: + base = _canonical(str(update.base)) + if base in by_base: + raise ValueError(f"Duplicate base version {base} among registered updates") + + by_base[base] = update + + chain: List[VersionUpdate] = [] + position = _canonical(version) + for _ in range(len(updates) + 1): + if compare_versions(position, current_version) == 0: + return tuple(chain) + + matching = by_base.get(position) + if matching is None: + return () + + chain.append(matching) + position = _canonical(str(matching.target)) + + return () + + +def _canonical(version: str) -> str: + return str(Version.model_validate(version)) diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py index c62d07e63..3f19a6541 100644 --- a/src/sampletones_core/library/data.py +++ b/src/sampletones_core/library/data.py @@ -6,6 +6,8 @@ from pydantic import ConfigDict, Field, ValidationError +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import upgrade_binary from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.constants.enums import GeneratorClassName from sampletones_core.data import DataModel, Metadata, MetadataContract @@ -116,6 +118,7 @@ def load(cls, path: Pathlike, fast: bool = True) -> InstructionLibraryData: binary = load_binary(path) try: + binary = upgrade_binary(ObjectKind.LIBRARY, binary) return InstructionLibraryData.deserialize( binary, validation=cls.validate_metadata, diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index 375b93e4b..9fa73c0b7 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -4,6 +4,8 @@ from pydantic import ValidationError +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import upgrade_json from sampletones_core.project.document import ProjectDocument from sampletones_core.project.instruments.record import SampleRecord from sampletones_core.project.instruments.sample import Sample @@ -63,7 +65,12 @@ def save(project: Project, path: Pathlike) -> None: def load(path: Pathlike) -> Project: try: with zipfile.ZipFile(path, "r") as archive: - document = ProjectDocument.model_validate_json(archive.read(PROJECT_DOCUMENT_NAME)) + document = ProjectDocument.model_validate_json( + upgrade_json( + ObjectKind.PROJECT, + archive.read(PROJECT_DOCUMENT_NAME), + ) + ) ProjectContainer._validate_document(document) reconstructions = ProjectContainer._read_reconstructions(archive) return ProjectContainer._build_project(document, reconstructions) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index b066f129b..fa3939f41 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -20,6 +20,8 @@ import numpy as np from pydantic import ConfigDict, Field, ValidationError, field_serializer +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import upgrade_binary from sampletones_core.configs import Config from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel, Metadata, MetadataContract @@ -416,6 +418,7 @@ def deserialize_data( fast: bool = True, ) -> Reconstruction: try: + binary = upgrade_binary(ObjectKind.RECONSTRUCTION, binary) return cls.deserialize(binary, validation=validation, fast=fast) except (ValidationError, TypeError, ValueError, struct.error, IndexError) as exception: raise InvalidReconstructionValuesError( diff --git a/tests/unit/sampletones_core/compatibility/__init__.py b/tests/unit/sampletones_core/compatibility/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/compatibility/test_binary.py b/tests/unit/sampletones_core/compatibility/test_binary.py new file mode 100644 index 000000000..c94bea91b --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/test_binary.py @@ -0,0 +1,41 @@ +import msgpack + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import upgrade_binary +from sampletones_shared.application import ( + SAMPLETONES_LIBRARY_DATA_VERSION, + SAMPLETONES_RECONSTRUCTION_DATA_VERSION, +) + + +class TestUpgradeBinary: + def test_current_reconstruction_version_returns_the_same_bytes(self) -> None: + binary = msgpack.packb( + {"metadata": {"reconstruction_data_version": SAMPLETONES_RECONSTRUCTION_DATA_VERSION}}, + use_bin_type=True, + ) + + assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary + + def test_current_library_version_returns_the_same_bytes(self) -> None: + binary = msgpack.packb( + {"metadata": {"library_data_version": SAMPLETONES_LIBRARY_DATA_VERSION}}, + use_bin_type=True, + ) + + assert upgrade_binary(ObjectKind.LIBRARY, binary) is binary + + def test_missing_metadata_returns_the_same_bytes(self) -> None: + binary = msgpack.packb({"items": []}, use_bin_type=True) + + assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary + + def test_non_mapping_payload_returns_the_same_bytes(self) -> None: + binary = msgpack.packb([1, 2, 3], use_bin_type=True) + + assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary + + def test_malformed_payload_returns_the_same_bytes(self) -> None: + binary = b"\xc1" + + assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary diff --git a/tests/unit/sampletones_core/compatibility/test_json.py b/tests/unit/sampletones_core/compatibility/test_json.py new file mode 100644 index 000000000..bf4cbaab3 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/test_json.py @@ -0,0 +1,27 @@ +import json + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import upgrade_json +from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION + + +class TestUpgradeJson: + def test_current_version_returns_the_same_bytes(self) -> None: + raw = json.dumps({"format_version": SAMPLETONES_PROJECT_DATA_VERSION}).encode("utf-8") + + assert upgrade_json(ObjectKind.PROJECT, raw) is raw + + def test_missing_version_returns_the_same_bytes(self) -> None: + raw = json.dumps({"samples": []}).encode("utf-8") + + assert upgrade_json(ObjectKind.PROJECT, raw) is raw + + def test_non_mapping_document_returns_the_same_bytes(self) -> None: + raw = b"[1, 2, 3]" + + assert upgrade_json(ObjectKind.PROJECT, raw) is raw + + def test_malformed_document_returns_the_same_bytes(self) -> None: + raw = b"{ not valid json" + + assert upgrade_json(ObjectKind.PROJECT, raw) is raw diff --git a/tests/unit/sampletones_core/compatibility/test_upgrade.py b/tests/unit/sampletones_core/compatibility/test_upgrade.py new file mode 100644 index 000000000..f42291889 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/test_upgrade.py @@ -0,0 +1,93 @@ +from typing import Final + +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.update import VersionUpdate +from sampletones_core.compatibility.upgrade import upgrade +from sampletones_shared.deployment.version import Version +from sampletones_shared.types.data import SerializedData + +FIRST_MARKER: Final[str] = "first" +SECOND_MARKER: Final[str] = "second" + + +def _marking_update(base: str, target: str, marker: str) -> VersionUpdate: + def apply(data: SerializedData) -> SerializedData: + markers = list(data["markers"]) + markers.append(marker) + return {**data, "markers": markers} + + return VersionUpdate(ObjectKind.LIBRARY, Version.model_validate(base), Version.model_validate(target), apply) + + +def _library_data(version: str) -> SerializedData: + return {"metadata": {"library_data_version": version}, "markers": []} + + +class TestUpgradeChain: + def test_chain_applies_each_update_in_order(self) -> None: + updates = ( + _marking_update("1.0", "1.1", FIRST_MARKER), + _marking_update("1.1", "1.2", SECOND_MARKER), + ) + + upgraded = upgrade(ObjectKind.LIBRARY, "1.0", _library_data("1.0"), updates, "1.2") + + assert upgraded["markers"] == [FIRST_MARKER, SECOND_MARKER] + assert upgraded["metadata"]["library_data_version"] == "1.2" + + def test_current_version_returns_the_input_unchanged(self) -> None: + updates = (_marking_update("1.0", "1.1", FIRST_MARKER),) + data = _library_data("1.1") + + assert upgrade(ObjectKind.LIBRARY, "1.1", data, updates, "1.1") is data + + def test_partial_chain_returns_the_input_unchanged(self) -> None: + updates = (_marking_update("1.0", "1.1", FIRST_MARKER),) + data = _library_data("1.0") + + assert upgrade(ObjectKind.LIBRARY, "1.0", data, updates, "1.2") is data + + def test_future_version_returns_the_input_unchanged(self) -> None: + updates = (_marking_update("1.0", "1.1", FIRST_MARKER),) + data = _library_data("1.2") + + assert upgrade(ObjectKind.LIBRARY, "1.2", data, updates, "1.1") is data + + def test_unknown_starting_version_returns_the_input_unchanged(self) -> None: + updates = (_marking_update("1.0", "1.1", FIRST_MARKER),) + data = _library_data("0.9") + + assert upgrade(ObjectKind.LIBRARY, "0.9", data, updates, "1.1") is data + + def test_two_component_version_matches_three_component_base(self) -> None: + updates = (_marking_update("1.1.0", "1.2", FIRST_MARKER),) + + upgraded = upgrade(ObjectKind.LIBRARY, "1.1", _library_data("1.1"), updates, "1.2") + + assert upgraded["markers"] == [FIRST_MARKER] + + def test_project_update_stamps_format_version(self) -> None: + updates = ( + VersionUpdate( + ObjectKind.PROJECT, + Version.model_validate("1.0"), + Version.model_validate("1.1"), + lambda data: {**data, "upgraded": True}, + ), + ) + + upgraded = upgrade(ObjectKind.PROJECT, "1.0", {"format_version": "1.0"}, updates, "1.1") + + assert upgraded["format_version"] == "1.1" + assert upgraded["upgraded"] is True + + def test_duplicate_base_raises(self) -> None: + updates = ( + _marking_update("1.0", "1.1", FIRST_MARKER), + _marking_update("1.0", "1.2", SECOND_MARKER), + ) + + with pytest.raises(ValueError): + upgrade(ObjectKind.LIBRARY, "1.0", _library_data("1.0"), updates, "1.2") From c068dda10cdc778614ff8c94dec90661f59aba85 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 13:32:00 +0200 Subject: [PATCH 011/142] Moved: equal temperament to the shared package --- docs/development/architecture.md | 2 +- docs/development/bugs-and-todos.md | 1 - docs/development/packages.md | 21 +- scripts/checks/import_boundary.py | 259 +------ src/sampletones_core/configs/library.py | 8 +- src/sampletones_core/constants/general.py | 4 - .../formats/bitphase/specification/chip.py | 3 +- src/sampletones_core/timers/utils.py | 2 +- src/sampletones_core/utils/frequencies.py | 141 +--- src/sampletones_shared/constants/music.py | 6 + .../meta/import_boundary/__init__.py | 0 .../meta/import_boundary/check.py | 60 ++ .../meta/import_boundary/graph.py | 41 ++ .../meta/import_boundary/imports.py | 24 + .../meta/import_boundary/lines.py | 25 + .../meta/import_boundary/rule.py | 65 ++ .../meta/import_boundary/scope.py | 34 + .../meta/import_boundary/token.py | 42 ++ .../meta/import_boundary/units.py | 34 + .../meta/import_boundary/violation.py | 32 + src/sampletones_shared/utils/frequencies.py | 143 ++++ src/sampletones_synthesis/frequency.py | 4 +- tests/suite/player.py | 2 +- tests/suite/source.py | 37 +- .../utils/test_frequencies.py | 678 +---------------- .../meta/import_boundary/__init__.py | 0 .../meta/import_boundary/test_check.py | 86 +++ .../meta/import_boundary/test_graph.py | 37 + .../meta/import_boundary/test_imports.py | 53 ++ .../meta/import_boundary/test_rule.py | 63 ++ .../meta/import_boundary/test_scope.py | 52 ++ .../meta/import_boundary/test_token.py | 40 ++ .../meta/import_boundary/test_units.py | 32 + .../meta/import_boundary/test_violation.py | 20 + .../utils/test_frequencies.py | 680 ++++++++++++++++++ .../scripts/checks/test_import_boundary.py | 225 ++---- 36 files changed, 1688 insertions(+), 1268 deletions(-) create mode 100644 src/sampletones_shared/meta/import_boundary/__init__.py create mode 100644 src/sampletones_shared/meta/import_boundary/check.py create mode 100644 src/sampletones_shared/meta/import_boundary/graph.py create mode 100644 src/sampletones_shared/meta/import_boundary/imports.py create mode 100644 src/sampletones_shared/meta/import_boundary/lines.py create mode 100644 src/sampletones_shared/meta/import_boundary/rule.py create mode 100644 src/sampletones_shared/meta/import_boundary/scope.py create mode 100644 src/sampletones_shared/meta/import_boundary/token.py create mode 100644 src/sampletones_shared/meta/import_boundary/units.py create mode 100644 src/sampletones_shared/meta/import_boundary/violation.py create mode 100644 src/sampletones_shared/utils/frequencies.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/__init__.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_check.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_graph.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_imports.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_rule.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_scope.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_token.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_units.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/test_violation.py create mode 100644 tests/unit/sampletones_shared/utils/test_frequencies.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 6223feff7..d2205b845 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -178,7 +178,7 @@ What DearPyGui has already taken a copy of is registered rather than remembered Two mechanisms keep the codebase aligned with this document. -**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. The same script holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. +**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. The same script holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. The script declares what the boundaries are; `sampletones_shared/meta/import_boundary/` holds how they are read and reported. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. **The identifier vocabularies are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target: diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index c12cc4d7b..b5b2a61c7 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -38,7 +38,6 @@ * Per-tab undo routing * In-application console * Improve performance of browser favorite scan of the entire tree per click -* `sampletones_synthesis` reaching back into `sampletones_core` for the pitch limits and `pitch_to_frequency` ## Bugs diff --git a/docs/development/packages.md b/docs/development/packages.md index d105caeb5..ff0cc33f9 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -35,12 +35,11 @@ graph TD PLAYER --> SHARED APP --> SHARED ENTRY --> SHARED - SYNTH -.->|"pitch limits"| CORE ``` | Package | Purpose | May import | |---------|---------|------------| -| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the AST layer the checks read source through | — | +| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the source layer the checks read the tree through | — | | `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, calibration — reached as package data rather than by import | — | | `sampletones_assets` | The application mark and the bundled fonts, with the code that draws the mark | `sampletones_shared` | | `sampletones_synthesis` | Analytic waveform synthesis: oscillators, envelopes, layers and voices | `sampletones_shared` | @@ -57,14 +56,11 @@ player's format move while the engine holds still. The consequence is that an ex reaching the console — the seam `sampletones_core/trackers/backend.py` describes — is registered from above rather than from the engine's own registry. -### The pitch back-edge - -`sampletones_synthesis/frequency.py` reaches back up to `sampletones_core` for the pitch limits and -the pitch-to-frequency conversion, which is the one edge running against the order above. The check -narrows it to exactly the two modules it needs, `sampletones_core.constants.general` and -`sampletones_core.utils.frequencies`, so the rest of the engine stays out of reach from below. -Moving those facts into `sampletones_shared` closes the edge; it is listed in -[`bugs-and-todos.md`](bugs-and-todos.md) until then. +**Equal temperament sits at the bottom.** The MIDI pitch limits and the A4 reference are +`sampletones_shared/constants/music.py`, and the pitch-to-frequency conversion they govern is +`sampletones_shared/utils/frequencies.py` — so the synthesis package reads them without reaching up +into the engine, and `sampletones_core/utils/frequencies.py` keeps what is the engine's own: the +project's usable pitch range, the noise periods, and the note and period names. --- @@ -102,3 +98,8 @@ it may import — and derives the rule it runs from them: every unit a table lea reach, so an edge is declared before it is taken. The hook audits the whole source tree on every commit (`make check-import-boundary`), which means adding an edge to a table is how a new dependency is opened, and removing one enumerates the work of closing it. + +The script is the declaration alone. Reading a module line by line, resolving a unit to the modules +it owns, deriving a rule from a graph and reporting what crosses it live in +`sampletones_shared/meta/import_boundary/`, beside the source layer the other checks read the tree +through. diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 1540e270f..a80d41e54 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -19,26 +19,25 @@ express — e.g. that panels never compose a column suffix (`SUF_PANEL_*`) or parent into another panel's container. +This script declares what the boundaries are; `sampletones_shared/meta/import_boundary/` holds how +they are read and reported. + Usage: python scripts/checks/import_boundary.py [files...] # check specific files python scripts/checks/import_boundary.py --all # run all rules against the source tree """ import argparse -import re import sys from pathlib import Path -from typing import Dict, Final, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple +from typing import Dict, Final, List, Sequence, Tuple -from sampletones_shared.meta.source.modules import MODULE_SEPARATOR, source_paths +from sampletones_shared.meta.import_boundary.check import check_boundaries +from sampletones_shared.meta.import_boundary.graph import LayerGraph +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.token import TokenRule from sampletones_shared.paths.source import SOURCE_ROOT -IMPORT_RE = re.compile(r"^\s*(import|from)\s+([\w.]+)") - -MODULE_SUFFIX: Final[str] = ".py" -PATH_SEPARATOR: Final[str] = "/" -UNIT_GLOB: Final[str] = "**/*.py" - APPLICATION: Final[str] = "sampletones_application" PLAYER: Final[str] = "sampletones_player" @@ -65,15 +64,6 @@ "sampletones": ("sampletones_shared", "sampletones_core", "sampletones_application"), } -SYNTHESIS_PITCH_CONTRACT: Final[Tuple[str, ...]] = ( - "sampletones_core.constants.general", - "sampletones_core.utils.frequencies", -) - -PACKAGE_CONTRACTS: Final[Dict[str, Tuple[str, ...]]] = { - "sampletones_synthesis": SYNTHESIS_PITCH_CONTRACT, -} - PLAYER_LAYERS: Final[Dict[str, Tuple[str, ...]]] = { "__init__.py": (), "specification": (), @@ -86,119 +76,16 @@ "driver/assembler": ("driver", "specification"), } - -class LayerGraph(NamedTuple): - """A tree of modules, the units it divides into, and what each unit may import. - - Attributes: - root: Directory under the source root the units are named within. - package: Import prefix the units sit under, empty where the units are packages themselves. - layers: Each unit and the units it may import. - contracts: Import prefixes a unit reaches past its layers. - """ - - root: str - package: str - layers: Dict[str, Tuple[str, ...]] - contracts: Dict[str, Tuple[str, ...]] - - -class BoundaryRule(NamedTuple): - """One tree of modules and the imports it stays clear of. - - Attributes: - root: Directory under the source root the pattern is written against. - pattern: Glob naming the modules the rule reaches. - forbidden: Import prefixes out of reach in them. - contracts: Import prefixes exempt from the forbidden ones. - excluding: Globs naming the modules a rule of their own owns instead. - """ - - root: str - pattern: str - forbidden: Tuple[str, ...] - contracts: Tuple[str, ...] = () - excluding: Tuple[str, ...] = () - - -class TokenRule(NamedTuple): - """One tree of modules and a spelling that stays out of them.""" - - root: str - pattern: str - forbidden: str - message: str - - -class Violation(NamedTuple): - """One import or token a rule forbids, and where a reader opens it.""" - - kind: str - location: str - - -def unit_prefix(package: str, unit: str) -> str: - """The import prefix a unit is reached by. - - Args: - package: Import prefix the unit sits under, empty where the unit is a package itself. - unit: Unit named as a path under the graph's root. - - Returns: - str: The dotted prefix an import of that unit begins with. - """ - name = unit.removesuffix(MODULE_SUFFIX).replace(PATH_SEPARATOR, MODULE_SEPARATOR) - return f"{package}{MODULE_SEPARATOR}{name}" if package else name - - -def unit_glob(unit: str) -> str: - """The glob naming the modules a unit holds, whether the unit is a module or a directory.""" - return unit if unit.endswith(MODULE_SUFFIX) else f"{unit}{PATH_SEPARATOR}{UNIT_GLOB}" - - -def nested_globs(unit: str, units: Iterable[str]) -> Tuple[str, ...]: - """The globs of the units declared inside another one, which own their modules instead.""" - return tuple(unit_glob(other) for other in units if other.startswith(f"{unit}{PATH_SEPARATOR}")) - - -def layer_rules(graph: LayerGraph) -> List[BoundaryRule]: - """One rule per unit of a layer graph, forbidding every unit its layers leave out. - - Declaring what a unit may import states the graph once, and the rule the check runs is what - remains — so an edge the graph leaves out is reported wherever it is taken. - - Args: - graph: The tree, its units, and the units each one may import. - - Returns: - List[BoundaryRule]: The rules the graph amounts to, in declaration order. - """ - return [ - BoundaryRule( - root=graph.root, - pattern=unit_glob(unit), - forbidden=tuple( - unit_prefix(graph.package, other) for other in graph.layers if other != unit and other not in allowed - ), - contracts=graph.contracts.get(unit, ()), - excluding=nested_globs(unit, graph.layers), - ) - for unit, allowed in graph.layers.items() - ] - - PACKAGES: Final[LayerGraph] = LayerGraph( root="", package="", layers=PACKAGE_LAYERS, - contracts=PACKAGE_CONTRACTS, ) PLAYER_GRAPH: Final[LayerGraph] = LayerGraph( root=PLAYER, package=PLAYER, layers=PLAYER_LAYERS, - contracts={}, ) APPLICATION_RULES: Final[Tuple[BoundaryRule, ...]] = ( @@ -274,14 +161,12 @@ def layer_rules(graph: LayerGraph) -> List[BoundaryRule]: ), ) - RULES: Final[Tuple[BoundaryRule, ...]] = ( - *layer_rules(PACKAGES), - *layer_rules(PLAYER_GRAPH), + *PACKAGES.rules(), + *PLAYER_GRAPH.rules(), *APPLICATION_RULES, ) - TOKEN_RULES: Final[Tuple[TokenRule, ...]] = ( TokenRule( APPLICATION, @@ -308,128 +193,6 @@ def layer_rules(graph: LayerGraph) -> List[BoundaryRule]: ) -def _matches_prefix(module: str, prefix: str) -> bool: - return module == prefix or module.startswith(prefix + MODULE_SEPARATOR) - - -def find_token_violations( - filepath: Path, - rule: TokenRule, -) -> List[Violation]: - pattern = re.compile(rule.forbidden) - violations: List[Violation] = [] - for line_number, line in enumerate( - filepath.read_text(encoding="utf-8").splitlines(), - start=1, - ): - if pattern.search(line): - location = f"{filepath}:{line_number}" - violations.append( - Violation( - kind=rule.message, - location=f"{location}: {line.strip()}", - ) - ) - - return violations - - -def find_violations( - filepath: Path, - rule: BoundaryRule, -) -> List[Violation]: - violations: List[Violation] = [] - for line_number, line in enumerate( - filepath.read_text(encoding="utf-8").splitlines(), - start=1, - ): - match = IMPORT_RE.match(line) - if match is None: - continue - - module = match.group(2) - if any(_matches_prefix(module, contract) for contract in rule.contracts): - continue - - for prefix in rule.forbidden: - if _matches_prefix(module, prefix): - location = f"{filepath}:{line_number}" - violations.append( - Violation( - kind=prefix, - location=f"{location}: {line.strip()}", - ) - ) - break - - return violations - - -def rule_modules( - root: Path, - pattern: str, - excluding: Tuple[str, ...], - swept: Set[Path], - selection: Optional[Set[Path]], -) -> List[Path]: - """The modules a rule reaches, in path order. - - A rule names its files by one glob whether the check runs over the whole tree or over the files - a hook lists, so the two entry points read the same rule the same way. A module a nested rule - owns belongs to that rule alone, which is how a subpackage states a boundary of its own inside - the one around it. - - Args: - root: Directory the rule globs are written against. - pattern: Glob the rule names its files by. - excluding: Globs naming the modules a rule of their own owns instead. - swept: Visible modules the tree holds, which the glob is held to. - selection: Resolved paths to narrow the rule to, or `None` to reach every module it names. - - Returns: - List[Path]: The modules the rule applies to. - """ - owned = {path.resolve() for nested in excluding for path in root.glob(nested)} - matched = ({path.resolve() for path in root.glob(pattern)} & swept) - owned - if selection is not None: - matched &= selection - - return sorted(matched) - - -def check_boundaries(source: Path, selection: Optional[Set[Path]]) -> List[Violation]: - """Every import and token the rules forbid under a source root. - - The tree is swept first, so the rules run over the modules it holds and a root reading as empty - stops the check where it would otherwise report a clean tree. - - Args: - source: Source root the rule roots are named within. - selection: Resolved paths to narrow the check to, or `None` to check the whole tree. - - Returns: - List[Violation]: What the rules report, boundary rules first. - - Raises: - NotADirectoryError: If the source root names no directory. - FileNotFoundError: If the source root holds no module to read. - """ - swept = {path.resolve() for path in source_paths([source])} - violations = [ - violation - for rule in RULES - for filepath in rule_modules(source / rule.root, rule.pattern, rule.excluding, swept, selection) - for violation in find_violations(filepath, rule) - ] - violations.extend( - violation - for token_rule in TOKEN_RULES - for filepath in rule_modules(source / token_rule.root, token_rule.pattern, (), swept, selection) - for violation in find_token_violations(filepath, token_rule) - ) - return violations - - def main(argv: Sequence[str]) -> int: """Report every import and token the layer boundaries forbid.""" parser = argparse.ArgumentParser( @@ -456,7 +219,7 @@ def main(argv: Sequence[str]) -> int: files: List[Path] = arguments.files selection = None if arguments.all else {path.resolve() for path in files} - violations = check_boundaries(arguments.source, selection) + violations = check_boundaries(arguments.source, RULES, TOKEN_RULES, selection) if not violations: return 0 diff --git a/src/sampletones_core/configs/library.py b/src/sampletones_core/configs/library.py index 0cdfa86f4..ce6b0981f 100644 --- a/src/sampletones_core/configs/library.py +++ b/src/sampletones_core/configs/library.py @@ -8,14 +8,10 @@ MIN_SAMPLE_RATE, ) from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.constants.general import ( - A4_FREQUENCY, - A4_PITCH, - LIMIT_MAX_PITCH, - MIN_FREQUENCY, -) +from sampletones_core.constants.general import MIN_FREQUENCY from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY from sampletones_core.data import DataModel +from sampletones_shared.constants.music import A4_FREQUENCY, A4_PITCH, LIMIT_MAX_PITCH from sampletones_shared.constants.nes import ( DEFAULT_NES_FREQUENCY, MAX_NES_FREQUENCY, diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py index cda29fc14..230569e1c 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -5,17 +5,13 @@ APU_CLOCK: Final[float] = 1789773.0 TIMER_CYCLE_DIVIDER: Final[int] = 16 MAX_TIMER: Final[int] = 0x7FF -LIMIT_MIN_PITCH: Final[int] = 24 MIN_PITCH: Final[int] = 33 MAX_PITCH: Final[int] = 119 -LIMIT_MAX_PITCH: Final[int] = 127 PITCH_RANGE: Final[int] = MAX_PITCH - MIN_PITCH MIN_FREQUENCY: Final[float] = APU_CLOCK / (TIMER_CYCLE_DIVIDER * (MAX_TIMER + 1)) MAX_FREQUENCY: Final[float] = APU_CLOCK / TIMER_CYCLE_DIVIDER -A4_FREQUENCY: Final[float] = 440.0 -A4_PITCH: Final[int] = 69 NOTE_NAMES: Tuple[str, ...] = ( "C-", "C#", diff --git a/src/sampletones_core/formats/bitphase/specification/chip.py b/src/sampletones_core/formats/bitphase/specification/chip.py index 24dd1152a..7bb7b3e89 100644 --- a/src/sampletones_core/formats/bitphase/specification/chip.py +++ b/src/sampletones_core/formats/bitphase/specification/chip.py @@ -1,7 +1,8 @@ from enum import StrEnum from typing import Dict, Final -from sampletones_core.constants.general import A4_FREQUENCY, APU_CLOCK +from sampletones_core.constants.general import APU_CLOCK +from sampletones_shared.constants.music import A4_FREQUENCY CHIP_TYPE_NES: Final[str] = "nes" diff --git a/src/sampletones_core/timers/utils.py b/src/sampletones_core/timers/utils.py index 1e4f46f83..d6db24bab 100644 --- a/src/sampletones_core/timers/utils.py +++ b/src/sampletones_core/timers/utils.py @@ -1,7 +1,7 @@ from typing import Dict from sampletones_core.configs import Config -from sampletones_core.utils.frequencies import pitch_to_frequency +from sampletones_shared.utils.frequencies import pitch_to_frequency from .arithmetic import frequency_to_timer from .implementation.phase import PhaseTimer diff --git a/src/sampletones_core/utils/frequencies.py b/src/sampletones_core/utils/frequencies.py index 4423c0cdb..eeb2d966d 100644 --- a/src/sampletones_core/utils/frequencies.py +++ b/src/sampletones_core/utils/frequencies.py @@ -1,10 +1,4 @@ -import numpy as np - from sampletones_core.constants.general import ( - A4_FREQUENCY, - A4_PITCH, - LIMIT_MAX_PITCH, - LIMIT_MIN_PITCH, MAX_PERIOD, MAX_PITCH, MIN_PITCH, @@ -12,24 +6,7 @@ NOTE_NAMES, ) from sampletones_shared.utils.arrays import clamp - - -def validate_pitch(pitch: int) -> None: - """ - Validates that a pitch value is an integer within the range 24-127. - - Args: - pitch: The pitch value to validate. - - Raises: - TypeError: If pitch is not an integer. - ValueError: If pitch is outside the range 24-127. - """ - if not isinstance(pitch, int): - raise TypeError("Pitch must be an integer value") - - if not LIMIT_MIN_PITCH <= pitch <= LIMIT_MAX_PITCH: - raise ValueError(f"Pitch must be in the range {LIMIT_MIN_PITCH}-{LIMIT_MAX_PITCH}") +from sampletones_shared.utils.frequencies import validate_pitch def is_pitch_valid(pitch: int) -> bool: @@ -45,27 +22,6 @@ def is_pitch_valid(pitch: int) -> bool: return MIN_PITCH <= pitch <= MAX_PITCH -def validate_frequency(frequency: float) -> None: - """ - Validates that a frequency value is a positive finite number. - - Args: - frequency: The frequency value to validate. - - Raises: - TypeError: If frequency is not a numeric type. - ValueError: If frequency is not a positive finite number. - """ - if not isinstance(frequency, (int, float)): - raise TypeError("Frequency must be a numeric value") - - if np.isinf(frequency) or np.isnan(frequency): - raise ValueError("Frequency must be a positive finite number") - - if frequency <= 0: - raise ValueError("Frequency must be a positive value") - - def validate_period(period: int) -> None: """ Validates that a period value is an integer within the range 0-15. @@ -84,101 +40,6 @@ def validate_period(period: int) -> None: raise ValueError(f"Period must be in the range 0-{MAX_PERIOD}") -def pitch_to_frequency( - pitch: int, - a4_frequency: float = A4_FREQUENCY, - a4_pitch: int = A4_PITCH, -) -> float: - """ - Converts a MIDI-style pitch value to its corresponding frequency in Hz. - - Uses the equal temperament tuning system where each semitone is separated - by a factor of 2^(1/12). - - Args: - pitch: The MIDI pitch number (24-127, where 69 is typically A4). - a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz. - a4_pitch: The MIDI pitch number for A4. Defaults to 69. - - Returns: - The frequency in Hz corresponding to the given pitch. - - Raises: - TypeError: If pitch is not an integer. - TypeError: If a4_frequency is not a numeric type. - TypeError: If a4_pitch is not an integer. - ValueError: If pitch is outside the range 24-127. - ValueError: If a4_pitch is outside the range 24-127. - ValueError: If a4_frequency is not a positive finite number. - ValueError: If calculated frequency is not a positive finite number. - - Examples: - >>> pitch_to_frequency(69) # A4 - 440.0 - >>> pitch_to_frequency(57) # A3 (one octave below A4) - 220.0 - >>> pitch_to_frequency(60) # Middle C (C4) - 261.6255653005986 - >>> pitch_to_frequency(69, a4_frequency=432.0) # A4 with different tuning - 432.0 - """ - validate_pitch(pitch) - validate_pitch(a4_pitch) - validate_frequency(a4_frequency) - - frequency: float = a4_frequency * (2 ** ((pitch - a4_pitch) / 12)) - validate_frequency(frequency) - return frequency - - -def frequency_to_pitch( - frequency: float, - a4_frequency: float = A4_FREQUENCY, - a4_pitch: int = A4_PITCH, -) -> int: - """ - Converts a frequency in Hz to the nearest MIDI-style pitch value. - - Uses logarithmic conversion based on the equal temperament tuning system. - Returns 0 for frequencies at or below 0 Hz. - - Args: - frequency: The frequency in Hz to convert. - a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz. - a4_pitch: The MIDI pitch number for A4. Defaults to 69. - - Returns: - The nearest integer MIDI pitch number. - - Raises: - TypeError: If frequency is not a numeric type. - TypeError: If a4_frequency is not a numeric type. - TypeError: If a4_pitch is not an integer. - ValueError: If frequency is not a positive finite number. - ValueError: If a4_frequency is not a positive finite number. - ValueError: If calculated pitch is outside the range 24-127. - - Examples: - >>> frequency_to_pitch(440.0) # A4 - 69 - >>> frequency_to_pitch(880.0) # A5 - 81 - >>> frequency_to_pitch(261.63) # ~middle C - 60 - >>> frequency_to_pitch(0.0) # invalid frequency - Traceback (most recent call last): - ... - ValueError: Frequency must be a positive value - """ - validate_frequency(frequency) - validate_frequency(a4_frequency) - validate_pitch(a4_pitch) - - pitch: int = round(a4_pitch + 12 * (np.log2(frequency / a4_frequency))) - validate_pitch(pitch) - return pitch - - def pitch_to_name(pitch: int, transpose: int = 0) -> str: """ Converts a MIDI pitch value to a human-readable note name diff --git a/src/sampletones_shared/constants/music.py b/src/sampletones_shared/constants/music.py index 26cb282db..2395a6ed8 100644 --- a/src/sampletones_shared/constants/music.py +++ b/src/sampletones_shared/constants/music.py @@ -2,3 +2,9 @@ SEMITONE_STEP: Final[int] = 1 OCTAVE_SEMITONES: Final[int] = 12 + +LIMIT_MIN_PITCH: Final[int] = 24 +LIMIT_MAX_PITCH: Final[int] = 127 + +A4_FREQUENCY: Final[float] = 440.0 +A4_PITCH: Final[int] = 69 diff --git a/src/sampletones_shared/meta/import_boundary/__init__.py b/src/sampletones_shared/meta/import_boundary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_shared/meta/import_boundary/check.py b/src/sampletones_shared/meta/import_boundary/check.py new file mode 100644 index 000000000..c5604faec --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/check.py @@ -0,0 +1,60 @@ +from pathlib import Path +from typing import List, Optional, Sequence, Set + +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.scope import rule_modules +from sampletones_shared.meta.import_boundary.token import TokenRule +from sampletones_shared.meta.import_boundary.violation import Violation +from sampletones_shared.meta.source.modules import source_paths + + +def check_boundaries( + source: Path, + rules: Sequence[BoundaryRule], + token_rules: Sequence[TokenRule], + selection: Optional[Set[Path]], +) -> List[Violation]: + """Every import and token the rules forbid under a source root. + + The tree is swept first, so the rules run over the modules it holds and a root reading as empty + stops the check where it would otherwise report a clean tree. + + Args: + source: Source root the rule roots are named within. + rules: Import boundaries to hold the tree to. + token_rules: Spellings to keep out of the tree. + selection: Resolved paths to narrow the check to, or `None` to check the whole tree. + + Returns: + List[Violation]: What the rules report, boundary rules first. + + Raises: + NotADirectoryError: If the source root names no directory. + FileNotFoundError: If the source root holds no module to read. + """ + swept = {path.resolve() for path in source_paths([source])} + violations = [ + violation + for rule in rules + for path in rule_modules( + source / rule.root, + rule.pattern, + rule.excluding, + swept, + selection, + ) + for violation in rule.violations(path) + ] + violations.extend( + violation + for token_rule in token_rules + for path in rule_modules( + source / token_rule.root, + token_rule.pattern, + (), + swept, + selection, + ) + for violation in token_rule.violations(path) + ) + return violations diff --git a/src/sampletones_shared/meta/import_boundary/graph.py b/src/sampletones_shared/meta/import_boundary/graph.py new file mode 100644 index 000000000..bfa8a69e7 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/graph.py @@ -0,0 +1,41 @@ +from typing import Dict, List, NamedTuple, Tuple + +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.units import nested_globs, unit_glob, unit_prefix + + +class LayerGraph(NamedTuple): + """A tree of modules, the units it divides into, and what each unit may import. + + Attributes: + root: Directory under the source root the units are named within. + package: Import prefix the units sit under, empty where the units are packages themselves. + layers: Each unit and the units it may import. + """ + + root: str + package: str + layers: Dict[str, Tuple[str, ...]] + + def rules(self) -> List[BoundaryRule]: + """One rule per unit, forbidding every unit its layers leave out. + + Declaring what a unit may import states the graph once, and the rule the check runs is what + remains — so an edge the graph leaves out is reported wherever it is taken. A unit declared + inside another owns its own modules, which is how a subpackage states a boundary of its own + inside the one around it. + + Returns: + List[BoundaryRule]: The rules the graph amounts to, in declaration order. + """ + return [ + BoundaryRule( + root=self.root, + pattern=unit_glob(unit), + forbidden=tuple( + unit_prefix(self.package, other) for other in self.layers if other != unit and other not in allowed + ), + excluding=nested_globs(unit, self.layers), + ) + for unit, allowed in self.layers.items() + ] diff --git a/src/sampletones_shared/meta/import_boundary/imports.py b/src/sampletones_shared/meta/import_boundary/imports.py new file mode 100644 index 000000000..5a4f50508 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/imports.py @@ -0,0 +1,24 @@ +import re +from typing import Final, Optional + +from sampletones_shared.meta.source.modules import MODULE_SEPARATOR + +IMPORT_PATTERN: Final[re.Pattern[str]] = re.compile(r"^\s*(import|from)\s+([\w.]+)") + + +def imported_module(line: str) -> Optional[str]: + """The dotted module one line of source imports. + + Args: + line: Line to read. + + Returns: + Optional[str]: The module the line imports, or `None` where the line imports nothing. + """ + match = IMPORT_PATTERN.match(line) + return match.group(2) if match is not None else None + + +def matches_prefix(module: str, prefix: str) -> bool: + """Whether a dotted module is the prefix itself or a module underneath it.""" + return module == prefix or module.startswith(prefix + MODULE_SEPARATOR) diff --git a/src/sampletones_shared/meta/import_boundary/lines.py b/src/sampletones_shared/meta/import_boundary/lines.py new file mode 100644 index 000000000..3b6f2d28a --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/lines.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Iterator, Tuple + +from sampletones_shared.meta.source.modules import SOURCE_ENCODING + + +def numbered_lines(path: Path) -> Iterator[Tuple[int, str]]: + """Each line of a module paired with the number a report points a reader at. + + A boundary is stated over the source as it is written rather than over the tree it parses to, + so a report quotes the line a reader opens and an unparseable module is still checked. + + Args: + path: Module to read. + + Yields: + Tuple[int, str]: The line number, counting from one, and the line. + + Raises: + OSError: If the module cannot be read. + """ + yield from enumerate( + path.read_text(encoding=SOURCE_ENCODING).splitlines(), + start=1, + ) diff --git a/src/sampletones_shared/meta/import_boundary/rule.py b/src/sampletones_shared/meta/import_boundary/rule.py new file mode 100644 index 000000000..e88556015 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/rule.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import List, NamedTuple, Tuple + +from sampletones_shared.meta.import_boundary.imports import ( + imported_module, + matches_prefix, +) +from sampletones_shared.meta.import_boundary.lines import numbered_lines +from sampletones_shared.meta.import_boundary.violation import Violation + + +class BoundaryRule(NamedTuple): + """One tree of modules and the imports it stays clear of. + + Attributes: + root: Directory under the source root the pattern is written against. + pattern: Glob naming the modules the rule reaches. + forbidden: Import prefixes out of reach in them. + contracts: Import prefixes exempt from the forbidden ones. + excluding: Globs naming the modules a rule of their own owns instead. + """ + + root: str + pattern: str + forbidden: Tuple[str, ...] + contracts: Tuple[str, ...] = () + excluding: Tuple[str, ...] = () + + def violations(self, path: Path) -> List[Violation]: + """Every import one module takes that the rule forbids. + + A contract is read first, so a module named by one is reached even where the prefix around + it is out of bounds. The first forbidden prefix an import matches names the violation, since + one import crosses one boundary. + + Args: + path: Module to read. + + Returns: + List[Violation]: What the module imports past the boundary, in line order. + + Raises: + OSError: If the module cannot be read. + """ + violations: List[Violation] = [] + for line_number, line in numbered_lines(path): + module = imported_module(line) + if module is None or any(matches_prefix(module, contract) for contract in self.contracts): + continue + + crossed = next( + (prefix for prefix in self.forbidden if matches_prefix(module, prefix)), + None, + ) + if crossed is not None: + violations.append( + Violation.at( + crossed, + path, + line_number, + line, + ) + ) + + return violations diff --git a/src/sampletones_shared/meta/import_boundary/scope.py b/src/sampletones_shared/meta/import_boundary/scope.py new file mode 100644 index 000000000..86aa45c28 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/scope.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import List, Optional, Set, Tuple + + +def rule_modules( + root: Path, + pattern: str, + excluding: Tuple[str, ...], + swept: Set[Path], + selection: Optional[Set[Path]], +) -> List[Path]: + """The modules a rule reaches, in path order. + + A rule names its files by one glob whether the check runs over the whole tree or over the files + a hook lists, so the two entry points read the same rule the same way. A module a nested rule + owns belongs to that rule alone, which is how a subpackage states a boundary of its own inside + the one around it. + + Args: + root: Directory the rule globs are written against. + pattern: Glob the rule names its files by. + excluding: Globs naming the modules a rule of their own owns instead. + swept: Visible modules the tree holds, which the glob is held to. + selection: Resolved paths to narrow the rule to, or `None` to reach every module it names. + + Returns: + List[Path]: The modules the rule applies to. + """ + owned = {path.resolve() for nested in excluding for path in root.glob(nested)} + matched = ({path.resolve() for path in root.glob(pattern)} & swept) - owned + if selection is not None: + matched &= selection + + return sorted(matched) diff --git a/src/sampletones_shared/meta/import_boundary/token.py b/src/sampletones_shared/meta/import_boundary/token.py new file mode 100644 index 000000000..fe55f4ecd --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/token.py @@ -0,0 +1,42 @@ +import re +from pathlib import Path +from typing import List, NamedTuple + +from sampletones_shared.meta.import_boundary.lines import numbered_lines +from sampletones_shared.meta.import_boundary.violation import Violation + + +class TokenRule(NamedTuple): + """One tree of modules and a spelling that stays out of them. + + Attributes: + root: Directory under the source root the pattern is written against. + pattern: Glob naming the modules the rule reaches. + forbidden: Regular expression the modules stay clear of. + message: What the rule holds, printed where a module writes the spelling. + """ + + root: str + pattern: str + forbidden: str + message: str + + def violations(self, path: Path) -> List[Violation]: + """Every line of one module that writes the forbidden spelling. + + Args: + path: Module to read. + + Returns: + List[Violation]: The lines the rule reports, in line order. + + Raises: + OSError: If the module cannot be read. + re.error: If the forbidden spelling is no valid regular expression. + """ + spelling = re.compile(self.forbidden) + return [ + Violation.at(self.message, path, line_number, line) + for line_number, line in numbered_lines(path) + if spelling.search(line) + ] diff --git a/src/sampletones_shared/meta/import_boundary/units.py b/src/sampletones_shared/meta/import_boundary/units.py new file mode 100644 index 000000000..c87b06d0a --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/units.py @@ -0,0 +1,34 @@ +from typing import Final, Iterable, Tuple + +from sampletones_shared.meta.source.modules import MODULE_SEPARATOR + +MODULE_SUFFIX: Final[str] = ".py" +PATH_SEPARATOR: Final[str] = "/" +UNIT_PATTERN: Final[str] = "**/*.py" + + +def unit_prefix(package: str, unit: str) -> str: + """The import prefix a unit is reached by. + + Args: + package: Import prefix the unit sits under, empty where the unit is a package itself. + unit: Unit named as a path under the graph's root. + + Returns: + str: The dotted prefix an import of that unit begins with. + """ + name = unit.removesuffix(MODULE_SUFFIX).replace( + PATH_SEPARATOR, + MODULE_SEPARATOR, + ) + return f"{package}{MODULE_SEPARATOR}{name}" if package else name + + +def unit_glob(unit: str) -> str: + """The glob naming the modules a unit holds, whether the unit is a module or a directory.""" + return unit if unit.endswith(MODULE_SUFFIX) else f"{unit}{PATH_SEPARATOR}{UNIT_PATTERN}" + + +def nested_globs(unit: str, units: Iterable[str]) -> Tuple[str, ...]: + """The globs of the units declared inside another one, which own their modules instead.""" + return tuple(unit_glob(other) for other in units if other.startswith(f"{unit}{PATH_SEPARATOR}")) diff --git a/src/sampletones_shared/meta/import_boundary/violation.py b/src/sampletones_shared/meta/import_boundary/violation.py new file mode 100644 index 000000000..42eb61260 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/violation.py @@ -0,0 +1,32 @@ +from pathlib import Path +from typing import NamedTuple, Self + + +class Violation(NamedTuple): + """One import or token a rule forbids, and where a reader opens it. + + Attributes: + kind: What the rule forbids, spelled the way the report names it. + location: The module, the line number and the line itself. + """ + + kind: str + location: str + + @classmethod + def at(cls, kind: str, path: Path, line_number: int, line: str) -> Self: + """One violation located as `path:line`, the form an editor jumps to. + + Args: + kind: What the rule forbids. + path: Module the line sits in. + line_number: Line the violation sits on, counting from one. + line: The line itself, quoted stripped of its indentation. + + Returns: + Self: The violation a report prints. + """ + return cls( + kind=kind, + location=f"{path}:{line_number}: {line.strip()}", + ) diff --git a/src/sampletones_shared/utils/frequencies.py b/src/sampletones_shared/utils/frequencies.py new file mode 100644 index 000000000..b0ba02de2 --- /dev/null +++ b/src/sampletones_shared/utils/frequencies.py @@ -0,0 +1,143 @@ +import numpy as np + +from sampletones_shared.constants.music import ( + A4_FREQUENCY, + A4_PITCH, + LIMIT_MAX_PITCH, + LIMIT_MIN_PITCH, + OCTAVE_SEMITONES, +) + + +def validate_pitch(pitch: int) -> None: + """ + Validates that a pitch value is an integer within the range 24-127. + + Args: + pitch: The pitch value to validate. + + Raises: + TypeError: If pitch is not an integer. + ValueError: If pitch is outside the range 24-127. + """ + if not isinstance(pitch, int): + raise TypeError("Pitch must be an integer value") + + if not LIMIT_MIN_PITCH <= pitch <= LIMIT_MAX_PITCH: + raise ValueError(f"Pitch must be in the range {LIMIT_MIN_PITCH}-{LIMIT_MAX_PITCH}") + + +def validate_frequency(frequency: float) -> None: + """ + Validates that a frequency value is a positive finite number. + + Args: + frequency: The frequency value to validate. + + Raises: + TypeError: If frequency is not a numeric type. + ValueError: If frequency is not a positive finite number. + """ + if not isinstance(frequency, (int, float)): + raise TypeError("Frequency must be a numeric value") + + if np.isinf(frequency) or np.isnan(frequency): + raise ValueError("Frequency must be a positive finite number") + + if frequency <= 0: + raise ValueError("Frequency must be a positive value") + + +def pitch_to_frequency( + pitch: int, + a4_frequency: float = A4_FREQUENCY, + a4_pitch: int = A4_PITCH, +) -> float: + """ + Converts a MIDI-style pitch value to its corresponding frequency in Hz. + + Uses the equal temperament tuning system where each semitone is separated + by a factor of 2^(1/12). + + Args: + pitch: The MIDI pitch number (24-127, where 69 is typically A4). + a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz. + a4_pitch: The MIDI pitch number for A4. Defaults to 69. + + Returns: + The frequency in Hz corresponding to the given pitch. + + Raises: + TypeError: If pitch is not an integer. + TypeError: If a4_frequency is not a numeric type. + TypeError: If a4_pitch is not an integer. + ValueError: If pitch is outside the range 24-127. + ValueError: If a4_pitch is outside the range 24-127. + ValueError: If a4_frequency is not a positive finite number. + ValueError: If calculated frequency is not a positive finite number. + + Examples: + >>> pitch_to_frequency(69) # A4 + 440.0 + >>> pitch_to_frequency(57) # A3 (one octave below A4) + 220.0 + >>> pitch_to_frequency(60) # Middle C (C4) + 261.6255653005986 + >>> pitch_to_frequency(69, a4_frequency=432.0) # A4 with different tuning + 432.0 + """ + validate_pitch(pitch) + validate_pitch(a4_pitch) + validate_frequency(a4_frequency) + + frequency: float = a4_frequency * (2 ** ((pitch - a4_pitch) / OCTAVE_SEMITONES)) + validate_frequency(frequency) + return frequency + + +def frequency_to_pitch( + frequency: float, + a4_frequency: float = A4_FREQUENCY, + a4_pitch: int = A4_PITCH, +) -> int: + """ + Converts a frequency in Hz to the nearest MIDI-style pitch value. + + Uses logarithmic conversion based on the equal temperament tuning system. + Returns 0 for frequencies at or below 0 Hz. + + Args: + frequency: The frequency in Hz to convert. + a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz. + a4_pitch: The MIDI pitch number for A4. Defaults to 69. + + Returns: + The nearest integer MIDI pitch number. + + Raises: + TypeError: If frequency is not a numeric type. + TypeError: If a4_frequency is not a numeric type. + TypeError: If a4_pitch is not an integer. + ValueError: If frequency is not a positive finite number. + ValueError: If a4_frequency is not a positive finite number. + ValueError: If calculated pitch is outside the range 24-127. + + Examples: + >>> frequency_to_pitch(440.0) # A4 + 69 + >>> frequency_to_pitch(880.0) # A5 + 81 + >>> frequency_to_pitch(261.63) # ~middle C + 60 + >>> frequency_to_pitch(0.0) # invalid frequency + Traceback (most recent call last): + ... + ValueError: Frequency must be a positive value + """ + validate_frequency(frequency) + validate_frequency(a4_frequency) + validate_pitch(a4_pitch) + + pitch: int = round(a4_pitch + OCTAVE_SEMITONES * (np.log2(frequency / a4_frequency))) + validate_pitch(pitch) + return pitch diff --git a/src/sampletones_synthesis/frequency.py b/src/sampletones_synthesis/frequency.py index 36f46d085..9ab12a4df 100644 --- a/src/sampletones_synthesis/frequency.py +++ b/src/sampletones_synthesis/frequency.py @@ -2,8 +2,8 @@ from pydantic import BeforeValidator, Field, StrictFloat, StrictInt -from sampletones_core.constants.general import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH -from sampletones_core.utils.frequencies import pitch_to_frequency +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH +from sampletones_shared.utils.frequencies import pitch_to_frequency def _require_hertz(value: Any) -> Any: diff --git a/tests/suite/player.py b/tests/suite/player.py index b63533c66..d78a92067 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -3,7 +3,6 @@ from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH from sampletones_core.instructions import PulseInstruction from sampletones_core.timers.arithmetic import frequency_to_timer -from sampletones_core.utils.frequencies import pitch_to_frequency from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters @@ -20,6 +19,7 @@ TRIANGLE_SILENT_RELOAD, TRIANGLE_SOUNDING_RELOAD, ) +from sampletones_shared.utils.frequencies import pitch_to_frequency PLAYER_REFERENCE_TIMER: Final[int] = 0x154 PLAYER_OCTAVE_UP_TIMER: Final[int] = PLAYER_REFERENCE_TIMER // 2 diff --git a/tests/suite/source.py b/tests/suite/source.py index 003626b48..86a038f75 100644 --- a/tests/suite/source.py +++ b/tests/suite/source.py @@ -1,8 +1,10 @@ import ast +from pathlib import Path from textwrap import dedent -from typing import Iterable +from typing import Iterable, Set from sampletones_shared.meta.source.bindings.scopes import Scope +from sampletones_shared.meta.source.modules import source_paths def parse_source(source: str) -> ast.Module: @@ -28,3 +30,36 @@ def scope_named(scopes: Iterable[Scope], name: str) -> Scope: return scope raise AssertionError(f"the scopes hold no function named {name}") + + +def write_module(directory: Path, name: str, body: str) -> Path: + """Writes one module into a tree a test builds, opening the directories it sits under. + + Args: + directory: Directory the module belongs in. + name: File name to write it under. + body: Source the module holds. + + Returns: + Path: Where the module was written. + """ + directory.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text(body, encoding="utf-8") + return path + + +def swept_paths(root: Path) -> Set[Path]: + """The resolved modules a sweep of one tree reads, the form a rule is held to. + + Args: + root: Tree to sweep. + + Returns: + Set[Path]: Every visible module under the tree, resolved. + + Raises: + NotADirectoryError: If the root names no directory. + FileNotFoundError: If the root holds no module to read. + """ + return {path.resolve() for path in source_paths([root])} diff --git a/tests/unit/sampletones_core/utils/test_frequencies.py b/tests/unit/sampletones_core/utils/test_frequencies.py index 8f41a2cda..50ffc17c1 100644 --- a/tests/unit/sampletones_core/utils/test_frequencies.py +++ b/tests/unit/sampletones_core/utils/test_frequencies.py @@ -1,242 +1,26 @@ from dataclasses import dataclass from typing import Any, Type, Union -import numpy as np import pytest -from sampletones_core.constants.general import ( - LIMIT_MAX_PITCH, - LIMIT_MIN_PITCH, - MAX_PERIOD, - MAX_PITCH, - MIN_PITCH, -) +from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH from sampletones_core.utils.frequencies import ( clamp_period, clamp_pitch, - frequency_to_pitch, is_pitch_valid, period_to_name, - pitch_to_frequency, pitch_to_name, sanitize, sanitize_period, sanitize_pitch, - validate_frequency, validate_period, - validate_pitch, ) +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error -class TestValidatePitch(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - expected: Union[None, Type[Exception]] - pitch: Any - - test_cases = ( - TestCase( - pitch=LIMIT_MIN_PITCH, - expected=None, - label="exactly_min_limit", - ), - TestCase( - pitch=LIMIT_MAX_PITCH, - expected=None, - label="exactly_max_limit", - ), - TestCase( - pitch=69, - expected=None, - label="middle_valid_pitch", - ), - TestCase( - pitch=60, - expected=None, - label="another_valid_pitch", - ), - TestCase( - pitch=(LIMIT_MIN_PITCH + LIMIT_MAX_PITCH) // 2, - expected=None, - label="middle_of_range", - ), - TestCase( - pitch=LIMIT_MIN_PITCH - 1, - expected=ValueError, - label="one_below_min", - ), - TestCase( - pitch=LIMIT_MAX_PITCH + 1, - expected=ValueError, - label="one_above_max", - ), - TestCase( - pitch=0, - expected=ValueError, - label="zero", - ), - TestCase( - pitch=-100, - expected=ValueError, - label="large_negative", - ), - TestCase( - pitch=200, - expected=ValueError, - label="large_positive", - ), - TestCase( - pitch="60", - expected=TypeError, - label="pitch_string", - ), - TestCase( - pitch=None, - expected=TypeError, - label="pitch_none", - ), - TestCase( - pitch=60.5, - expected=TypeError, - label="pitch_float", - ), - TestCase( - pitch=[60], - expected=TypeError, - label="pitch_list", - ), - TestCase( - pitch={"pitch": 60}, - expected=TypeError, - label="pitch_dict", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_validate_pitch(self, test_case: TestCase) -> None: - if expect_error(validate_pitch, test_case.expected, test_case.pitch): - return - - validate_pitch(test_case.pitch) - - -class TestValidateFrequency: - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - expected: Union[None, Type[Exception]] - frequency: Any - - test_cases = ( - TestCase( - frequency=440.0, - expected=None, - label="valid_float", - ), - TestCase( - frequency=440, - expected=None, - label="valid_int", - ), - TestCase( - frequency=1.0, - expected=None, - label="one_hz", - ), - TestCase( - frequency=0.001, - expected=None, - label="very_small_positive", - ), - TestCase( - frequency=100000.0, - expected=None, - label="very_large_positive", - ), - TestCase( - frequency=1e-100, - expected=None, - label="extremely_small_positive", - ), - TestCase( - frequency=1e100, - expected=None, - label="extremely_large_positive", - ), - TestCase( - frequency=0.0, - expected=ValueError, - label="zero", - ), - TestCase( - frequency=-1.0, - expected=ValueError, - label="negative", - ), - TestCase( - frequency=-440.0, - expected=ValueError, - label="negative_440", - ), - TestCase( - frequency=np.inf, - expected=ValueError, - label="positive_infinity", - ), - TestCase( - frequency=-np.inf, - expected=ValueError, - label="negative_infinity", - ), - TestCase( - frequency=np.nan, - expected=ValueError, - label="nan", - ), - TestCase( - frequency="440", - expected=TypeError, - label="frequency_string", - ), - TestCase( - frequency=None, - expected=TypeError, - label="frequency_none", - ), - TestCase( - frequency=[440.0], - expected=TypeError, - label="frequency_list", - ), - TestCase( - frequency={"freq": 440.0}, - expected=TypeError, - label="frequency_dict", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_validate_frequency(self, test_case: TestCase) -> None: - if expect_error( - validate_frequency, - test_case.expected, - test_case.frequency, - ): - return - - validate_frequency(test_case.frequency) - - class TestIsPitchValid: def test_is_pitch_valid(self) -> None: valid_pitches = range(MIN_PITCH, MAX_PITCH + 1) @@ -351,464 +135,6 @@ def test_validate_period(self, test_case: TestCase) -> None: validate_period(test_case.period) -class TestPitchToFrequency(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - expected: Union[float, Type[Exception]] - pitch: Any - a4_frequency: Any - a4_pitch: Any - - test_cases = ( - TestCase( - pitch=69, - a4_frequency=440.0, - a4_pitch=69, - expected=440.0, - label="a4_default_tuning", - ), - TestCase( - pitch=81, - a4_frequency=440.0, - a4_pitch=69, - expected=880.0, - label="a5_one_octave_above", - ), - TestCase( - pitch=57, - a4_frequency=440.0, - a4_pitch=69, - expected=220.0, - label="a3_one_octave_below", - ), - TestCase( - pitch=60, - a4_frequency=440.0, - a4_pitch=69, - expected=261.6255653005986, - label="middle_c", - ), - TestCase( - pitch=69, - a4_frequency=432.0, - a4_pitch=69, - expected=432.0, - label="a4_alternative_tuning", - ), - TestCase( - pitch=69, - a4_frequency=440.0, - a4_pitch=69, - expected=440.0, - label="reference_pitch_returns_reference_frequency", - ), - TestCase( - pitch=MIN_PITCH, - a4_frequency=440.0, - a4_pitch=69, - expected=55.0, - label="min_pitch_boundary", - ), - TestCase( - pitch=MAX_PITCH, - a4_frequency=440.0, - a4_pitch=69, - expected=7902.132820097988, - label="max_pitch_boundary", - ), - TestCase( - pitch=0, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="pitch_zero", - ), - TestCase( - pitch=-12, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="negative_pitch", - ), - TestCase( - pitch=127, - a4_frequency=440.0, - a4_pitch=69, - expected=12543.853951415975, - label="max_midi_pitch", - ), - TestCase( - pitch=60, - a4_frequency=432, - a4_pitch=69, - expected=256.86873684058776, - label="middle_c_alternative_tuning", - ), - TestCase( - pitch=60, - a4_frequency=440.0, - a4_pitch=60, - expected=440.0, - label="different_reference_pitch", - ), - TestCase( - pitch=72, - a4_frequency=440.0, - a4_pitch=60, - expected=880.0, - label="octave_above_different_reference", - ), - TestCase( - pitch=60, - a4_frequency=880.0, - a4_pitch=69, - expected=523.2511306011972, - label="double_reference_frequency", - ), - TestCase( - pitch=69, - a4_frequency=220.0, - a4_pitch=69, - expected=220.0, - label="half_reference_frequency", - ), - TestCase( - pitch="not_an_int", - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="pitch_string", - ), - TestCase( - pitch=None, - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="pitch_none", - ), - TestCase( - pitch=[60], - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="pitch_list", - ), - TestCase( - pitch={"pitch": 60}, - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="pitch_dict", - ), - TestCase( - pitch=60, - a4_frequency="440", - a4_pitch=69, - expected=TypeError, - label="a4_frequency_string", - ), - TestCase( - pitch=60, - a4_frequency=None, - a4_pitch=69, - expected=TypeError, - label="a4_frequency_none", - ), - TestCase( - pitch=60, - a4_frequency=[440.0], - a4_pitch=69, - expected=TypeError, - label="a4_frequency_list", - ), - TestCase( - pitch=60, - a4_frequency=440.0, - a4_pitch="69", - expected=TypeError, - label="a4_pitch_string", - ), - TestCase( - pitch=60, - a4_frequency=440.0, - a4_pitch=None, - expected=TypeError, - label="a4_pitch_none", - ), - TestCase( - pitch=60, - a4_frequency=440.0, - a4_pitch=[69], - expected=TypeError, - label="a4_pitch_list", - ), - TestCase( - pitch=60, - a4_frequency=np.inf, - a4_pitch=69, - expected=ValueError, - label="a4_frequency_inf", - ), - TestCase( - pitch=60, - a4_frequency=-440.0, - a4_pitch=69, - expected=ValueError, - label="a4_frequency_negative", - ), - TestCase( - pitch=60, - a4_frequency=0.0, - a4_pitch=69, - expected=ValueError, - label="a4_frequency_zero", - ), - TestCase( - pitch=60, - a4_frequency=np.nan, - a4_pitch=69, - expected=ValueError, - label="a4_frequency_nan", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_pitch_to_frequency(self, test_case: TestCase) -> None: - if expect_error( - pitch_to_frequency, - test_case.expected, - test_case.pitch, - test_case.a4_frequency, - test_case.a4_pitch, - ): - return - - result = pitch_to_frequency( - test_case.pitch, - test_case.a4_frequency, - test_case.a4_pitch, - ) - if isinstance(test_case.expected, float) and np.isnan(test_case.expected): - assert np.isnan(result) - else: - assert result == pytest.approx(test_case.expected, rel=1e-9) - assert isinstance(result, float) - - -class TestFrequencyToPitch(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - expected: Union[int, Type[Exception]] - frequency: Any - a4_frequency: Any - a4_pitch: Any - - test_cases = ( - TestCase( - frequency=440.0, - a4_frequency=440.0, - a4_pitch=69, - expected=69, - label="a4_frequency", - ), - TestCase( - frequency=880.0, - a4_frequency=440.0, - a4_pitch=69, - expected=81, - label="a5_frequency", - ), - TestCase( - frequency=261.63, - a4_frequency=440.0, - a4_pitch=69, - expected=60, - label="middle_c_approximate", - ), - TestCase( - frequency=0.0, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="zero_frequency", - ), - TestCase( - frequency=-100.0, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="negative_frequency", - ), - TestCase( - frequency=220.0, - a4_frequency=440.0, - a4_pitch=69, - expected=57, - label="a3_frequency", - ), - TestCase( - frequency=440.0, - a4_frequency=440.0, - a4_pitch=69, - expected=69, - label="reference_frequency_returns_reference_pitch", - ), - TestCase( - frequency=55.0, - a4_frequency=440.0, - a4_pitch=69, - expected=MIN_PITCH, - label="min_frequency_boundary", - ), - TestCase( - frequency=1e-10, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="very_small_positive_frequency", - ), - TestCase( - frequency=100000.0, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="very_high_frequency", - ), - TestCase( - frequency=432, - a4_frequency=432, - a4_pitch=69, - expected=69, - label="alternative_tuning", - ), - TestCase( - frequency=440.0, - a4_frequency=432, - a4_pitch=69, - expected=69, - label="different_reference_frequency", - ), - TestCase( - frequency=440, - a4_frequency=440.0, - a4_pitch=60, - expected=60, - label="different_reference_pitch", - ), - TestCase( - frequency=880.0, - a4_frequency=220.0, - a4_pitch=69, - expected=93, - label="quadruple_reference_frequency", - ), - TestCase( - frequency="440", - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="frequency_string", - ), - TestCase( - frequency=None, - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="frequency_none", - ), - TestCase( - frequency=[440.0], - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="frequency_list", - ), - TestCase( - frequency={"freq": 440.0}, - a4_frequency=440.0, - a4_pitch=69, - expected=TypeError, - label="frequency_dict", - ), - TestCase( - frequency=440.0, - a4_frequency="440", - a4_pitch=69, - expected=TypeError, - label="a4_frequency_string", - ), - TestCase( - frequency=440.0, - a4_frequency=None, - a4_pitch=69, - expected=TypeError, - label="a4_frequency_none", - ), - TestCase( - frequency=440.0, - a4_frequency=440.0, - a4_pitch="69", - expected=TypeError, - label="a4_pitch_string", - ), - TestCase( - frequency=440.0, - a4_frequency=440.0, - a4_pitch=None, - expected=TypeError, - label="a4_pitch_none", - ), - TestCase( - frequency=np.inf, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="frequency_inf", - ), - TestCase( - frequency=-np.inf, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="frequency_negative_inf", - ), - TestCase( - frequency=np.nan, - a4_frequency=440.0, - a4_pitch=69, - expected=ValueError, - label="frequency_nan", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_frequency_to_pitch(self, test_case: TestCase) -> None: - if expect_error( - frequency_to_pitch, - test_case.expected, - test_case.frequency, - test_case.a4_frequency, - test_case.a4_pitch, - ): - return - - result = frequency_to_pitch( - test_case.frequency, - test_case.a4_frequency, - test_case.a4_pitch, - ) - assert result == test_case.expected - assert isinstance(result, int) - - class TestPitchToName(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): diff --git a/tests/unit/sampletones_shared/meta/import_boundary/__init__.py b/tests/unit/sampletones_shared/meta/import_boundary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_check.py b/tests/unit/sampletones_shared/meta/import_boundary/test_check.py new file mode 100644 index 000000000..38fd4a544 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_check.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from sampletones_shared.meta.import_boundary.check import check_boundaries +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.token import TokenRule +from tests.suite.source import write_module + +FORBIDDEN: Final[str] = "from other_package.module import Thing\n" +ALLOWED: Final[str] = "from package.inner import Helper\n" +SPELLING: Final[str] = "dpg.add_group(parent=SUF_PANEL_LEFT)\n" + +RULES: Final[Tuple[BoundaryRule, ...]] = ( + BoundaryRule("package", "logic/**/*.py", ("other_package",)), + BoundaryRule("package", "nested/**/*.py", ("other_package",), excluding=("nested/inner/**/*.py",)), +) + +TOKEN_RULES: Final[Tuple[TokenRule, ...]] = ( + TokenRule("package", "ui/**/*.py", r"\bSUF_PANEL_", "ui stays clear of a column suffix"), +) + + +class TestCheckBoundaries: + """Every rule read over one tree, from the sweep to the report.""" + + def test_a_forbidden_import_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / "package" / "logic", "direct.py", FORBIDDEN) + + violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, None) + + assert [violation.kind for violation in violations] == ["other_package"] + + def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None: + write_module(tmp_path / "package" / "logic", "direct.py", ALLOWED) + + assert check_boundaries(tmp_path, RULES, TOKEN_RULES, None) == [] + + def test_a_module_outside_every_rule_stays_unchecked(self, tmp_path: Path) -> None: + write_module(tmp_path / "package" / "services", "conversion.py", FORBIDDEN) + + assert check_boundaries(tmp_path, RULES, TOKEN_RULES, None) == [] + + def test_a_module_a_nested_rule_owns_is_left_to_it(self, tmp_path: Path) -> None: + write_module(tmp_path / "package" / "nested" / "inner", "deep.py", FORBIDDEN) + + assert check_boundaries(tmp_path, RULES, TOKEN_RULES, None) == [] + + def test_a_forbidden_spelling_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / "package" / "ui", "left.py", SPELLING) + + violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, None) + + assert [violation.kind for violation in violations] == ["ui stays clear of a column suffix"] + + def test_the_boundary_rules_are_reported_before_the_token_rules(self, tmp_path: Path) -> None: + write_module(tmp_path / "package" / "ui", "left.py", SPELLING) + write_module(tmp_path / "package" / "logic", "direct.py", FORBIDDEN) + + violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, None) + + assert [violation.kind for violation in violations] == [ + "other_package", + "ui stays clear of a column suffix", + ] + + def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None: + checked = write_module(tmp_path / "package" / "logic", "checked.py", FORBIDDEN) + write_module(tmp_path / "package" / "logic", "other.py", FORBIDDEN) + + violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, {checked.resolve()}) + + assert len(violations) == 1 + + +class TestSweptRoots: + """A root the sweep reads nothing under stops the check, where it would report a clean tree.""" + + def test_a_root_holding_no_module_stops_the_check(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + check_boundaries(tmp_path, RULES, TOKEN_RULES, None) + + def test_an_absent_root_stops_the_check(self, tmp_path: Path) -> None: + with pytest.raises(NotADirectoryError): + check_boundaries(tmp_path / "absent", RULES, TOKEN_RULES, None) diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py b/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py new file mode 100644 index 000000000..395e53da5 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py @@ -0,0 +1,37 @@ +from typing import Final + +from sampletones_shared.meta.import_boundary.graph import LayerGraph + +GRAPH: Final[LayerGraph] = LayerGraph( + root="package", + package="package", + layers={"low": (), "high": ("low",), "high/nested": ("low",)}, +) + + +class TestLayerRules: + """A graph states what a unit may import, and the rule the check runs is what remains.""" + + def test_one_rule_stands_for_each_declared_unit(self) -> None: + assert len(GRAPH.rules()) == len(GRAPH.layers) + + def test_a_unit_forbids_the_units_its_layers_leave_out(self) -> None: + low, _, _ = GRAPH.rules() + assert low.forbidden == ("package.high", "package.high.nested") + + def test_a_unit_stays_free_of_the_units_it_may_import(self) -> None: + _, high, _ = GRAPH.rules() + assert "package.low" not in high.forbidden + + def test_a_unit_never_forbids_itself(self) -> None: + assert all(rule.pattern.split("/")[0] not in rule.forbidden for rule in GRAPH.rules()) + + def test_a_nested_unit_is_left_out_of_the_unit_around_it(self) -> None: + _, high, _ = GRAPH.rules() + assert high.excluding == ("high/nested/**/*.py",) + + def test_every_rule_is_written_against_the_graphs_root(self) -> None: + assert all(rule.root == "package" for rule in GRAPH.rules()) + + def test_a_rule_names_its_unit_by_the_glob_the_unit_holds(self) -> None: + assert [rule.pattern for rule in GRAPH.rules()] == ["low/**/*.py", "high/**/*.py", "high/nested/**/*.py"] diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_imports.py b/tests/unit/sampletones_shared/meta/import_boundary/test_imports.py new file mode 100644 index 000000000..681e2c086 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_imports.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_shared.meta.import_boundary.imports import imported_module, matches_prefix +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestImportedModule(BaseTestSuite): + """The module one line of source imports, whichever spelling the line uses.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Optional[str] + line: str + name: str + + @property + def label(self) -> str: + return self.name + + test_cases: Tuple[TestCase, ...] = ( + TestCase(name="plain-import", line="import numpy", expected="numpy"), + TestCase(name="aliased-import", line="import numpy as np", expected="numpy"), + TestCase(name="dotted-import", line="import dearpygui.dearpygui as dpg", expected="dearpygui.dearpygui"), + TestCase(name="from-import", line="from package.module import Thing", expected="package.module"), + TestCase(name="indented-import", line=" import numpy", expected="numpy"), + TestCase(name="assignment", line="imported = 1", expected=None), + TestCase(name="comment", line="# import numpy", expected=None), + TestCase(name="blank", line="", expected=None), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_line_names_the_module_it_imports(self, test_case: TestCase) -> None: + assert imported_module(test_case.line) == test_case.expected + + +class TestMatchesPrefix: + """A prefix names the module itself and everything underneath it, and nothing beside it.""" + + def test_the_prefix_itself_matches(self) -> None: + assert matches_prefix("package.module", "package.module") + + def test_a_module_underneath_matches(self) -> None: + assert matches_prefix("package.module.inner", "package.module") + + def test_a_module_beside_it_stays_clear(self) -> None: + assert not matches_prefix("package.modules", "package.module") + + def test_a_module_above_it_stays_clear(self) -> None: + assert not matches_prefix("package", "package.module") diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_rule.py b/tests/unit/sampletones_shared/meta/import_boundary/test_rule.py new file mode 100644 index 000000000..a6058a404 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_rule.py @@ -0,0 +1,63 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from tests.suite.source import write_module + +FORBIDDEN: Final[str] = "from other_package.module import Thing\n" +CONTRACT: Final[str] = "from other_package.contract import Result\n" +ALLOWED: Final[str] = "from package.inner import Helper\n" + +RULE: Final[BoundaryRule] = BoundaryRule( + root="package", + pattern="logic/**/*.py", + forbidden=("other_package", "third_package"), + contracts=("other_package.contract",), +) + + +class TestBoundaryViolations: + """The imports one module takes past the boundary around it.""" + + def test_a_forbidden_import_is_named_by_the_prefix_it_crosses(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "direct.py", FORBIDDEN) + + assert [violation.kind for violation in RULE.violations(path)] == ["other_package"] + + def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "direct.py", f"{ALLOWED}{FORBIDDEN}") + + assert RULE.violations(path)[0].location == f"{path}:2: {FORBIDDEN.strip()}" + + def test_a_contract_module_stays_reachable(self, tmp_path: Path) -> None: + """A layer reads another layer's data contract while its implementation stays out of reach.""" + path = write_module(tmp_path, "direct.py", CONTRACT) + + assert RULE.violations(path) == [] + + def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "direct.py", ALLOWED) + + assert RULE.violations(path) == [] + + def test_a_module_importing_nothing_reports_nothing(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "direct.py", "VALUE = 1\n") + + assert RULE.violations(path) == [] + + def test_one_import_is_reported_once(self, tmp_path: Path) -> None: + """An import crosses one boundary, so the first prefix it matches names it.""" + rule = BoundaryRule( + root=RULE.root, + pattern=RULE.pattern, + forbidden=("other_package", "other_package.module"), + ) + path = write_module(tmp_path, "direct.py", FORBIDDEN) + + assert len(rule.violations(path)) == 1 + + def test_every_forbidden_import_is_reported_in_line_order(self, tmp_path: Path) -> None: + body = f"{FORBIDDEN}{ALLOWED}from third_package.module import Other\n" + path = write_module(tmp_path, "direct.py", body) + + assert [violation.kind for violation in RULE.violations(path)] == ["other_package", "third_package"] diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_scope.py b/tests/unit/sampletones_shared/meta/import_boundary/test_scope.py new file mode 100644 index 000000000..92050e731 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_scope.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.meta.import_boundary.scope import rule_modules +from tests.suite.source import swept_paths, write_module + +LOGIC_PATTERN: Final[str] = "logic/**/*.py" +BODY: Final[str] = "from package.inner import Helper\n" + + +class TestRuleModules: + """The modules one glob reaches, held to the tree the sweep reads.""" + + def test_a_module_directly_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None: + """`logic/**/*.py` names `logic/direct.py` as surely as `logic/inner/deep.py`.""" + direct = write_module(tmp_path / "logic", "direct.py", BODY) + + assert rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) == [direct.resolve()] + + def test_a_module_nested_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None: + deep = write_module(tmp_path / "logic" / "inner", "deep.py", BODY) + + assert rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) == [deep.resolve()] + + def test_a_module_outside_the_rule_directory_stays_aside(self, tmp_path: Path) -> None: + write_module(tmp_path / "services", "conversion.py", BODY) + + assert rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) == [] + + def test_a_module_a_nested_rule_owns_is_left_to_it(self, tmp_path: Path) -> None: + direct = write_module(tmp_path / "logic", "direct.py", BODY) + write_module(tmp_path / "logic" / "inner", "deep.py", BODY) + + reached = rule_modules(tmp_path, LOGIC_PATTERN, ("logic/inner/**/*.py",), swept_paths(tmp_path), None) + + assert reached == [direct.resolve()] + + def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path) -> None: + named = write_module(tmp_path / "logic", "named.py", BODY) + write_module(tmp_path / "logic", "other.py", BODY) + + reached = rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), {named.resolve()}) + + assert reached == [named.resolve()] + + def test_the_modules_are_reported_in_path_order(self, tmp_path: Path) -> None: + second = write_module(tmp_path / "logic", "second.py", BODY) + first = write_module(tmp_path / "logic", "first.py", BODY) + + reached = rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) + + assert reached == [first.resolve(), second.resolve()] diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_token.py b/tests/unit/sampletones_shared/meta/import_boundary/test_token.py new file mode 100644 index 000000000..ad19063bc --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_token.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.meta.import_boundary.token import TokenRule +from tests.suite.source import write_module + +MESSAGE: Final[str] = "a panel receives its parent through create_panel(parent)" + +RULE: Final[TokenRule] = TokenRule( + root="package", + pattern="ui/**/*.py", + forbidden=r"\bSUF_PANEL_", + message=MESSAGE, +) + + +class TestTokenViolations: + """The lines of one module that write a spelling the rule keeps out.""" + + def test_the_forbidden_spelling_is_reported_with_the_rules_message(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "left.py", "dpg.add_group(parent=SUF_PANEL_LEFT)\n") + + assert [violation.kind for violation in RULE.violations(path)] == [MESSAGE] + + def test_the_report_names_the_line_the_spelling_sits_on(self, tmp_path: Path) -> None: + line = "dpg.add_group(parent=SUF_PANEL_LEFT)" + path = write_module(tmp_path, "left.py", f"VALUE = 1\n{line}\n") + + assert RULE.violations(path)[0].location == f"{path}:2: {line}" + + def test_a_module_clear_of_the_spelling_reports_nothing(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "left.py", "dpg.add_group(parent=parent)\n") + + assert RULE.violations(path) == [] + + def test_every_line_writing_the_spelling_is_reported(self, tmp_path: Path) -> None: + body = "first = SUF_PANEL_LEFT\nsecond = 1\nthird = SUF_PANEL_RIGHT\n" + path = write_module(tmp_path, "left.py", body) + + assert len(RULE.violations(path)) == 2 diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_units.py b/tests/unit/sampletones_shared/meta/import_boundary/test_units.py new file mode 100644 index 000000000..d824a4adf --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_units.py @@ -0,0 +1,32 @@ +from sampletones_shared.meta.import_boundary.units import nested_globs, unit_glob, unit_prefix + +PLAYER = "sampletones_player" + + +class TestUnitGlobs: + """A unit names either one module or a directory of them, and reads as both.""" + + def test_a_directory_unit_reaches_every_module_below_it(self) -> None: + assert unit_glob("driver") == "driver/**/*.py" + + def test_a_module_unit_names_itself(self) -> None: + assert unit_glob("song.py") == "song.py" + + def test_a_nested_unit_is_named_as_the_glob_it_owns(self) -> None: + assert nested_globs("driver", ("driver", "driver/assembler", "clock")) == ("driver/assembler/**/*.py",) + + def test_a_unit_beside_another_is_left_to_itself(self) -> None: + assert nested_globs("clock", ("driver", "driver/assembler", "clock")) == () + + +class TestUnitPrefixes: + """The dotted prefix an import of a unit begins with.""" + + def test_a_unit_under_a_package_is_reached_by_a_dotted_prefix(self) -> None: + assert unit_prefix(PLAYER, "driver/assembler") == "sampletones_player.driver.assembler" + + def test_a_module_unit_drops_its_suffix(self) -> None: + assert unit_prefix(PLAYER, "song.py") == "sampletones_player.song" + + def test_a_package_unit_is_reached_by_its_own_name(self) -> None: + assert unit_prefix("", "sampletones_core") == "sampletones_core" diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py b/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py new file mode 100644 index 000000000..c4a608627 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from sampletones_shared.meta.import_boundary.violation import Violation + + +class TestViolationLocation: + """Where a report sends a reader to see what a rule caught.""" + + def test_the_location_reads_as_path_and_line(self) -> None: + violation = Violation.at("other_package", Path("package/logic/direct.py"), 2, "import other_package") + + assert violation.location == "package/logic/direct.py:2: import other_package" + + def test_the_quoted_line_stands_clear_of_its_indentation(self) -> None: + violation = Violation.at("other_package", Path("direct.py"), 1, " import other_package") + + assert violation.location.endswith(": import other_package") + + def test_the_kind_names_what_the_rule_forbids(self) -> None: + assert Violation.at("other_package", Path("direct.py"), 1, "import other_package").kind == "other_package" diff --git a/tests/unit/sampletones_shared/utils/test_frequencies.py b/tests/unit/sampletones_shared/utils/test_frequencies.py new file mode 100644 index 000000000..0ed2d26ea --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_frequencies.py @@ -0,0 +1,680 @@ +from dataclasses import dataclass +from typing import Any, Type, Union + +import numpy as np +import pytest + +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH +from sampletones_shared.utils.frequencies import ( + frequency_to_pitch, + pitch_to_frequency, + validate_frequency, + validate_pitch, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.errors import expect_error + + +class TestValidatePitch(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[None, Type[Exception]] + pitch: Any + + test_cases = ( + TestCase( + pitch=LIMIT_MIN_PITCH, + expected=None, + label="exactly_min_limit", + ), + TestCase( + pitch=LIMIT_MAX_PITCH, + expected=None, + label="exactly_max_limit", + ), + TestCase( + pitch=69, + expected=None, + label="middle_valid_pitch", + ), + TestCase( + pitch=60, + expected=None, + label="another_valid_pitch", + ), + TestCase( + pitch=(LIMIT_MIN_PITCH + LIMIT_MAX_PITCH) // 2, + expected=None, + label="middle_of_range", + ), + TestCase( + pitch=LIMIT_MIN_PITCH - 1, + expected=ValueError, + label="one_below_min", + ), + TestCase( + pitch=LIMIT_MAX_PITCH + 1, + expected=ValueError, + label="one_above_max", + ), + TestCase( + pitch=0, + expected=ValueError, + label="zero", + ), + TestCase( + pitch=-100, + expected=ValueError, + label="large_negative", + ), + TestCase( + pitch=200, + expected=ValueError, + label="large_positive", + ), + TestCase( + pitch="60", + expected=TypeError, + label="pitch_string", + ), + TestCase( + pitch=None, + expected=TypeError, + label="pitch_none", + ), + TestCase( + pitch=60.5, + expected=TypeError, + label="pitch_float", + ), + TestCase( + pitch=[60], + expected=TypeError, + label="pitch_list", + ), + TestCase( + pitch={"pitch": 60}, + expected=TypeError, + label="pitch_dict", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_validate_pitch(self, test_case: TestCase) -> None: + if expect_error(validate_pitch, test_case.expected, test_case.pitch): + return + + validate_pitch(test_case.pitch) + + +class TestValidateFrequency: + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[None, Type[Exception]] + frequency: Any + + test_cases = ( + TestCase( + frequency=440.0, + expected=None, + label="valid_float", + ), + TestCase( + frequency=440, + expected=None, + label="valid_int", + ), + TestCase( + frequency=1.0, + expected=None, + label="one_hz", + ), + TestCase( + frequency=0.001, + expected=None, + label="very_small_positive", + ), + TestCase( + frequency=100000.0, + expected=None, + label="very_large_positive", + ), + TestCase( + frequency=1e-100, + expected=None, + label="extremely_small_positive", + ), + TestCase( + frequency=1e100, + expected=None, + label="extremely_large_positive", + ), + TestCase( + frequency=0.0, + expected=ValueError, + label="zero", + ), + TestCase( + frequency=-1.0, + expected=ValueError, + label="negative", + ), + TestCase( + frequency=-440.0, + expected=ValueError, + label="negative_440", + ), + TestCase( + frequency=np.inf, + expected=ValueError, + label="positive_infinity", + ), + TestCase( + frequency=-np.inf, + expected=ValueError, + label="negative_infinity", + ), + TestCase( + frequency=np.nan, + expected=ValueError, + label="nan", + ), + TestCase( + frequency="440", + expected=TypeError, + label="frequency_string", + ), + TestCase( + frequency=None, + expected=TypeError, + label="frequency_none", + ), + TestCase( + frequency=[440.0], + expected=TypeError, + label="frequency_list", + ), + TestCase( + frequency={"freq": 440.0}, + expected=TypeError, + label="frequency_dict", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_validate_frequency(self, test_case: TestCase) -> None: + if expect_error( + validate_frequency, + test_case.expected, + test_case.frequency, + ): + return + + validate_frequency(test_case.frequency) + + +class TestPitchToFrequency(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[float, Type[Exception]] + pitch: Any + a4_frequency: Any + a4_pitch: Any + + test_cases = ( + TestCase( + pitch=69, + a4_frequency=440.0, + a4_pitch=69, + expected=440.0, + label="a4_default_tuning", + ), + TestCase( + pitch=81, + a4_frequency=440.0, + a4_pitch=69, + expected=880.0, + label="a5_one_octave_above", + ), + TestCase( + pitch=57, + a4_frequency=440.0, + a4_pitch=69, + expected=220.0, + label="a3_one_octave_below", + ), + TestCase( + pitch=60, + a4_frequency=440.0, + a4_pitch=69, + expected=261.6255653005986, + label="middle_c", + ), + TestCase( + pitch=69, + a4_frequency=432.0, + a4_pitch=69, + expected=432.0, + label="a4_alternative_tuning", + ), + TestCase( + pitch=69, + a4_frequency=440.0, + a4_pitch=69, + expected=440.0, + label="reference_pitch_returns_reference_frequency", + ), + TestCase( + pitch=LIMIT_MIN_PITCH, + a4_frequency=440.0, + a4_pitch=69, + expected=32.70319566257483, + label="min_pitch_boundary", + ), + TestCase( + pitch=LIMIT_MAX_PITCH, + a4_frequency=440.0, + a4_pitch=69, + expected=12543.853951415975, + label="max_pitch_boundary", + ), + TestCase( + pitch=0, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="pitch_zero", + ), + TestCase( + pitch=-12, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="negative_pitch", + ), + TestCase( + pitch=127, + a4_frequency=440.0, + a4_pitch=69, + expected=12543.853951415975, + label="max_midi_pitch", + ), + TestCase( + pitch=60, + a4_frequency=432, + a4_pitch=69, + expected=256.86873684058776, + label="middle_c_alternative_tuning", + ), + TestCase( + pitch=60, + a4_frequency=440.0, + a4_pitch=60, + expected=440.0, + label="different_reference_pitch", + ), + TestCase( + pitch=72, + a4_frequency=440.0, + a4_pitch=60, + expected=880.0, + label="octave_above_different_reference", + ), + TestCase( + pitch=60, + a4_frequency=880.0, + a4_pitch=69, + expected=523.2511306011972, + label="double_reference_frequency", + ), + TestCase( + pitch=69, + a4_frequency=220.0, + a4_pitch=69, + expected=220.0, + label="half_reference_frequency", + ), + TestCase( + pitch="not_an_int", + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="pitch_string", + ), + TestCase( + pitch=None, + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="pitch_none", + ), + TestCase( + pitch=[60], + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="pitch_list", + ), + TestCase( + pitch={"pitch": 60}, + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="pitch_dict", + ), + TestCase( + pitch=60, + a4_frequency="440", + a4_pitch=69, + expected=TypeError, + label="a4_frequency_string", + ), + TestCase( + pitch=60, + a4_frequency=None, + a4_pitch=69, + expected=TypeError, + label="a4_frequency_none", + ), + TestCase( + pitch=60, + a4_frequency=[440.0], + a4_pitch=69, + expected=TypeError, + label="a4_frequency_list", + ), + TestCase( + pitch=60, + a4_frequency=440.0, + a4_pitch="69", + expected=TypeError, + label="a4_pitch_string", + ), + TestCase( + pitch=60, + a4_frequency=440.0, + a4_pitch=None, + expected=TypeError, + label="a4_pitch_none", + ), + TestCase( + pitch=60, + a4_frequency=440.0, + a4_pitch=[69], + expected=TypeError, + label="a4_pitch_list", + ), + TestCase( + pitch=60, + a4_frequency=np.inf, + a4_pitch=69, + expected=ValueError, + label="a4_frequency_inf", + ), + TestCase( + pitch=60, + a4_frequency=-440.0, + a4_pitch=69, + expected=ValueError, + label="a4_frequency_negative", + ), + TestCase( + pitch=60, + a4_frequency=0.0, + a4_pitch=69, + expected=ValueError, + label="a4_frequency_zero", + ), + TestCase( + pitch=60, + a4_frequency=np.nan, + a4_pitch=69, + expected=ValueError, + label="a4_frequency_nan", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_pitch_to_frequency(self, test_case: TestCase) -> None: + if expect_error( + pitch_to_frequency, + test_case.expected, + test_case.pitch, + test_case.a4_frequency, + test_case.a4_pitch, + ): + return + + result = pitch_to_frequency( + test_case.pitch, + test_case.a4_frequency, + test_case.a4_pitch, + ) + if isinstance(test_case.expected, float) and np.isnan(test_case.expected): + assert np.isnan(result) + else: + assert result == pytest.approx(test_case.expected, rel=1e-9) + assert isinstance(result, float) + + +class TestFrequencyToPitch(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[int, Type[Exception]] + frequency: Any + a4_frequency: Any + a4_pitch: Any + + test_cases = ( + TestCase( + frequency=440.0, + a4_frequency=440.0, + a4_pitch=69, + expected=69, + label="a4_frequency", + ), + TestCase( + frequency=880.0, + a4_frequency=440.0, + a4_pitch=69, + expected=81, + label="a5_frequency", + ), + TestCase( + frequency=261.63, + a4_frequency=440.0, + a4_pitch=69, + expected=60, + label="middle_c_approximate", + ), + TestCase( + frequency=0.0, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="zero_frequency", + ), + TestCase( + frequency=-100.0, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="negative_frequency", + ), + TestCase( + frequency=220.0, + a4_frequency=440.0, + a4_pitch=69, + expected=57, + label="a3_frequency", + ), + TestCase( + frequency=440.0, + a4_frequency=440.0, + a4_pitch=69, + expected=69, + label="reference_frequency_returns_reference_pitch", + ), + TestCase( + frequency=32.70319566257483, + a4_frequency=440.0, + a4_pitch=69, + expected=LIMIT_MIN_PITCH, + label="min_frequency_boundary", + ), + TestCase( + frequency=1e-10, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="very_small_positive_frequency", + ), + TestCase( + frequency=100000.0, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="very_high_frequency", + ), + TestCase( + frequency=432, + a4_frequency=432, + a4_pitch=69, + expected=69, + label="alternative_tuning", + ), + TestCase( + frequency=440.0, + a4_frequency=432, + a4_pitch=69, + expected=69, + label="different_reference_frequency", + ), + TestCase( + frequency=440, + a4_frequency=440.0, + a4_pitch=60, + expected=60, + label="different_reference_pitch", + ), + TestCase( + frequency=880.0, + a4_frequency=220.0, + a4_pitch=69, + expected=93, + label="quadruple_reference_frequency", + ), + TestCase( + frequency="440", + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="frequency_string", + ), + TestCase( + frequency=None, + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="frequency_none", + ), + TestCase( + frequency=[440.0], + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="frequency_list", + ), + TestCase( + frequency={"freq": 440.0}, + a4_frequency=440.0, + a4_pitch=69, + expected=TypeError, + label="frequency_dict", + ), + TestCase( + frequency=440.0, + a4_frequency="440", + a4_pitch=69, + expected=TypeError, + label="a4_frequency_string", + ), + TestCase( + frequency=440.0, + a4_frequency=None, + a4_pitch=69, + expected=TypeError, + label="a4_frequency_none", + ), + TestCase( + frequency=440.0, + a4_frequency=440.0, + a4_pitch="69", + expected=TypeError, + label="a4_pitch_string", + ), + TestCase( + frequency=440.0, + a4_frequency=440.0, + a4_pitch=None, + expected=TypeError, + label="a4_pitch_none", + ), + TestCase( + frequency=np.inf, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="frequency_inf", + ), + TestCase( + frequency=-np.inf, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="frequency_negative_inf", + ), + TestCase( + frequency=np.nan, + a4_frequency=440.0, + a4_pitch=69, + expected=ValueError, + label="frequency_nan", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_frequency_to_pitch(self, test_case: TestCase) -> None: + if expect_error( + frequency_to_pitch, + test_case.expected, + test_case.frequency, + test_case.a4_frequency, + test_case.a4_pitch, + ): + return + + result = frequency_to_pitch( + test_case.frequency, + test_case.a4_frequency, + test_case.a4_pitch, + ) + assert result == test_case.expected + assert isinstance(result, int) diff --git a/tests/unit/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py index 25e53947c..10ff6256d 100644 --- a/tests/unit/scripts/checks/test_import_boundary.py +++ b/tests/unit/scripts/checks/test_import_boundary.py @@ -4,45 +4,33 @@ import pytest -from sampletones_shared.meta.source.modules import source_paths +from sampletones_shared.meta.import_boundary.check import check_boundaries +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.scope import rule_modules from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.scripts import load_script +from tests.suite.source import swept_paths, write_module check_import_boundary = load_script("checks/import_boundary.py") APPLICATION: Final[str] = "sampletones_application" +CORE: Final[str] = "sampletones_core" PLAYER: Final[str] = "sampletones_player" -LOGIC_RULE: Final[str] = "logic/**/*.py" +ASSEMBLER: Final[str] = "sampletones_player.driver.assembler" -FORBIDDEN_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n" +VISUAL_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n" CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n" PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n" PLAYER_IMPORT: Final[str] = "from sampletones_player.song import Song\n" ASSEMBLER_IMPORT: Final[str] = "from sampletones_player.driver.assembler.builder import build_driver\n" +DRIVER_IMPORT: Final[str] = "from sampletones_player.driver.image import DriverImage\n" PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n" -def write_module(directory: Path, name: str, body: str) -> Path: - directory.mkdir(parents=True, exist_ok=True) - path = directory / name - path.write_text(body, encoding="utf-8") - return path - - -def swept(package: Path) -> List[Path]: - return [path.resolve() for path in source_paths([package])] - - -def reached_modules(rule: check_import_boundary.BoundaryRule) -> List[Path]: +def reached_modules(rule: BoundaryRule) -> List[Path]: """The modules a rule of the real source tree applies to.""" root = SOURCE_ROOT / rule.root - return check_import_boundary.rule_modules( - root, - rule.pattern, - rule.excluding, - {path.resolve() for path in source_paths([root])}, - None, - ) + return rule_modules(root, rule.pattern, rule.excluding, swept_paths(root), None) def reaches(layers: Dict[str, Tuple[str, ...]], unit: str, seen: Set[str]) -> Set[str]: @@ -55,53 +43,15 @@ def reaches(layers: Dict[str, Tuple[str, ...]], unit: str, seen: Set[str]) -> Se return seen -class TestUnitGlobs: - """A unit names either one module or a directory of them, and reads as both.""" - - def test_a_directory_unit_reaches_every_module_below_it(self) -> None: - assert check_import_boundary.unit_glob("driver") == "driver/**/*.py" - - def test_a_module_unit_names_itself(self) -> None: - assert check_import_boundary.unit_glob("song.py") == "song.py" - - def test_a_unit_under_a_package_is_reached_by_a_dotted_prefix(self) -> None: - assert check_import_boundary.unit_prefix(PLAYER, "driver/assembler") == "sampletones_player.driver.assembler" - - def test_a_module_unit_drops_its_suffix(self) -> None: - assert check_import_boundary.unit_prefix(PLAYER, "song.py") == "sampletones_player.song" - - def test_a_package_unit_is_reached_by_its_own_name(self) -> None: - assert check_import_boundary.unit_prefix("", "sampletones_core") == "sampletones_core" - - def test_a_nested_unit_is_named_as_the_glob_it_owns(self) -> None: - nested = check_import_boundary.nested_globs("driver", ("driver", "driver/assembler", "clock")) - assert nested == ("driver/assembler/**/*.py",) - - -class TestLayerRules: - """A graph states what a unit may import, and the rule the check runs is what remains.""" - - GRAPH: Final = check_import_boundary.LayerGraph( - root="package", - package="package", - layers={"low": (), "high": ("low",)}, - contracts={"low": ("package.high.contract",)}, +def reported(tmp_path: Path) -> List[str]: + """What the declared rules report over a tree a test builds.""" + violations = check_boundaries( + tmp_path, + check_import_boundary.RULES, + check_import_boundary.TOKEN_RULES, + None, ) - - def test_a_unit_forbids_the_units_its_layers_leave_out(self) -> None: - low, _ = check_import_boundary.layer_rules(self.GRAPH) - assert low.forbidden == ("package.high",) - - def test_a_unit_stays_free_of_the_units_it_may_import(self) -> None: - _, high = check_import_boundary.layer_rules(self.GRAPH) - assert high.forbidden == () - - def test_a_unit_carries_the_contracts_declared_for_it(self) -> None: - low, _ = check_import_boundary.layer_rules(self.GRAPH) - assert low.contracts == ("package.high.contract",) - - def test_every_rule_is_written_against_the_graphs_root(self) -> None: - assert all(rule.root == "package" for rule in check_import_boundary.layer_rules(self.GRAPH)) + return [violation.kind for violation in violations] class TestPackageGraph: @@ -121,10 +71,14 @@ def test_the_package_graph_is_acyclic(self) -> None: assert all(package not in reaches(self.LAYERS, package, set()) for package in self.LAYERS) def test_the_reconstruction_engine_stays_clear_of_the_console_player(self) -> None: - assert PLAYER not in reaches(self.LAYERS, "sampletones_core", set()) + assert PLAYER not in reaches(self.LAYERS, CORE, set()) def test_the_console_player_reads_the_reconstruction_engine(self) -> None: - assert "sampletones_core" in self.LAYERS[PLAYER] + assert CORE in self.LAYERS[PLAYER] + + def test_the_synthesis_package_stands_below_the_reconstruction_engine(self) -> None: + """Equal temperament sits in `sampletones_shared`, so synthesis reaches no engine module.""" + assert CORE not in reaches(self.LAYERS, "sampletones_synthesis", set()) class TestPlayerGraph: @@ -149,130 +103,57 @@ def test_the_driver_is_reached_through_the_file_that_writes_the_nsf(self) -> Non assert "driver" in self.LAYERS["nsf"] def test_every_module_of_the_player_belongs_to_one_unit(self) -> None: - rules = check_import_boundary.layer_rules(check_import_boundary.PLAYER_GRAPH) - owners = Counter(path for rule in rules for path in reached_modules(rule)) + owners = Counter(path for rule in check_import_boundary.PLAYER_GRAPH.rules() for path in reached_modules(rule)) - assert set(owners) == set(swept(SOURCE_ROOT / PLAYER)) + assert set(owners) == swept_paths(SOURCE_ROOT / PLAYER) assert set(owners.values()) == {1} -class TestRuleModules: - def test_a_module_directly_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None: - """`logic/**/*.py` names `logic/direct.py` as surely as `logic/inner/deep.py`.""" - direct = write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT) - - reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, (), set(swept(tmp_path)), None) - - assert reached == [direct.resolve()] - - def test_a_module_nested_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None: - deep = write_module(tmp_path / "logic" / "inner", "deep.py", PLAIN_IMPORT) +class TestDeclaredRules: + """Each declared boundary read over a tree that crosses it.""" - reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, (), set(swept(tmp_path)), None) + def test_a_layer_reaching_the_interface_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / APPLICATION / "logic", "direct.py", VISUAL_IMPORT) - assert reached == [deep.resolve()] + assert reported(tmp_path) == ["dearpygui"] - def test_a_module_outside_the_rule_directory_stays_aside(self, tmp_path: Path) -> None: - write_module(tmp_path / "services", "conversion.py", PLAIN_IMPORT) - - assert check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, (), set(swept(tmp_path)), None) == [] - - def test_a_module_a_nested_rule_owns_is_left_to_it(self, tmp_path: Path) -> None: - direct = write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT) - write_module(tmp_path / "logic" / "inner", "deep.py", PLAIN_IMPORT) - - reached = check_import_boundary.rule_modules( - tmp_path, - LOGIC_RULE, - ("logic/inner/**/*.py",), - set(swept(tmp_path)), - None, - ) - - assert reached == [direct.resolve()] - - def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path) -> None: - named = write_module(tmp_path / "logic", "named.py", PLAIN_IMPORT) - write_module(tmp_path / "logic", "other.py", PLAIN_IMPORT) - - reached = check_import_boundary.rule_modules( - tmp_path, - LOGIC_RULE, - (), - set(swept(tmp_path)), - {named.resolve()}, - ) - - assert reached == [named.resolve()] - - -class TestCheckBoundaries: - def test_a_forbidden_import_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / APPLICATION / "logic", "direct.py", FORBIDDEN_IMPORT) - - violations = check_import_boundary.check_boundaries(tmp_path, None) - - assert [violation.kind for violation in violations] == ["dearpygui"] - - def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None: - path = write_module(tmp_path / APPLICATION / "logic", "direct.py", f"{PLAIN_IMPORT}{FORBIDDEN_IMPORT}") - - violations = check_import_boundary.check_boundaries(tmp_path, None) - - assert violations[0].location.startswith(f"{path}:2") - - def test_a_contract_module_stays_reachable(self, tmp_path: Path) -> None: + def test_a_service_contract_stays_reachable(self, tmp_path: Path) -> None: """A layer reads another layer's data contract while its implementation stays out of reach.""" write_module(tmp_path / APPLICATION / "logic", "direct.py", CONTRACT_IMPORT) - assert check_import_boundary.check_boundaries(tmp_path, None) == [] + assert reported(tmp_path) == [] - def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None: - write_module(tmp_path / APPLICATION / "logic", "direct.py", PLAIN_IMPORT) + def test_the_reconstruction_engine_reaching_the_console_player_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / CORE / "formats", "player.py", PLAYER_IMPORT) - assert check_import_boundary.check_boundaries(tmp_path, None) == [] + assert reported(tmp_path) == [PLAYER] - def test_a_package_reaching_across_the_graph_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / "sampletones_core" / "formats", "player.py", PLAYER_IMPORT) + def test_the_console_player_reading_the_engine_is_left_alone(self, tmp_path: Path) -> None: + write_module(tmp_path / PLAYER / "nsf", "file.py", PLAIN_IMPORT) - violations = check_import_boundary.check_boundaries(tmp_path, None) - - assert [violation.kind for violation in violations] == [PLAYER] + assert reported(tmp_path) == [] def test_a_shipped_module_reaching_the_build_toolchain_is_reported(self, tmp_path: Path) -> None: write_module(tmp_path / PLAYER / "nsf", "file.py", ASSEMBLER_IMPORT) - violations = check_import_boundary.check_boundaries(tmp_path, None) - - assert [violation.kind for violation in violations] == ["sampletones_player.driver.assembler"] + assert reported(tmp_path) == [ASSEMBLER] def test_the_build_toolchain_reads_the_driver_it_assembles(self, tmp_path: Path) -> None: - body = "from sampletones_player.driver.image import DriverImage\n" - write_module(tmp_path / PLAYER / "driver" / "assembler", "builder.py", body) + write_module(tmp_path / PLAYER / "driver" / "assembler", "builder.py", DRIVER_IMPORT) - assert check_import_boundary.check_boundaries(tmp_path, None) == [] + assert reported(tmp_path) == [] - def test_a_forbidden_token_is_reported(self, tmp_path: Path) -> None: + def test_a_panel_composing_a_column_suffix_is_reported(self, tmp_path: Path) -> None: write_module(tmp_path / APPLICATION / "ui" / "panels", "left.py", PANEL_SUFFIX) - violations = check_import_boundary.check_boundaries(tmp_path, None) - - assert len(violations) == 1 + assert len(reported(tmp_path)) == 1 - def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None: - checked = write_module(tmp_path / APPLICATION / "logic", "checked.py", FORBIDDEN_IMPORT) - write_module(tmp_path / APPLICATION / "logic", "other.py", FORBIDDEN_IMPORT) - violations = check_import_boundary.check_boundaries(tmp_path, {checked.resolve()}) - - assert len(violations) == 1 - - -class TestSweptRoots: - """A root the sweep reads nothing under reports nothing, which reads as a clean tree.""" +class TestRuleCoverage: + """A rule naming no module of the tree reads as a clean tree, so each one reaches something.""" def test_the_source_root_holds_modules(self) -> None: - assert source_paths([SOURCE_ROOT]) + assert swept_paths(SOURCE_ROOT) def test_every_boundary_rule_reaches_a_module(self) -> None: assert all(reached_modules(rule) for rule in check_import_boundary.RULES) @@ -281,14 +162,6 @@ def test_every_token_rule_reaches_a_module(self) -> None: rules = check_import_boundary.TOKEN_RULES assert all(list((SOURCE_ROOT / rule.root).glob(rule.pattern)) for rule in rules) - def test_a_root_holding_no_module_stops_the_check(self, tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError): - check_import_boundary.check_boundaries(tmp_path, None) - - def test_an_absent_root_stops_the_check(self, tmp_path: Path) -> None: - with pytest.raises(NotADirectoryError): - check_import_boundary.check_boundaries(tmp_path / "absent", None) - class TestMain: def test_the_repository_holds_its_import_boundaries(self) -> None: @@ -299,7 +172,7 @@ def test_a_forbidden_import_is_reported_where_it_sits( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - path = write_module(tmp_path / APPLICATION / "logic", "direct.py", FORBIDDEN_IMPORT) + path = write_module(tmp_path / APPLICATION / "logic", "direct.py", VISUAL_IMPORT) exit_code = check_import_boundary.main(["--all", "--source", str(tmp_path)]) @@ -313,7 +186,7 @@ def test_named_files_narrow_the_run_to_themselves( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: - write_module(tmp_path / APPLICATION / "logic", "reported.py", FORBIDDEN_IMPORT) + write_module(tmp_path / APPLICATION / "logic", "reported.py", VISUAL_IMPORT) clean = write_module(tmp_path / APPLICATION / "logic", "clean.py", PLAIN_IMPORT) assert check_import_boundary.main([str(clean), "--source", str(tmp_path)]) == 0 From 9dd6733c234c00709b8f85fbd59da25a0708fbaa Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 15:46:43 +0200 Subject: [PATCH 012/142] Renamed: GeneratorName to ChannelName --- docs/api/index.md | 10 +- docs/concepts/calibration.md | 4 +- docs/development/architecture.md | 4 +- docs/development/browser.md | 6 +- docs/development/compatibility.md | 6 +- docs/development/sequencer-blocks.md | 2 +- docs/formats/bitphase.md | 2 +- docs/formats/configuration.md | 2 +- docs/formats/famitracker.md | 2 +- docs/formats/projects.md | 11 +- docs/formats/reconstructions.md | 12 +- docs/guide/configuration.md | 2 +- docs/guide/getting-started.md | 2 +- docs/guide/interface.md | 4 +- scripts/calibration.py | 22 +- src/sampletones/__init__.py | 10 +- src/sampletones_application/application.py | 16 +- .../categories/context.py | 16 +- .../categories/elements/global_.py | 2 +- .../categories/elements/main.py | 4 +- .../categories/elements/reconstructions.py | 4 +- .../config/managers/config.py | 2 +- .../constants/sequencer.py | 4 +- .../coordinators/reconstruction.py | 6 +- .../coordinators/tabs/main.py | 14 +- .../coordinators/tabs/reconstruction.py | 20 +- .../coordinators/tabs/sequencer.py | 22 +- .../layout/tabs/sequencer/tables/cells.py | 2 +- .../logic/main/converter.py | 2 +- .../logic/project/controller.py | 42 +-- .../browser/tree/configurations/grouping.py | 4 +- .../browser/tree/configurations/naming.py | 4 +- .../logic/reconstruction/data.py | 6 +- .../logic/reconstruction/feature.py | 16 +- .../logic/reconstruction/instruments.py | 82 +++--- .../logic/reconstruction/reconstruction.py | 104 ++++---- .../logic/sequencer/channels.py | 24 +- .../logic/sequencer/clipboard/tracker.py | 6 +- .../logic/sequencer/history_detail.py | 70 ++--- .../logic/sequencer/order/order.py | 28 +- .../logic/sequencer/order/reader.py | 14 +- .../logic/sequencer/order/writer.py | 6 +- .../logic/sequencer/playback/protocol.py | 2 +- .../sequencer/playback/synthesizer/bank.py | 28 +- .../playback/synthesizer/synthesizer.py | 30 +-- .../sequencer/playback/synthesizer/voice.py | 14 +- .../logic/sequencer/tracker/adjuster.py | 24 +- .../logic/sequencer/tracker/reader.py | 14 +- .../logic/sequencer/tracker/tracker.py | 218 ++++++++------- .../logic/sequencer/tracker/writer.py | 30 +-- .../services/regeneration.py | 22 +- src/sampletones_application/shell.py | 4 +- src/sampletones_application/tags/main.py | 2 +- .../tags/reconstructions.py | 6 +- .../ui/elements/graphs/waveform.py | 22 +- .../ui/elements/tree/tree.py | 6 +- src/sampletones_application/ui/menu.py | 24 +- .../ui/panels/instruction/library.py | 6 +- .../ui/panels/main/reconstructor.py | 44 ++- .../reconstruction/instruments/instruments.py | 248 ++++++++--------- .../ui/panels/reconstruction/plot.py | 81 +++--- .../ui/panels/sequencer/channels.py | 28 +- .../ui/panels/sequencer/columns.py | 22 +- .../ui/panels/sequencer/display.py | 12 +- .../ui/panels/sequencer/input/edit.py | 6 +- .../ui/panels/sequencer/input/order.py | 24 +- .../ui/panels/sequencer/input/target.py | 4 +- .../ui/panels/sequencer/input/tracker.py | 46 ++-- .../ui/panels/sequencer/order.py | 152 +++++------ .../ui/panels/sequencer/samples.py | 8 +- .../ui/panels/sequencer/tracker.py | 208 +++++++-------- .../ui/themes/channels.py | 12 +- .../utils/gui/shortcuts/ids.py | 12 +- .../view_model/main/reconstructor.py | 4 +- .../view_model/main/updates.py | 4 +- .../view_model/reconstruction/instruments.py | 6 +- .../reconstruction/reconstruction.py | 8 +- .../view_model/reconstruction/update.py | 4 +- .../view_model/sequencer/channels.py | 16 +- .../view_model/sequencer/order.py | 12 +- .../view_model/sequencer/region.py | 16 +- .../view_model/sequencer/slot.py | 12 +- .../view_model/sequencer/tracker.py | 12 +- .../view_model/shared/footprint.py | 18 +- .../view_model/shared/waveform_data.py | 12 +- src/sampletones_config/lang/en.yaml | 12 +- .../layout/tabs/sequencer/table_cells.yaml | 2 +- src/sampletones_core/compatibility/fields.py | 16 ++ .../compatibility/project/__init__.py | 4 +- .../compatibility/project/v1_1.py | 110 ++++++++ .../compatibility/reconstruction/__init__.py | 4 +- .../compatibility/reconstruction/v2_2.py | 59 ++++ src/sampletones_core/compatibility/utils.py | 9 + src/sampletones_core/configs/config.py | 6 +- src/sampletones_core/configs/display.py | 8 +- src/sampletones_core/configs/generation.py | 12 +- src/sampletones_core/constants/enums.py | 32 +-- .../constants/field_aliases.py | 2 +- src/sampletones_core/exporters/__init__.py | 4 +- src/sampletones_core/exporters/maps.py | 12 +- src/sampletones_core/exporters/naming.py | 12 +- src/sampletones_core/exporters/slices.py | 28 +- src/sampletones_core/features/__init__.py | 4 +- src/sampletones_core/features/spec.py | 24 +- .../formats/bitphase/builder.py | 59 ++-- .../formats/bitphase/envelopes.py | 28 +- .../formats/bitphase/preset.py | 12 +- .../bitphase/specification/channels.py | 12 +- .../formats/famitracker/builder.py | 42 +-- .../formats/famitracker/footprint.py | 12 +- .../famitracker/specification/channels.py | 12 +- src/sampletones_core/generators/__init__.py | 8 +- .../generators/implementation/noise.py | 4 +- .../generators/implementation/pulse.py | 4 +- .../generators/implementation/triangle.py | 4 +- src/sampletones_core/generators/maps.py | 12 +- src/sampletones_core/generators/utils.py | 24 +- .../instructions/instruction.py | 4 +- .../project/instruments/instrument.py | 4 +- .../project/patterns/channel.py | 10 +- src/sampletones_core/project/song.py | 72 ++--- .../reconstructions/converter/paths/fields.py | 16 +- .../reconstruction/approximations.py | 6 +- .../reconstruction/instructions.py | 26 +- .../reconstruction/reconstruction.py | 136 +++++----- .../reconstructor/approximation.py | 4 +- .../reconstructor/reconstructor.py | 40 +-- .../reconstructor/selector/base.py | 36 +-- .../reconstructor/selector/greedy.py | 4 +- .../reconstructor/selector/viterbi.py | 34 +-- .../reconstructions/reconstructor/state.py | 18 +- .../reconstructions/reconstructor/worker.py | 12 +- src/sampletones_core/trackers/backend.py | 4 +- .../trackers/implementation/bitphase.py | 2 +- .../trackers/implementation/famitracker.py | 2 +- src/sampletones_core/trackers/request.py | 10 +- src/sampletones_player/nsf/song.py | 4 +- src/sampletones_player/registers/streams.py | 4 +- .../specification/channels.py | 12 +- src/sampletones_player/specification/song.py | 4 +- src/sampletones_player/trace/trace.py | 4 +- src/sampletones_shared/application.py | 4 +- tests/conftest.py | 4 +- tests/integration/assets/reconstruction.py | 34 +-- tests/integration/assets/song_loader.py | 36 +-- tests/integration/config/reconstruction.yaml | 6 +- .../famitracker/test_ftm_pipeline.py | 6 +- .../services/conftest.py | 6 +- .../services/test_export.py | 4 +- .../services/test_regeneration.py | 42 +-- tests/suite/browser.py | 18 +- tests/suite/sequencer.py | 49 ++-- .../config/managers/test_config.py | 4 +- .../coordinators/tabs/test_sequencer.py | 132 ++++----- .../coordinators/test_reconstruction.py | 4 +- .../logic/main/test_converter.py | 6 +- .../logic/project/test_controller.py | 92 +++---- .../logic/project/test_manager.py | 6 +- .../logic/reconstruction/browser/conftest.py | 4 +- .../browser/test_configurations.py | 6 +- .../logic/reconstruction/test_data.py | 8 +- .../logic/reconstruction/test_feature.py | 16 +- .../logic/reconstruction/test_instruments.py | 44 +-- .../logic/reconstruction/test_manager.py | 6 +- .../reconstruction/test_reconstruction.py | 64 ++--- .../logic/sequencer/clipboard/test_samples.py | 4 +- .../logic/sequencer/clipboard/test_tracker.py | 42 +-- .../logic/sequencer/order/test_order.py | 78 +++--- .../logic/sequencer/order/test_reader.py | 16 +- .../logic/sequencer/order/test_writer.py | 44 +-- .../logic/sequencer/playback/conftest.py | 46 ++-- .../sequencer/playback/test_synthesizer.py | 56 ++-- .../sequencer/playback/test_tick_clock.py | 6 +- .../logic/sequencer/playback/test_voice.py | 58 ++-- .../logic/sequencer/test_channels.py | 20 +- .../logic/sequencer/test_history_detail.py | 52 ++-- .../logic/sequencer/test_samples.py | 26 +- .../logic/sequencer/tracker/test_adjuster.py | 48 ++-- .../logic/sequencer/tracker/test_reader.py | 52 ++-- .../logic/sequencer/tracker/test_tracker.py | 252 +++++++++--------- .../logic/sequencer/tracker/test_writer.py | 58 ++-- .../services/export/test_service.py | 4 +- .../services/test_regeneration.py | 72 ++--- .../test_application_channels.py | 28 +- .../sampletones_application/test_startup.py | 24 +- .../ui/elements/tree/test_detail_items.py | 2 +- .../ui/panels/main/test_reconstructor.py | 69 +++-- .../reconstruction/test_instruments_panel.py | 109 ++++---- .../ui/panels/reconstruction/test_plot.py | 120 ++++----- .../sequencer/input/test_order_input.py | 36 +-- .../sequencer/input/test_tracker_input.py | 50 ++-- .../ui/panels/sequencer/test_block_keys.py | 40 +-- .../ui/panels/sequencer/test_block_menu.py | 80 +++--- .../panels/sequencer/test_channels_switch.py | 16 +- .../ui/panels/sequencer/test_columns.py | 22 +- .../panels/sequencer/test_order_channels.py | 130 +++++---- .../ui/panels/sequencer/test_order_keys.py | 12 +- .../ui/panels/sequencer/test_order_remove.py | 6 +- .../ui/panels/sequencer/test_samples_menu.py | 6 +- .../panels/sequencer/test_selection_drag.py | 40 +-- .../panels/sequencer/test_selection_keys.py | 30 +-- .../panels/sequencer/test_tracker_channels.py | 118 ++++---- .../sequencer/test_tracker_context_menu.py | 20 +- .../sequencer/test_tracker_header_menu.py | 110 ++++---- .../ui/panels/sequencer/test_tracker_rows.py | 30 +-- .../sampletones_application/ui/test_menu.py | 30 +-- .../reconstruction/test_reconstruction.py | 4 +- .../view_model/sequencer/test_channels.py | 46 ++-- .../view_model/sequencer/test_order.py | 36 +-- .../view_model/sequencer/test_region.py | 36 +-- .../view_model/sequencer/test_slot.py | 20 +- .../view_model/sequencer/test_tracker.py | 52 ++-- .../view_model/shared/test_waveform_data.py | 30 +-- .../compatibility/project/__init__.py | 0 .../compatibility/project/test_v1_1.py | 84 ++++++ .../compatibility/reconstruction/__init__.py | 0 .../compatibility/reconstruction/test_v2_2.py | 50 ++++ .../compatibility/test_binary.py | 19 ++ .../compatibility/test_json.py | 32 +++ .../sampletones_core/configs/test_display.py | 18 +- .../sampletones_core/constants/test_enums.py | 14 +- .../sampletones_core/exporters/test_naming.py | 28 +- .../sampletones_core/exporters/test_slices.py | 30 +-- .../sampletones_core/features/test_spec.py | 6 +- .../formats/bitphase/conftest.py | 8 +- .../formats/bitphase/test_btp.py | 4 +- .../formats/bitphase/test_builder.py | 8 +- .../formats/bitphase/test_envelopes.py | 54 ++-- .../formats/bitphase/test_preset.py | 4 +- .../formats/bitphase/test_project_builder.py | 38 +-- .../formats/bitphase/test_tuning.py | 2 +- .../formats/famitracker/conftest.py | 44 +-- .../formats/famitracker/test_builder.py | 20 +- .../formats/famitracker/test_footprint.py | 14 +- .../generators/implementation/test_noise.py | 6 +- .../generators/implementation/test_pulse.py | 4 +- .../implementation/test_triangle.py | 4 +- .../generators/test_generator.py | 6 +- .../sampletones_core/generators/test_utils.py | 34 +-- .../project/patterns/test_channel.py | 6 +- .../project/patterns/test_pattern.py | 6 +- .../project/test_container.py | 24 +- .../sampletones_core/project/test_models.py | 8 +- .../project/test_serialization.py | 10 +- .../sampletones_core/project/test_song.py | 184 ++++++------- .../project/test_structure.py | 20 +- .../converter/paths/test_fields.py | 8 +- .../reconstruction/test_reconstruction.py | 130 ++++----- .../reconstructions/reconstructor/conftest.py | 16 +- .../reconstructor/selector/test_viterbi.py | 30 +-- .../reconstructor/test_reconstructor.py | 52 ++-- .../reconstructor/test_scorer.py | 4 +- .../reconstructor/test_selector.py | 8 +- .../reconstructor/test_state.py | 48 ++-- .../reconstructor/test_worker.py | 30 +-- .../trackers/test_bitphase.py | 4 +- .../trackers/test_famitracker.py | 4 +- .../sampletones_core/utils/test_display.py | 4 +- .../unit/sampletones_player/nsf/test_song.py | 8 +- tests/unit/scripts/checks/test_unused_tags.py | 12 +- 260 files changed, 3795 insertions(+), 3404 deletions(-) create mode 100644 src/sampletones_core/compatibility/fields.py create mode 100644 src/sampletones_core/compatibility/project/v1_1.py create mode 100644 src/sampletones_core/compatibility/reconstruction/v2_2.py create mode 100644 src/sampletones_core/compatibility/utils.py create mode 100644 tests/unit/sampletones_core/compatibility/project/__init__.py create mode 100644 tests/unit/sampletones_core/compatibility/project/test_v1_1.py create mode 100644 tests/unit/sampletones_core/compatibility/reconstruction/__init__.py create mode 100644 tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py diff --git a/docs/api/index.md b/docs/api/index.md index 3b457e75f..87a9b0cc5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -13,7 +13,7 @@ from sampletones import ( InstructionLibrary, Reconstruction, Reconstructor, - GeneratorName, + ChannelName, Generator, PulseGenerator, TriangleGenerator, @@ -31,8 +31,8 @@ from sampletones import ( | `Window` | analysis window derived from a config (`Window.from_config(config)`) | | `InstructionLibrary` | the library of candidate instructions a reconstruction searches | | `Reconstructor` | runs a reconstruction: `Reconstructor(config)("sample.wav")` | -| `Reconstruction` | the result of a reconstruction — its approximation audio, per-generator instructions, and the config used | -| `GeneratorName` | enum naming the four channels: `pulse1`, `pulse2`, `triangle`, `noise` | +| `Reconstruction` | the result of a reconstruction — its approximation audio, per-channel instructions, and the config used | +| `ChannelName` | enum naming the four channels: `pulse1`, `pulse2`, `triangle`, `noise` | | `Generator` | shared base class of the oscillator generators | | `PulseGenerator`, `TriangleGenerator`, `NoiseGenerator` | render one channel's waveform from an instruction | | `Instruction` | shared base class of the per-frame channel instructions | @@ -110,7 +110,7 @@ write_wave("reconstruction.wav", sample_rate, reconstruction.approximation) ### Load a reconstruction -`Reconstruction.load` reads a saved `.stn` back into a `Reconstruction`, carrying its approximation, per-generator instructions, and config: +`Reconstruction.load` reads a saved `.stn` back into a `Reconstruction`, carrying its approximation, per-channel instructions, and config: ```python from sampletones import Reconstruction @@ -120,7 +120,7 @@ reconstruction = Reconstruction.load("reconstruction.stn") ### Export instruments -`Reconstruction.export` returns the per-channel [features](../formats/instruction-libraries.md), one entry per generator, and each set saves as a FamiTracker `.fti` instrument: +`Reconstruction.export` returns the per-channel [features](../formats/instruction-libraries.md), one entry per channel, and each set saves as a FamiTracker `.fti` instrument: ```python from sampletones import Reconstruction diff --git a/docs/concepts/calibration.md b/docs/concepts/calibration.md index a74453795..37d63ef4a 100644 --- a/docs/concepts/calibration.md +++ b/docs/concepts/calibration.md @@ -12,13 +12,13 @@ and runs as a script: ``` python scripts/calibration.py [--config ] [--methods fft,cqt] [--perceptual-exponents 0.5,1.0] [--temporal-weights 0.1,0.3] - [--generators pulse1,triangle,noise] + [--channels pulse1,triangle,noise] ``` The base configuration comes from `--config` when given; otherwise the saved application configuration is used, so a run inherits the current app settings — sample rate, gamma, selector and the rest. The channel set is the exception: -calibration pins the generators itself (`--generators`, by default pulse 1 + +calibration pins the channels itself (`--channels`, by default pulse 1 + triangle + noise), so every run reconstructs with an explicitly chosen channel set and results stay comparable across machines. diff --git a/docs/development/architecture.md b/docs/development/architecture.md index d2205b845..c38f1a8de 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -205,7 +205,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m - A panel creates its entire widget tree in one call to `create_panel(parent)`, rooting its subtree at `self.tag` inside the coordinator-injected `parent`, and calls DPG afterwards only in `update_view()`, `update_*` methods, and event callbacks wired by DPG itself. - Panels hold only visual state: their tag, their child widget references, and layout dimensions. Domain objects stay in logic; panels receive projections of them. - A panel never encodes its own placement: it does not compose a column tag (`SUF_PANEL_*`) as its parent, and it never hosts a sibling panel. Tab layout is the coordinator's (see the Coordinators reference). Where a section is a card, one card is one panel is one module; the coordinator declares which cards a tab contains and how they are arranged. -- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — and the `card()` context manager binds SURFACE to a card. Panels and coordinators bind only semantic/content themes (a per-generator checkbox tint, the player toolbar), never GROUND or SURFACE. +- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — and the `card()` context manager binds SURFACE to a card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE. - Every mutation from outside goes through `update_view(view_model)` or through a direct DPG call (`dpg_configure_item`, `dpg_set_value`) triggered by an `update_*` method. - Callback wiring from coordinators sets public `on_x` attributes *after* construction; panels must therefore tolerate `None` hooks until wiring is complete. - A widget whose rendering needs synchronous per-item queries declares a consumer-owned `Protocol` of exactly that surface (e.g. `TreeLogicProtocol`, through which the file trees query per-node favorite and playability state); the owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. Hooks and view models remain the default — the Protocol is the exception for query-heavy widgets where projecting a whole tree per repaint would be disproportionate. @@ -492,7 +492,7 @@ sampletones_application/ | Service class | `Service` | `ConversionService` | | DPG widget tag | `TAG_` + the composed tag, upper-cased | `TAG_MAIN_CONFIG_TABLE_CONFIG_ROW` (`main.config.table.config_row`) | | Tag suffix | `SUF_` | `SUF_PANEL_LEFT` | -| Tag prefix | `PRE_` | `PRE_RECONSTRUCTION_GENERATOR` | +| Tag prefix | `PRE_` | `PRE_RECONSTRUCTION_CHANNEL` | | Text key | `page.panel.text_type.element` | `global.dialog.label.ok` | | Panel callback hook | `on_` attribute | `on_convert_requested` | | Panel state hook | `can_` or `_` attribute | `can_add_to_sequencer`, `replace_in_sequencer_label` | diff --git a/docs/development/browser.md b/docs/development/browser.md index 0bf2e4d1f..c8798b023 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -19,7 +19,7 @@ complements `docs/development/architecture.md` (layering and ownership) and render one model, so they show one shape, and each rule is exercised without a window. 3. **A row's identity is its path; its name is a label.** Favorites, the context menus, copy-path, playback and opening a reconstruction all key on `filepath`. That is what frees a name to be - rewritten — a configuration directory renamed to its generator abbreviation, a chain of headings + rewritten — a configuration directory renamed to its channel abbreviation, a chain of headings joined into one row, a colliding label marked with its configuration hash. 4. **The browser writes the headings the disk states rather than holds.** A frequency pair, a transformation, a source folder, one source audio: each becomes a row that carries no path of its @@ -54,7 +54,7 @@ drive, and `get_all_reconstruction_files` reads the scan. |---|---|---| | Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it | | Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree | -| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation configuration headings and names it by its generators, so the rows leading to it spell its display name; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | +| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation configuration headings and names it by its channels, so the rows leading to it spell its display name; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | | Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration | | Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first | | Containers | `tree/containers.py` | `find_or_create_group`, `find_or_create_config_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each heading is looked up among the siblings of its own kind and class, so a folder and an audio sharing a name stay two rows | @@ -106,7 +106,7 @@ configuration is what distinguishes one row from the next. in. * **Unique sibling labels** (`unique_display_names`, `sampletones_core/configs/display.py`) — where siblings would read alike, every member of that label takes its short configuration hash. One rule - serves the generator directories under a transformation group, the nested configuration directories, + serves the channel directories under a transformation group, the nested configuration directories, and the variants under a sample. ## The panels diff --git a/docs/development/compatibility.md b/docs/development/compatibility.md index e6e6fdb49..1f9671be1 100644 --- a/docs/development/compatibility.md +++ b/docs/development/compatibility.md @@ -64,8 +64,10 @@ untouched. - `compatibility/update.py` — `VersionUpdate`, one named version step. - `compatibility/upgrade.py` — the engine: `upgrade`, `upgrade_binary`, `upgrade_json`, and the per-format registries `CURRENT_VERSIONS` and `UPDATES`. -- `compatibility//__init__.py` — that format's `UPDATES` tuple, empty - until the format's first shape change. +- `compatibility//__init__.py` — that format's `UPDATES` tuple. The + reconstruction chain currently holds the 2.1→2.2 step + (`compatibility/reconstruction/v2_2.py`), the project chain the 1.0→1.1 step + (`compatibility/project/v1_1.py`), and the library chain is empty. ### Version fields diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 142b26523..177113a38 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -25,7 +25,7 @@ coordinates, which is what lets it land anywhere it is anchored. Two axes underpin both grids: -- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + GeneratorName.items()`. Index 0 +- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + ChannelName.items()`. Index 0 is the aggregate column (the tracker's **Sample**, the order's **Master**) and 1 to 4 are the channels. Both grids lay out along it, so a row index means the same thing in either. diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index e0693bb64..c8e751195 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -196,7 +196,7 @@ given, one per slice. | Scope | `.btp` | `.json` preset | | --- | --- | --- | -| One generator slice | a playable document holding that instrument | one file | +| One channel slice | a playable document holding that instrument | one file | | A whole reconstruction | a playable document holding every slice | one file per slice, beside the chosen name | | A project | the song, its samples and its arrangement | — | diff --git a/docs/formats/configuration.md b/docs/formats/configuration.md index e0b119bde..6ec8c1f06 100644 --- a/docs/formats/configuration.md +++ b/docs/formats/configuration.md @@ -48,7 +48,7 @@ top-level keys and groups the scoring controls into `calculation`, `weights`, | Key | Meaning | Values | | --- | --- | --- | -| `generators` | channels used | list of `pulse1`, `pulse2`, `triangle`, `noise` | +| `channels` | channels used | list of `pulse1`, `pulse2`, `triangle`, `noise` (legacy key `generators` loads too) | | `drive` | how hard the channels are pushed (alias: `mixer`) | 0 < value ≤ 5 | | `reset_phase` | reset oscillator phase within each instruction | `true` / `false` | | `final_regeneration` | re-render the chosen instructions at the end to keep oscillators continuous | `true` / `false` | diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index b528b94f5..7095d5d73 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -166,7 +166,7 @@ one-item sequence sets the value once and holds it. A dimension arrives empty wh reconstruction records it as one the channel governs — the state clearing the envelope in the instruments panel puts it in (see [Reconstructions](reconstructions.md)). -**How _SampleToNES_ fills an instrument.** Each generator slice of a sample's +**How _SampleToNES_ fills an instrument.** Each channel slice of a sample's reconstruction becomes one instrument, so a sample yields one to four instruments. A reconstruction holds a stream for every channel, and one describing no frame is a channel standing by (see [Reconstructions](reconstructions.md#contents)): it takes no diff --git a/docs/formats/projects.md b/docs/formats/projects.md index 570d3275f..c0eeaa8a0 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -50,5 +50,12 @@ and no path that would only mean something on the author's machine. `project.json` records the project format version it was written with. On load, _SampleToNES_ requires that version to match the one it supports and declines an -incompatible file rather than misreading it. Unknown or extra fields within a -matching version are ignored, which leaves room for the format to grow. +incompatible file rather than misreading it. A file written at a version the +upgrade chain reaches is migrated in memory to the current shape before +deserialization (see +[Data compatibility](../development/compatibility.md)). Unknown or extra fields +within a matching version are ignored, which leaves room for the format to grow. + +The current format version is 1.1. Version 1.1 renamed each channel pool's +`generator` key to `channel_name` and a row instrument's `generator_name` key to +`channel_name`; the channel values stored inside never changed. diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index d91c4adb8..1874aeaee 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -64,8 +64,16 @@ reconstruction stays self-contained and a saved project stays portable. Each file records the reconstruction data-version it was written with. On load, _SampleToNES_ requires that version to match the one it supports and declines a -file written by an incompatible version rather than misreading it. The -application version is stored alongside it, for reference. +file written by an incompatible version rather than misreading it. A file +written at a version the upgrade chain reaches is migrated in memory to the +current shape before deserialization (see +[Data compatibility](../development/compatibility.md)); the application version +is stored alongside the data version, for reference. + +The current data version is 2.2. Version 2.2 renamed the per-channel stream and +approximation keys from `generator_name` to `channel_name` and the channel +selection under the embedded config from `generators` to `channels`; the enum +values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. ## Storage and export diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5ed3630de..0e2c320db 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -11,7 +11,7 @@ go deeper. The **Main** tab exposes the everyday settings (grouped under **General settings**, **Reconstructor settings**, and **Advanced settings**): -- which **Generators** (channels) take part, and the **Drive** applied to them; +- which **Channels** take part, and the **Drive** applied to them; - **Normalize audio** and **Quantize audio** preprocessing; - the **Sample rate** and **NES frequency**; - the **Generation method** and **Feature scaling**, which set how the audio's diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 342022fc3..6a01cc940 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -10,7 +10,7 @@ instruments, and building a whole song. Both assume it is already 2. In the **Filesystem** browser on the left, click an audio file (WAV, MP3, FLAC, OGG, AIFF, or AU) — or a folder, to reconstruct every audio file inside it. 3. Optionally choose which channels to use under **Reconstructor settings** and - adjust **General settings**. At least one generator must be enabled. + adjust **General settings**. At least one channel must be enabled. 4. Click **Convert sample** (or **Convert directory** for a folder). The first time you use a given set of settings, the [instruction library](../concepts/instruction-library.md) is built diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 6ff99732e..948dfb9f5 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -26,7 +26,7 @@ clicking either path shows it in your file manager. When a single file finishes, only one runs at a time. A few settings are worth knowing before you convert. Under **Reconstructor -settings**, the **Generators** toggles choose which channels take part — at least +settings**, the **Channels** toggles choose which channels take part — at least one must be on — and **Drive** sets how hard they are pushed. **General settings** holds the analysis options: sample rate, NES frequency, generation method, and feature scaling. The rest, including the worker count and the output and library @@ -122,7 +122,7 @@ reconstructed. one key away. `1` to `4` toggle the four NES channels on the tab in front of you: the -generators on **Main**, the channels drawn on **Reconstructions**, and the song's +the channels on **Main**, the channels drawn on **Reconstructions**, and the song's mix on the **Sequencer**. In the sequencer's grids the digits type values into the cell you are on, so use the channel names or the **Playback ▸ Channels** menu to mute there. diff --git a/scripts/calibration.py b/scripts/calibration.py index c129d1800..4bf8d57d0 100644 --- a/scripts/calibration.py +++ b/scripts/calibration.py @@ -11,8 +11,8 @@ from sampletones_core.calibration.runner import build_variants, evaluate_variants from sampletones_core.configs import Config from sampletones_core.constants.enums import ( - DEFAULT_GENERATORS, - GeneratorName, + DEFAULT_CHANNELS, + ChannelName, SpectrumMethod, ) from sampletones_shared.logger import logger @@ -21,7 +21,7 @@ DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration" DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}" DEFAULT_PERCEPTUAL_EXPONENTS: Final[str] = "1.0" -DEFAULT_GENERATOR_NAMES: Final[str] = ",".join(generator.value for generator in DEFAULT_GENERATORS) +DEFAULT_CHANNEL_NAMES: Final[str] = ",".join(generator.value for generator in DEFAULT_CHANNELS) def main() -> None: @@ -59,22 +59,22 @@ def main() -> None: help="Comma-separated values of weights.temporal_loss_weight; empty keeps the base blend.", ) parser.add_argument( - "--generators", + "--channels", type=str, - default=DEFAULT_GENERATOR_NAMES, - help="Comma-separated channel generators every variant reconstructs with.", + default=DEFAULT_CHANNEL_NAMES, + help="Comma-separated channels every variant reconstructs with.", ) arguments = parser.parse_args() - generators = [GeneratorName(name.strip()) for name in arguments.generators.split(",") if name.strip()] - if not generators: - parser.error("--generators requires at least one generator name") + channels = [ChannelName(name.strip()) for name in arguments.channels.split(",") if name.strip()] + if not channels: + parser.error("--channels requires at least one channel name") base = Config.load(arguments.config) if arguments.config else Config.default() base = base.model_copy( update={ "generation": base.generation.model_copy( - update={"generators": generators}, + update={"channels": channels}, ) }, ) @@ -94,7 +94,7 @@ def main() -> None: referees = build_referees(sample_rate) variants = build_variants(base, methods, exponents, temporal_weights) - channel_names = ", ".join(generator.value for generator in generators) + channel_names = ", ".join(channel.value for channel in channels) logger.info( f"Evaluating {len(variants)} variants x {len(items)} items x {len(referees)} referees on {channel_names}" ) diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py index 936911a5e..60d9ed3cc 100644 --- a/src/sampletones/__init__.py +++ b/src/sampletones/__init__.py @@ -3,7 +3,7 @@ if TYPE_CHECKING: from sampletones_core.configs import Config - from sampletones_core.constants.enums import GeneratorName + from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Window from sampletones_core.generators import ( Generator, @@ -33,10 +33,10 @@ def __getattr__(name: str) -> Any: return SAMPLETONES_VERSION - if name == "GeneratorName": - from sampletones_core.constants.enums import GeneratorName + if name == "ChannelName": + from sampletones_core.constants.enums import ChannelName - return GeneratorName + return ChannelName if name == "Window": from sampletones_core.fft import Window @@ -76,7 +76,7 @@ def __getattr__(name: str) -> Any: __all__ = [ "Config", "Generator", - "GeneratorName", + "ChannelName", "Instruction", "InstructionLibrary", "NoiseGenerator", diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ecb423e3c..770c3d43c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -140,7 +140,7 @@ from sampletones_application.viewport import ViewportManager from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.audio import BufferSize, SampleRate -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction @@ -995,13 +995,13 @@ def _rebind_replaced_sample( def _regenerate_instrument( self, - generator_name: GeneratorName, + channel_name: ChannelName, features: Features, feature_key: FeatureKey, feature_value: FeatureValue, ) -> None: self._reconstruction_coordinator.regenerate_instrument( - generator_name, + channel_name, features, feature_key, feature_value, @@ -1029,7 +1029,7 @@ def _on_reconstruction_updated( HistoryAction.EDIT_RECONSTRUCTION, detail=self._sequencer_tab.reconstruction_edit_detail( sample.id, - outcome.generator_name, + outcome.channel_name, outcome.feature_key, ), coalesce=(sample.id,), @@ -1401,7 +1401,7 @@ def _stop(self) -> None: self._playback_router.stop() self._update_menu() - def _toggle_channel(self, generator: GeneratorName) -> None: + def _toggle_channel(self, generator: ChannelName) -> None: """Switches one NES channel in the tab in front of the reader. A channel is switched by a control of its own on three tabs: the generators a @@ -1411,13 +1411,13 @@ def _toggle_channel(self, generator: GeneratorName) -> None: """ match self._shell.get_current_tab(): case Tab.MAIN: - self._main_tab.toggle_generator(generator) + self._main_tab.toggle_channel(generator) case Tab.RECONSTRUCTIONS: - self._reconstructions_tab.toggle_generator(generator) + self._reconstructions_tab.toggle_channel(generator) case _: self._mute_channel(generator) - def _mute_channel(self, generator: GeneratorName) -> None: + def _mute_channel(self, generator: ChannelName) -> None: """Flips one channel of the sequencer's mix, the gesture the Channels submenu offers.""" self._sequencer_tab.toggle_channel(generator) diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 820d60a3a..f8b04bc34 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -3,13 +3,13 @@ from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName -CHANNEL_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = { - GeneratorName.PULSE1: ContextElements.PULSE_1, - GeneratorName.PULSE2: ContextElements.PULSE_2, - GeneratorName.TRIANGLE: ContextElements.TRIANGLE, - GeneratorName.NOISE: ContextElements.NOISE, +CHANNEL_ELEMENTS: Final[Dict[ChannelName, ContextElements]] = { + ChannelName.PULSE1: ContextElements.PULSE_1, + ChannelName.PULSE2: ContextElements.PULSE_2, + ChannelName.TRIANGLE: ContextElements.TRIANGLE, + ChannelName.NOISE: ContextElements.NOISE, } @@ -55,11 +55,11 @@ def context_label( def channel_label( language_manager: LanguageManager, - generator: GeneratorName, + channel: ChannelName, ) -> str: """Resolves an NES channel's name, the words every display naming a channel prints. The playback menu's mix, the samples menu's byte figures and anything else addressing a channel read it from one entry, so a reader meets the same name for the same channel. """ - return context_label(language_manager, CHANNEL_ELEMENTS[generator]) + return context_label(language_manager, CHANNEL_ELEMENTS[channel]) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index c032172ea..42ec652b4 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -58,7 +58,7 @@ class ContextElements(AbstractElement): class NodeDetailElements(AbstractElement): SAMPLE_RATE = "detail_sample_rate" NES_FREQUENCY = "detail_nes_frequency" - GENERATORS = "detail_generators" + CHANNELS = "detail_channels" SPECTRUM_METHOD = "detail_spectrum_method" TRANSFORMATION_GAMMA = "detail_transformation_gamma" WINDOW_SIZE = "detail_window_size" diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 496e1b82d..963292a6b 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -32,7 +32,7 @@ class ConfigPanelElements(AbstractElement): class ReconstructorElements(AbstractElement): - SECTION_GENERATORS = "section_generators" + SECTION_CHANNELS = "section_channels" SECTION_SETTINGS = "section_settings" SLIDER_DRIVE = "slider_drive" TOOLTIP_DRIVE = "tooltip_drive" @@ -53,7 +53,7 @@ class ConverterElements(AbstractElement): STATUS_ERROR = "status_error" STATUS_RECONSTRUCTION_COMPLETED = "status_reconstruction_completed" STATUS_NO_FILES = "status_no_files" - STATUS_NO_GENERATORS = "status_no_generators" + STATUS_NO_CHANNELS = "status_no_channels" STATUS_IDLE = "status_idle" STATUS_WAITING = "status_waiting" STATUS_GENERATING_LIBRARY = "status_generating_library" diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index fb60c6091..ffc5fe051 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -55,8 +55,8 @@ class ReconstructionsInstrumentsElements(AbstractElement): STATUS_SEQUENCE = "status_sequence" STATUS_SEQUENCE_TOO_LONG = "status_sequence_too_long" STATUS_COPY_SEQUENCE = "status_copy_sequence" - STATUS_GENERATOR_TOGGLE = "status_generator_toggle" - STATUS_GENERATOR_NOT_AVAILABLE = "status_generator_not_available" + STATUS_CHANNEL_TOGGLE = "status_channel_toggle" + STATUS_CHANNEL_NOT_AVAILABLE = "status_channel_not_available" STATUS_EXPORT_INSTRUMENT = "status_export_instrument" EXPORT_INSTRUMENT_SUCCESS = "export_instrument_success" EXPORT_INSTRUMENTS_SUCCESS = "export_instruments_success" diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py index cd7038317..0400f2bc2 100644 --- a/src/sampletones_application/config/managers/config.py +++ b/src/sampletones_application/config/managers/config.py @@ -117,7 +117,7 @@ def apply_generation_settings( new_generation = self.config.generation.model_copy( update={ "drive": update.drive, - "generators": update.generators, + "channels": update.channels, } ) self.config = self.config.model_copy( diff --git a/src/sampletones_application/constants/sequencer.py b/src/sampletones_application/constants/sequencer.py index 0415da07c..160a5d1d3 100644 --- a/src/sampletones_application/constants/sequencer.py +++ b/src/sampletones_application/constants/sequencer.py @@ -1,5 +1,5 @@ from typing import Final, Optional, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName -CHANNEL_AXIS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) +CHANNEL_AXIS: Final[Tuple[Optional[ChannelName], ...]] = (None,) + tuple(ChannelName.items()) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index d2e5222ad..28175ef6b 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -28,7 +28,7 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.types.feature import FeatureValue from sampletones_shared.exceptions import SampleToNESError @@ -266,7 +266,7 @@ def _close(self) -> None: def regenerate_instrument( self, - generator_name: GeneratorName, + channel_name: ChannelName, features: Features, feature_key: FeatureKey, data: FeatureValue, @@ -277,7 +277,7 @@ def regenerate_instrument( accepted = self._regeneration_service.start( reconstruction_data.reconstruction, - generator_name, + channel_name, features, feature_key, data, diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 3cc17adab..6acc17483 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -61,7 +61,7 @@ ReconstructorPanelViewModel, ) from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -124,7 +124,7 @@ def __init__( self._config_height = layout.config_height _msg_converter_error = language_manager["main.converter.message.status_error"] _msg_no_files = language_manager["main.converter.message.status_no_files"] - _msg_no_generators = language_manager["main.converter.message.status_no_generators"] + _msg_no_generators = language_manager["main.converter.message.status_no_channels"] self._ttl_progress = language_manager["main.converter.title.progress_dialog"] self._explorer_logic: ExplorerLogic = ExplorerLogic( @@ -167,7 +167,7 @@ def __init__( ) self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( ReconstructorPanelViewModel( - generators=frozenset(_config.generation.generators), + channels=frozenset(_config.generation.channels), drive=_config.generation.drive, ), layout=layout.main.reconstructor, @@ -345,7 +345,7 @@ def _update_reconstructor_panel_view(self) -> None: config = self._config_manager.config self._reconstructor_panel.update_view( ReconstructorPanelViewModel( - generators=frozenset(config.generation.generators), + channels=frozenset(config.generation.channels), drive=config.generation.drive, ) ) @@ -501,9 +501,9 @@ def save_browser_shape(self) -> None: def refresh_browser(self) -> None: self._explorer_panel.refresh() - def toggle_generator(self, generator: GeneratorName) -> None: - """Switches one generator in or out of the set a reconstruction is built from.""" - self._reconstructor_panel.toggle_generator(generator) + def toggle_channel(self, channel: ChannelName) -> None: + """Switches one channel in or out of the set a reconstruction is built from.""" + self._reconstructor_panel.toggle_channel(channel) def toggle_advanced_settings(self) -> None: advanced_settings = self._session_manager.toggle_show_advanced_settings() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 53a77dbca..8fa8243a3 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -79,7 +79,7 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend @@ -226,7 +226,7 @@ def __init__( self._browser_panel.on_directory_remove_requested = self._request_remove_directory self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source - self._reconstruction_plot_panel.on_generators_changed = self._reconstruction_panel_logic.set_selected_generators + self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels self._browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view @@ -351,9 +351,9 @@ def _open_export_instrument_dialog( self, default_filename: str, default_path: str, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> None: - """Prompts for the file the ``generator_name`` slice is written to. + """Prompts for the file the ``channel_name`` slice is written to. Every format that writes a single slice is offered at once, so the type picked in the dialog names the tracker the slice is written for. @@ -364,7 +364,7 @@ def _open_export_instrument_dialog( default_filename=default_filename, filters=self._instrument_filters(), ) - self._handle_export_instrument(filepath, generator_name) + self._handle_export_instrument(filepath, channel_name) def _instrument_filters(self) -> Tuple[FileFilter, ...]: """The types a destination for one slice may be given, one per tracker offered. @@ -380,9 +380,9 @@ def _instrument_filters(self) -> Tuple[FileFilter, ...]: def _handle_export_instrument( self, filepath: Path, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> None: - self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath, generator_name) + self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath, channel_name) def _open_export_instruments_dialog( self, @@ -635,9 +635,9 @@ def update_reconstruction(self) -> None: def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) - def toggle_generator(self, generator: GeneratorName) -> None: - """Switches one generator's slice in and out of the waveform and of what plays.""" - self._reconstruction_plot_panel.toggle_generator(generator) + def toggle_channel(self, channel: ChannelName) -> None: + """Switches one channel's slice in and out of the waveform and of what plays.""" + self._reconstruction_plot_panel.toggle_channel(channel) @property def player(self) -> AudioPlayerProtocol: diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 5d16f7c12..40afe11c9 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -124,7 +124,7 @@ HistoryDetailWordSegment, ) from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -421,9 +421,9 @@ def channels(self) -> SequencerChannelsViewModel: """The mute set the tables show, for the menu bar that lists the same channels.""" return self._sequencer_channels_logic.build_channels() - def toggle_channel(self, generator: GeneratorName) -> None: + def toggle_channel(self, channel: ChannelName) -> None: """Flips one channel between audible and silent, the menu's per-channel gesture.""" - self._sequencer_channels_logic.toggle(generator) + self._sequencer_channels_logic.toggle(channel) def unmute_all_channels(self) -> None: """Returns every channel to audible, the menu's whole-mix gesture.""" @@ -768,15 +768,15 @@ def wrapped( def _cell_key( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> CoalesceKey: """Identifies one cell of the displayed frame as a coalescing target. - The sample column (``generator`` absent) is its own target, distinct from + The sample column (``channel`` absent) is its own target, distinct from every channel column. """ - channel = generator if generator is not None else "" - return (self._sequencer_tracker_logic.frame_index, channel, row_index) + channel_key = channel if channel is not None else "" + return (self._sequencer_tracker_logic.frame_index, channel_key, row_index) def _adjustment_key( self, @@ -800,7 +800,7 @@ def _adjustment_key( def _edit_row_key( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], sample_id: Optional[str], transpose: Optional[int], volume: Optional[int], @@ -812,7 +812,7 @@ def _edit_row_key( separate entries. """ return ( - *self._cell_key(row_index, generator), + *self._cell_key(row_index, channel), sample_id is not None, transpose is not None, volume is not None, @@ -874,13 +874,13 @@ def refresh_history(self) -> None: def reconstruction_edit_detail( self, sample_id: str, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, ) -> HistoryDetail: """Describes a reconstruction edit for the project history's detail line.""" return self._history_detail.edit_reconstruction( sample_id, - generator_name, + channel_name, feature_key, ) diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py index 8cc234eb3..6bdbac1ff 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py +++ b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py @@ -7,5 +7,5 @@ class SequencerTableCells(BaseModel, extra="forbid", frozen=True): row: int sample: int divider: int - generator: int + channel: int instrument: InstrumentColumnWidths diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index edb7361e7..e945abdf7 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -134,7 +134,7 @@ def start_conversion(self) -> None: logger.warning("A conversion or library generation is already in progress") return - if not self._config_manager.config.generation.generators: + if not self._config_manager.config.generation.channels: self.call(self.on_no_generators) return diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index fd6b4b954..e643c9b82 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Iterator, Optional -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE from sampletones_core.project import Project from sampletones_core.project.instruments.sample import Sample @@ -258,28 +258,28 @@ def move_sample(self, sample_id: str, to_index: int) -> None: self._announce(self.on_samples_changed) self._announce(self.on_song_changed) - def add_pattern(self, generator: GeneratorName) -> int: - index = self.song.add_pattern(generator) + def add_pattern(self, channel: ChannelName) -> int: + index = self.song.add_pattern(channel) self._touch() self._announce(self.on_song_changed) return index def clone_pattern( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, ) -> int: - clone_index = self.song.clone_pattern(generator, pattern_index) + clone_index = self.song.clone_pattern(channel, pattern_index) self._touch() self._announce(self.on_song_changed) return clone_index def remove_pattern( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, ) -> None: - self.song.remove_pattern(generator, pattern_index) + self.song.remove_pattern(channel, pattern_index) self._touch() self._announce(self.on_song_changed) @@ -297,7 +297,7 @@ def _clamp_volume(self, volume: Optional[int]) -> Optional[int]: def _existing_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, ) -> Row: @@ -307,7 +307,7 @@ def _existing_row( clear edits treat that as a blank row, and :meth:`set_row` materialises the pattern when it writes. """ - pattern = self.song.pattern(generator, pattern_index) + pattern = self.song.pattern(channel, pattern_index) if pattern is None: return Row() @@ -315,7 +315,7 @@ def _existing_row( def set_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, *, @@ -333,12 +333,12 @@ def set_row( transpose=self._clamp_transpose(transpose), volume=self._clamp_volume(volume), ) - channel = self.song[generator] - channel.ensure_pattern( + channel_pool = self.song[channel] + channel_pool.ensure_pattern( pattern_index, self.song.rows_per_pattern, ) - channel.set_row( + channel_pool.set_row( pattern_index, row_index, row, @@ -348,7 +348,7 @@ def set_row( def update_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, *, @@ -362,9 +362,9 @@ def update_row( single subcolumn while the rest of the row carries over. For clearing a subcolumn, :meth:`set_row` interprets ``None`` as "clear". """ - existing = self._existing_row(generator, pattern_index, row_index) + existing = self._existing_row(channel, pattern_index, row_index) self.set_row( - generator, + channel, pattern_index, row_index, command=command if command is not None else existing.command, @@ -374,7 +374,7 @@ def update_row( def clear_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, *, @@ -388,9 +388,9 @@ def clear_row( subcolumn to keep its current value. With no selectors this is the inverse of :meth:`update_row`. """ - existing = self._existing_row(generator, pattern_index, row_index) + existing = self._existing_row(channel, pattern_index, row_index) self.set_row( - generator, + channel, pattern_index, row_index, command=None if instrument else existing.command, @@ -410,11 +410,11 @@ def insert_frame(self, position: int) -> None: def set_order_entry( self, - generator: GeneratorName, + channel: ChannelName, position: int, pattern_index: Optional[int], ) -> None: - self.song.set_order_entry(position, generator, pattern_index) + self.song.set_order_entry(position, channel, pattern_index) self._touch() self._announce(self.on_song_changed) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py index a214fe22d..d249ec17e 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py @@ -1,6 +1,6 @@ from sampletones_application.logic.reconstruction.browser.tree.configurations.naming import ( assign_display_names, - disambiguate_generator_siblings, + disambiguate_channel_siblings, ) from sampletones_application.logic.reconstruction.browser.tree.containers import ( find_or_create_config_group, @@ -32,7 +32,7 @@ def organize_top_level_config_directories(branch: TreeNode) -> None: case FileSystemNode() if child.node_type == NodeType.DIRECTORY: assign_display_names(child) - disambiguate_generator_siblings(branch) + disambiguate_channel_siblings(branch) def _attach_config_directory_under_groups( diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py index aac9cec3c..59ef9f26f 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py @@ -4,7 +4,7 @@ from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode -def disambiguate_generator_siblings(node: TreeNode) -> None: +def disambiguate_channel_siblings(node: TreeNode) -> None: """Appends a short config hash to generator directories sharing a name under one method group.""" if node.node_type == NodeType.GROUP: _rename_config_directories( @@ -12,7 +12,7 @@ def disambiguate_generator_siblings(node: TreeNode) -> None: ) for child in node.children: - disambiguate_generator_siblings(child) + disambiguate_channel_siblings(child) def assign_display_names(node: TreeNode) -> None: diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index e74ddf556..0ea6cf8c3 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -8,7 +8,7 @@ from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.audio import load_audio from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from sampletones_shared.logger import logger @@ -145,5 +145,5 @@ def waveform_data(self) -> WaveformData: frame_length=self.reconstruction.config.frame_length, ) - def get_partials(self, generator_names: List[GeneratorName]) -> np.ndarray: - return self.waveform_data().partials(generator_names) + def get_partials(self, channel_names: List[ChannelName]) -> np.ndarray: + return self.waveform_data().partials(channel_names) diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py index a6a653f61..08d35bd8d 100644 --- a/src/sampletones_application/logic/reconstruction/feature.py +++ b/src/sampletones_application/logic/reconstruction/feature.py @@ -5,7 +5,7 @@ import numpy as np -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction @@ -18,18 +18,18 @@ class FeatureData: for any of them and :attr:`Features.has_frames` says which ones play. """ - generators: Dict[GeneratorName, Features] + channels: Dict[ChannelName, Features] - def __getitem__(self, generator_name: GeneratorName) -> Features: - return self.generators[generator_name] + def __getitem__(self, channel_name: ChannelName) -> Features: + return self.channels[channel_name] @classmethod def load(cls, reconstruction: Reconstruction) -> FeatureData: exported_features = reconstruction.export() - generators = {} + channels = {} for generator_name_str, features in exported_features.items(): - generator_name = GeneratorName(generator_name_str) + channel_name = ChannelName(generator_name_str) feature = Features( initial_pitch=cast(int, features.get(FeatureKey.INITIAL_PITCH)), volume=cast(np.ndarray, features.get(FeatureKey.VOLUME)), @@ -39,6 +39,6 @@ def load(cls, reconstruction: Reconstruction) -> FeatureData: duty_cycle=cast(Optional[np.ndarray], features.get(FeatureKey.DUTY_CYCLE)), ) - generators[generator_name] = feature + channels[channel_name] = feature - return cls(generators=generators) + return cls(channels=channels) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index aec69a882..54481a355 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -14,14 +14,14 @@ ReconstructionUpdate, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.formats.famitracker.footprint import features_footprint from sampletones_core.types.feature import FeatureValue from sampletones_shared.utils.callbacks import CallbackMixin OnReconstructionInstrumentUpdatedCallback = Callable[ - [GeneratorName, Features, FeatureKey, FeatureValue], + [ChannelName, Features, FeatureKey, FeatureValue], None, ] @@ -39,13 +39,13 @@ def __init__( self._pending_reconstruction_update: Optional[ReconstructionUpdate] = None self.on_view_changed: Optional[Callable[[ReconstructionInstrumentsViewModel], None]] = None - self.on_feature_data_changed: Optional[Callable[[Optional[Dict[GeneratorName, Features]]], None]] = None + self.on_feature_data_changed: Optional[Callable[[Optional[Dict[ChannelName, Features]]], None]] = None self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None def update_display(self) -> None: - generators = self._current_generators() - self.call(self.on_view_changed, self._build_view_model(generators)) - self.call(self.on_feature_data_changed, generators) + channels = self._current_generators() + self.call(self.on_view_changed, self._build_view_model(channels)) + self.call(self.on_feature_data_changed, channels) def refresh_view(self) -> None: """Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are. @@ -56,33 +56,33 @@ def refresh_view(self) -> None: """ self.call(self.on_view_changed, self._build_view_model(self._current_generators())) - def _current_generators(self) -> Optional[Dict[GeneratorName, Features]]: + def _current_generators(self) -> Optional[Dict[ChannelName, Features]]: feature_data = self.reconstruction_manager.current_features - return None if feature_data is None else feature_data.generators + return None if feature_data is None else feature_data.channels def _build_view_model( self, - generators: Optional[Dict[GeneratorName, Features]], + channels: Optional[Dict[ChannelName, Features]], ) -> ReconstructionInstrumentsViewModel: - if generators is None: + if channels is None: return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - playing_generators=frozenset(), + playing_channels=frozenset(), footprint=None, ) - playing_generators: FrozenSet[GeneratorName] = frozenset( - generator_name for generator_name, features in generators.items() if features.has_frames + playing_channels: FrozenSet[ChannelName] = frozenset( + channel_name for channel_name, features in channels.items() if features.has_frames ) return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - playing_generators=playing_generators, - footprint=self._build_footprint(generators), + playing_channels=playing_channels, + footprint=self._build_footprint(channels), ) def _build_footprint( self, - generators: Dict[GeneratorName, Features], + channels: Dict[ChannelName, Features], ) -> SampleFootprintViewModel: """Measures each playing channel's instrument as the size its own export writes. @@ -93,20 +93,20 @@ def _build_footprint( """ return SampleFootprintViewModel.from_footprints( { - generator_name: features_footprint(features, loop=False) - for generator_name, features in generators.items() + channel_name: features_footprint(features, loop=False) + for channel_name, features in channels.items() if features.has_frames } ) def handle_pitch_value_changed( self, - generator_name: GeneratorName, + channel_name: ChannelName, value: int, ) -> None: self._schedule_reconstruction_update( ReconstructionUpdate( - generator_name, + channel_name, FeatureKey.INITIAL_PITCH, value, ) @@ -114,14 +114,14 @@ def handle_pitch_value_changed( def handle_bar_point_clicked( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, ) -> None: - self._report_edited_size(generator_name, feature_key, data) + self._report_edited_size(channel_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( - generator_name, + channel_name, feature_key, data, ) @@ -129,14 +129,14 @@ def handle_bar_point_clicked( def handle_raw_data_changed( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, ) -> None: - self._report_edited_size(generator_name, feature_key, data) + self._report_edited_size(channel_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( - generator_name, + channel_name, feature_key, data, ) @@ -144,7 +144,7 @@ def handle_raw_data_changed( def _report_edited_size( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, ) -> None: @@ -154,16 +154,16 @@ def _report_edited_size( while the reconstruction is still being rebuilt. The regenerated instruments report again once they land, so the figures settle on the exported form. """ - generators = self._current_generators() - if generators is None: + channels = self._current_generators() + if channels is None: return self.call( self.on_view_changed, self._build_view_model( self._with_edit( - generators, - generator_name, + channels, + channel_name, feature_key, data, ) @@ -172,15 +172,15 @@ def _report_edited_size( def _with_edit( self, - generators: Dict[GeneratorName, Features], - generator_name: GeneratorName, + channels: Dict[ChannelName, Features], + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, - ) -> Dict[GeneratorName, Features]: + ) -> Dict[ChannelName, Features]: """The loaded channels with one envelope replaced, leaving the loaded ones as they are.""" - edited = generators[generator_name].model_copy(deep=True) + edited = channels[channel_name].model_copy(deep=True) edited[feature_key] = data - return {**generators, generator_name: edited} + return {**channels, channel_name: edited} def _schedule_reconstruction_update( self, @@ -204,18 +204,18 @@ def _on_reconstruction_update_scheduled(self) -> None: if self._pending_reconstruction_update is None: return - generator_name, feature_key, data = self._pending_reconstruction_update + channel_name, feature_key, data = self._pending_reconstruction_update self._pending_reconstruction_update = None self.call( self.on_reconstruction_instrument_updated, - generator_name, - self._get_features(generator_name), + channel_name, + self._get_features(channel_name), feature_key, data, ) - def _get_features(self, generator_name: GeneratorName) -> Features: + def _get_features(self, channel_name: ChannelName) -> Features: current_features = self.reconstruction_manager.current_features assert current_features is not None, "Current features should not be None" - return current_features[generator_name] + return current_features[channel_name] diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 39e0a7353..673a9d25b 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -13,7 +13,7 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.trackers.backend import TrackerBackend @@ -70,17 +70,17 @@ def __init__( self._tracker_backends = tracker_backends self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION - self._playing_generators: FrozenSet[GeneratorName] = frozenset() - self._selected_generators: List[GeneratorName] = [] + self._playing_channels: FrozenSet[ChannelName] = frozenset() + self._selected_channels: List[ChannelName] = [] self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None - self.on_waveform_load_changed: Optional[Callable[[WaveformData, List[GeneratorName]], None]] = None - self.on_waveform_update_changed: Optional[Callable[[WaveformData, List[GeneratorName]], None]] = None + self.on_waveform_load_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None + self.on_waveform_update_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None - self.on_open_export_instrument_dialog: Optional[Callable[[str, str, GeneratorName], None]] = None + self.on_open_export_instrument_dialog: Optional[Callable[[str, str, ChannelName], None]] = None self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None @@ -91,8 +91,8 @@ def display_reconstruction(self) -> None: if not reconstruction_data: return - self._playing_generators = frozenset(reconstruction_data.reconstruction.playing_generators) - self._selected_generators = self._in_channel_order(self._playing_generators) + self._playing_channels = frozenset(reconstruction_data.reconstruction.playing_channels) + self._selected_channels = self._in_channel_order(self._playing_channels) view_model = self._build_view_model(reconstruction_data) if not view_model.audio_source_enabled: @@ -103,7 +103,7 @@ def display_reconstruction(self) -> None: self.call( self.on_waveform_load_changed, reconstruction_data.waveform_data(), - self._selected_generators, + self._selected_channels, ) self._emit_audio_data() @@ -112,20 +112,20 @@ def update_reconstruction(self) -> None: if not reconstruction_data: return - self._adopt_playing_generators(frozenset(reconstruction_data.reconstruction.playing_generators)) + self._adopt_playing_channels(frozenset(reconstruction_data.reconstruction.playing_channels)) self.call(self.on_view_changed, self._build_view_model(reconstruction_data)) self.call( self.on_waveform_update_changed, reconstruction_data.waveform_data(), - self._selected_generators, + self._selected_channels, ) if self._current_audio_source != AudioSourceType.ORIGINAL: self._emit_audio_data() - def _adopt_playing_generators( + def _adopt_playing_channels( self, - playing_generators: FrozenSet[GeneratorName], + playing_channels: FrozenSet[ChannelName], ) -> None: """Carries the reader's choice of channels across an edit. @@ -133,15 +133,13 @@ def _adopt_playing_generators( whatever the reader chose for it, and one gaining its first frame joins the waveform, so the checkboxes report what plays while a deliberate choice survives. """ - selected = (set(self._selected_generators) & playing_generators) | ( - playing_generators - self._playing_generators - ) - self._playing_generators = playing_generators - self._selected_generators = self._in_channel_order(frozenset(selected)) + selected = (set(self._selected_channels) & playing_channels) | (playing_channels - self._playing_channels) + self._playing_channels = playing_channels + self._selected_channels = self._in_channel_order(frozenset(selected)) @staticmethod - def _in_channel_order(generators: FrozenSet[GeneratorName]) -> List[GeneratorName]: - return [generator_name for generator_name in GeneratorName.items() if generator_name in generators] + def _in_channel_order(channels: FrozenSet[ChannelName]) -> List[ChannelName]: + return [channel_name for channel_name in ChannelName.items() if channel_name in channels] def _build_view_model( self, @@ -150,16 +148,16 @@ def _build_view_model( reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data) return ReconstructionViewModel( reconstruction_loaded=True, - playing_generators=self._playing_generators, - selected_generators=frozenset(self._selected_generators), + playing_channels=self._playing_channels, + selected_channels=frozenset(self._selected_channels), reconstruction_file=reconstruction_file, original_audio=original_audio, ) def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION - self._playing_generators = frozenset() - self._selected_generators = [] + self._playing_channels = frozenset() + self._selected_channels = [] self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) empty_path = ReconstructionPathViewModel( @@ -170,8 +168,8 @@ def close_reconstruction(self) -> None: self.on_view_changed, ReconstructionViewModel( reconstruction_loaded=False, - playing_generators=frozenset(), - selected_generators=frozenset(), + playing_channels=frozenset(), + selected_channels=frozenset(), reconstruction_file=empty_path, original_audio=empty_path, ), @@ -182,8 +180,8 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: self._emit_audio_data() self.call(self.on_waveform_source_changed, audio_source) - def set_selected_generators(self, generators: List[GeneratorName]) -> None: - self._selected_generators = generators + def set_selected_channels(self, channels: List[ChannelName]) -> None: + self._selected_channels = channels reconstruction_data = self._reconstruction_data if not reconstruction_data: return @@ -191,39 +189,39 @@ def set_selected_generators(self, generators: List[GeneratorName]) -> None: self.call( self.on_waveform_load_changed, reconstruction_data.waveform_data(), - generators, + channels, ) self._emit_audio_data() def request_export_instrument_dialog( self, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> None: - """Asks for the destination one generator slice is written to. + """Asks for the destination one channel slice is written to. - Every tracker able to write a single slice is offered at once, so the generator travels + Every tracker able to write a single slice is offered at once, so the channel travels with the request to the dialog and back. The suggestion is the instrument's name on its own, leaving the tracker to the dialog's file-type selector and to any extension typed over it. Args: - generator_name: The generator whose slice is written. + channel_name: The channel whose slice is written. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") - if generator_name not in reconstruction_data.reconstruction.playing_generators: + if channel_name not in reconstruction_data.reconstruction.playing_channels: return - instrument_name = self._get_instrument_name(generator_name) + instrument_name = self._get_instrument_name(channel_name) default_path = str(self._session_manager.get_instrument_path()) self.call( self.on_open_export_instrument_dialog, instrument_name, default_path, - generator_name, + channel_name, ) def request_export_instruments_dialog( @@ -265,9 +263,9 @@ def request_export_wav_dialog(self) -> None: def handle_export_instrument_confirmed( self, filepath: Path, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> None: - """Writes the ``generator_name`` slice of the loaded reconstruction to ``filepath``. + """Writes the ``channel_name`` slice of the loaded reconstruction to ``filepath``. The extension picks the tracker the slice is written for, and the instrument carries the name the destination was saved under, so renaming the file in the dialog renames @@ -275,7 +273,7 @@ def handle_export_instrument_confirmed( Args: filepath: The destination the dialog was confirmed with. - generator_name: The generator whose slice is written. + channel_name: The channel whose slice is written. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -283,13 +281,13 @@ def handle_export_instrument_confirmed( return tracker_format = self._tracker_format(filepath, ExportScope.INSTRUMENT) - feature = reconstruction_data.feature_data[generator_name] + feature = reconstruction_data.feature_data[channel_name] self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, self._tracker_backends[tracker_format], - self._instrument_export(generator_name, feature, filepath.stem), + self._instrument_export(channel_name, feature, filepath.stem), ) def handle_export_instruments_confirmed( @@ -299,7 +297,7 @@ def handle_export_instruments_confirmed( ) -> None: """Writes the slice of every playing channel of the loaded reconstruction to ``destination``. - The destination names the batch: each slice takes its generator suffix from the stem, + The destination names the batch: each slice takes its channel suffix from the stem, so a format gathering the whole reconstruction into one document writes it there while one keeping an instrument per file writes its slices beside it. A channel standing by describes no frame and is written nowhere. @@ -318,11 +316,11 @@ def handle_export_instruments_confirmed( name=base_name, instruments=tuple( self._instrument_export( - generator_name, + channel_name, feature, - instrument_slice_name(base_name, generator_name), + instrument_slice_name(base_name, channel_name), ) - for generator_name, feature in reconstruction_data.feature_data.generators.items() + for channel_name, feature in reconstruction_data.feature_data.channels.items() if feature.has_frames ), nes_frequency=self._nes_frequency(), @@ -362,18 +360,18 @@ def _tracker_format( def _instrument_export( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature: Features, name: str, ) -> InstrumentExport: - """Packages one generator slice under ``name`` for a tracker backend. + """Packages one channel slice under ``name`` for a tracker backend. A reconstruction has no loop flag of its own — that belongs to a sample placed in a project — so the instrument plays its envelopes once. """ return InstrumentExport( name=name, - generator=generator_name, + channel=channel_name, features=feature, loop=False, nes_frequency=self._nes_frequency(), @@ -393,7 +391,7 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: logger.warning("No reconstruction data available for WAV export") return - audio_snapshot = reconstruction_data.get_partials(self._selected_generators) + audio_snapshot = reconstruction_data.get_partials(self._selected_channels) sample_rate = reconstruction_data.reconstruction.config.sample_rate self._session_manager.set_audio_path(filepath) self._export_service.export_wav(filepath, sample_rate, audio_snapshot) @@ -417,13 +415,13 @@ def open_reconstruction_in_explorer(self) -> None: open_path_in_explorer(filepath) - def _get_instrument_name(self, generator_name: GeneratorName) -> str: - """Names the loaded reconstruction's slice for one generator.""" + def _get_instrument_name(self, channel_name: ChannelName) -> str: + """Names the loaded reconstruction's slice for one channel.""" reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be present") - return instrument_slice_name(reconstruction_data.name, generator_name) + return instrument_slice_name(reconstruction_data.name, channel_name) def _emit_audio_data(self) -> None: audio_data = self._compute_audio_data() @@ -442,7 +440,7 @@ def _compute_audio_data(self) -> Optional[AudioData]: return AudioData.from_array(original_audio, sample_rate) - partial_approximation = reconstruction_data.get_partials(self._selected_generators) + partial_approximation = reconstruction_data.get_partials(self._selected_channels) return AudioData.from_array(partial_approximation, sample_rate) def _build_path_view_models( diff --git a/src/sampletones_application/logic/sequencer/channels.py b/src/sampletones_application/logic/sequencer/channels.py index aff9faef8..b7371316d 100644 --- a/src/sampletones_application/logic/sequencer/channels.py +++ b/src/sampletones_application/logic/sequencer/channels.py @@ -3,11 +3,11 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.utils.callbacks import CallbackMixin -ALL_CHANNELS: Final[FrozenSet[GeneratorName]] = frozenset(GeneratorName.items()) -_NO_CHANNELS: Final[FrozenSet[GeneratorName]] = frozenset() +ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) +_NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() class SequencerChannelsLogic(CallbackMixin): @@ -27,13 +27,13 @@ class SequencerChannelsLogic(CallbackMixin): """ def __init__(self) -> None: - self._muted: FrozenSet[GeneratorName] = _NO_CHANNELS - self._muted_before_solo: Optional[FrozenSet[GeneratorName]] = None + self._muted: FrozenSet[ChannelName] = _NO_CHANNELS + self._muted_before_solo: Optional[FrozenSet[ChannelName]] = None self.on_channels_changed: Optional[Callable[[SequencerChannelsViewModel], None]] = None @property - def active_channels(self) -> FrozenSet[GeneratorName]: + def active_channels(self) -> FrozenSet[ChannelName]: """The channels that sound, the mask the synthesiser mixes.""" return ALL_CHANNELS - self._muted @@ -43,19 +43,19 @@ def build_channels(self) -> SequencerChannelsViewModel: def push_channels(self) -> None: self.call(self.on_channels_changed, self.build_channels()) - def toggle(self, generator: GeneratorName) -> None: + def toggle(self, channel: ChannelName) -> None: """Flips one channel between audible and silent.""" self._muted_before_solo = None - self._apply(self._muted ^ {generator}) + self._apply(self._muted ^ {channel}) - def solo(self, generator: GeneratorName) -> None: - """Silences every other channel, restoring the previous mix once ``generator`` plays alone. + def solo(self, channel: ChannelName) -> None: + """Silences every other channel, restoring the previous mix once ``channel`` plays alone. The mute set in force when the solo starts is remembered, so a second solo of the same channel returns to it. Editing the mute set by hand adopts that set as the state a later solo returns to. """ - others = ALL_CHANNELS - {generator} + others = ALL_CHANNELS - {channel} if self._muted == others: restored = self._muted_before_solo if self._muted_before_solo is not None else _NO_CHANNELS self._muted_before_solo = None @@ -92,6 +92,6 @@ def reset(self) -> None: """Starts a fresh listening session, the state a newly opened document is heard in.""" self.unmute_all() - def _apply(self, muted: FrozenSet[GeneratorName]) -> None: + def _apply(self, muted: FrozenSet[ChannelName]) -> None: self._muted = muted self.push_channels() diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py index 53b4f2b30..cb5cb88f7 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/tracker.py +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -105,10 +105,10 @@ def _state_row( row_offset: int, ) -> str: """One row of the block, its fields in slot order and its columns held apart by a bar.""" - base = column_slot_base(slot_from_flat(region.first_slot).generator) + base = column_slot_base(slot_from_flat(region.first_slot).channel) fields: List[str] = [] for position, slot in enumerate(region.slots): - if position > 0 and slot.generator != region.slots[position - 1].generator: + if position > 0 and slot.channel != region.slots[position - 1].channel: fields.append(COLUMN_SEPARATOR) key = (row_offset, region.first_slot + position - base) @@ -180,7 +180,7 @@ def _read_rows( shape: BlockShape, ) -> Optional[TrackerBlock]: """The block a body states, each kind of subcolumn gathered into a map of its own.""" - base = column_slot_base(slot_from_flat(shape.first).generator) + base = column_slot_base(slot_from_flat(shape.first).channel) notes: Dict[BlockKey, Optional[BlockNote]] = {} transposes: Dict[BlockKey, Optional[int]] = {} volumes: Dict[BlockKey, Optional[int]] = {} diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 32f1672b8..e33bc1f15 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -17,9 +17,9 @@ HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ( + ChannelName, FeatureKey, - GeneratorName, - abbreviate_generator_names, + abbreviate_channel_names, ) from sampletones_core.utils.display import display_id, display_transpose, display_volume @@ -87,13 +87,13 @@ def __init__( def edit_row( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], sample_id: Optional[str], transpose: Optional[int], volume: Optional[int], ) -> Segments: - affected = self._edit_row_generators(generator, sample_id, row_index) - segments = list(self._location(row_index, generator, affected)) + affected = self._edit_row_channels(channel, sample_id, row_index) + segments = list(self._location(row_index, channel, affected)) if sample_id is not None: segments.append(self._arrow()) segments.append(self._sample(sample_id)) @@ -118,29 +118,29 @@ def edit_row( def note_off( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> Segments: - return self._location(row_index, generator, GeneratorName.items()) + return self._location(row_index, channel, ChannelName.items()) def clear_row( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> Segments: - return self._location(row_index, generator, GeneratorName.items()) + return self._location(row_index, channel, ChannelName.items()) def clear_subcolumn( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], subcolumn: SubColumn, ) -> Segments: affected = ( - GeneratorName.items() + ChannelName.items() if subcolumn is SubColumn.INSTRUMENT - else self._tracker_logic.relevant_generators(row_index) + else self._tracker_logic.relevant_channels(row_index) ) - segments = list(self._location(row_index, generator, affected)) + segments = list(self._location(row_index, channel, affected)) segments.append(self._subcolumn(subcolumn)) return tuple(segments) @@ -164,20 +164,20 @@ def tracker_block(self, region: TrackerRegion) -> Segments: def tracker_paste(self, cell: TrackerCell) -> Segments: """Reads as the cell a block was written from, the one place a paste chooses.""" - return self._location(cell.row, cell.generator, GeneratorName.items()) + return self._location(cell.row, cell.channel, ChannelName.items()) def order_block(self, region: OrderRegion) -> Segments: """Reads as the positions a block covers and the channels its rows reach.""" return ( self._frame_range(region.first_position, region.last_position), - self._channel(self._covered_channels(set(region.generators))), + self._channel(self._covered_channels(set(region.channels))), ) def order_paste(self, cell: OrderCell) -> Segments: """Reads as the cell a block was written from, the one place a paste chooses.""" return ( self._frame(cell.position), - self._channel(self._covered_channels({cell.generator})), + self._channel(self._covered_channels({cell.channel})), ) def add_frame(self, position: int) -> Segments: @@ -202,13 +202,13 @@ def move_frame(self, from_position: int, to_position: int) -> Segments: def set_order_entry( self, - generator: GeneratorName, + channel: ChannelName, position: int, pattern_index: Optional[int], ) -> Segments: return ( self._frame(position), - self._channel([generator]), + self._channel([channel]), self._arrow(), self._value(display_id(pattern_index)), ) @@ -220,7 +220,7 @@ def set_master_entry( ) -> Segments: return ( self._frame(position), - self._channel(GeneratorName.items()), + self._channel(ChannelName.items()), self._arrow(), self._value(display_id(pattern_index)), ) @@ -273,7 +273,7 @@ def set_sample_loop(self, sample_id: str, loop: bool) -> Segments: def edit_reconstruction( self, sample_id: str, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, ) -> Segments: """Describes a regenerated sample: its position, channel, and edited feature. @@ -284,26 +284,26 @@ def edit_reconstruction( """ return ( self._sample(sample_id, colon=True), - self._channel([generator_name]), + self._channel([channel_name]), self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]), ) def value(self, number: int) -> Segments: return (self._value(str(number)),) - def _edit_row_generators( + def _edit_row_channels( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], sample_id: Optional[str], row_index: int, - ) -> List[GeneratorName]: - if generator is not None: - return [generator] + ) -> List[ChannelName]: + if channel is not None: + return [channel] if sample_id is not None: return self._tracker_logic.used_generators(sample_id) - return self._tracker_logic.relevant_generators(row_index) + return self._tracker_logic.relevant_channels(row_index) def _tracker_region(self, region: TrackerRegion) -> Segments: """Reads a rectangle of the tracker as its frame, the channels it spans and the rows it covers. @@ -320,10 +320,10 @@ def _tracker_region(self, region: TrackerRegion) -> Segments: def _location( self, row_index: int, - generator: Optional[GeneratorName], - affected: List[GeneratorName], + channel: Optional[ChannelName], + affected: List[ChannelName], ) -> Segments: - channels = [generator] if generator is not None else affected + channels = [channel] if channel is not None else affected return ( self._frame(self._tracker_logic.frame_index), self._channel(channels), @@ -363,20 +363,20 @@ def _frame_range(self, first_position: int, last_position: int) -> HistoryDetail ) @staticmethod - def _covered_channels(covered: Set[Optional[GeneratorName]]) -> List[GeneratorName]: + def _covered_channels(covered: Set[Optional[ChannelName]]) -> List[ChannelName]: """The channels a run of columns names, an aggregate one standing for all it summarises. Both grids carry a column that answers for every channel — the tracker's sample column and the order's master row — so a gesture reaching one of them reads as the whole set. """ if None in covered: - return GeneratorName.items() + return ChannelName.items() - return [generator for generator in GeneratorName.items() if generator in covered] + return [channel for channel in ChannelName.items() if channel in covered] - def _channel(self, generators: List[GeneratorName]) -> HistoryDetailSegment: + def _channel(self, channels: List[ChannelName]) -> HistoryDetailSegment: return HistoryDetailSegment( - text=abbreviate_generator_names(generators), + text=abbreviate_channel_names(channels), role=HistoryDetailRole.CHANNEL, ) diff --git a/src/sampletones_application/logic/sequencer/order/order.py b/src/sampletones_application/logic/sequencer/order/order.py index 15d27ac5e..fd07511b3 100644 --- a/src/sampletones_application/logic/sequencer/order/order.py +++ b/src/sampletones_application/logic/sequencer/order/order.py @@ -6,7 +6,7 @@ SequencerOrderTrackerViewModel, SequencerOrderViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.song import Song from sampletones_shared.utils.callbacks import CallbackMixin @@ -26,7 +26,7 @@ def __init__(self, project_controller: ProjectController) -> None: def build_order(self) -> SequencerOrderTrackerViewModel: song = self._controller.project.song - channels = {generator: self._build_channel_view(generator, song) for generator in GeneratorName.items()} + channels = {channel: self._build_channel_view(channel, song) for channel in ChannelName.items()} return SequencerOrderTrackerViewModel( position_count=song.order_length(), channels=channels, @@ -40,19 +40,19 @@ def refresh(self) -> None: def set_order_entry( self, - generator: GeneratorName, + channel: ChannelName, position: int, pattern_index: Optional[int], ) -> None: - self._controller.set_order_entry(generator, position, pattern_index) + self._controller.set_order_entry(channel, position, pattern_index) def set_master_entry(self, position: int, pattern_index: Optional[int]) -> None: - for generator in GeneratorName.items(): - self._controller.set_order_entry(generator, position, pattern_index) + for channel in ChannelName.items(): + self._controller.set_order_entry(channel, position, pattern_index) def write_entry( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], position: int, pattern_index: Optional[int], ) -> None: @@ -61,18 +61,18 @@ def write_entry( This is the rule the table's two kinds of row follow, kept in one place so a gesture reaching across them writes what the reader typing into each by hand would. """ - if generator is None: + if channel is None: self.set_master_entry(position, pattern_index) else: - self.set_order_entry(generator, position, pattern_index) + self.set_order_entry(channel, position, pattern_index) - def entry(self, generator: GeneratorName, position: int) -> Optional[int]: + def entry(self, channel: ChannelName, position: int) -> Optional[int]: """The pattern index a channel plays at a position, empty past the order's last frame.""" order = self._controller.song.order if position >= len(order): return None - return order[position].get(generator) + return order[position].get(channel) def position_count(self) -> int: return self._controller.order_length @@ -105,17 +105,17 @@ def move_frame(self, from_position: int, to_position: int) -> None: def _build_channel_view( self, - generator: GeneratorName, + channel: ChannelName, song: Song, ) -> SequencerOrderViewModel: entries = tuple( OrderEntryViewModel( position=position, - pattern_index=frame.get(generator), + pattern_index=frame.get(channel), ) for position, frame in enumerate(song.order) ) return SequencerOrderViewModel( - generator=generator, + channel=channel, entries=entries, ) diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py index c14819701..8e325044c 100644 --- a/src/sampletones_application/logic/sequencer/order/reader.py +++ b/src/sampletones_application/logic/sequencer/order/reader.py @@ -1,7 +1,7 @@ from typing import Dict, Optional from sampletones_application.view_model.sequencer.region import OrderRegion -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.utils.agreement import Agreement from .block import BlockKey, OrderBlock @@ -26,9 +26,9 @@ def read(self, region: OrderRegion) -> OrderBlock: value the paste passes by. """ entries: Dict[BlockKey, Optional[int]] = {} - for row_offset, generator in enumerate(region.generators): + for row_offset, channel in enumerate(region.channels): for position_offset, position in enumerate(region.positions): - agreement = self._agree(generator, position) + agreement = self._agree(channel, position) if agreement.is_unanimous: entries[(row_offset, position_offset)] = agreement.value @@ -36,7 +36,7 @@ def read(self, region: OrderRegion) -> OrderBlock: def _agree( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], position: int, ) -> Agreement[Optional[int]]: """What a row holds at a position: a channel's own index, or the one its channels share. @@ -45,7 +45,7 @@ def _agree( answers for every channel, which is the group its display summarises too, so a block states about a cell exactly what the table it came from shows there. """ - if generator is not None: - return Agreement.collapse([self._order.entry(generator, position)]) + if channel is not None: + return Agreement.collapse([self._order.entry(channel, position)]) - return Agreement.collapse(self._order.entry(channel, position) for channel in GeneratorName.items()) + return Agreement.collapse(self._order.entry(channel, position) for channel in ChannelName.items()) diff --git a/src/sampletones_application/logic/sequencer/order/writer.py b/src/sampletones_application/logic/sequencer/order/writer.py index 5e4a90de3..e108d3851 100644 --- a/src/sampletones_application/logic/sequencer/order/writer.py +++ b/src/sampletones_application/logic/sequencer/order/writer.py @@ -37,9 +37,9 @@ def clear(self, region: OrderRegion) -> None: The order keeps its length, so emptying the frames at its end leaves them standing as silent ones rather than taking positions away from the arrangement. """ - for generator in region.generators: + for channel in region.channels: for position in region.positions: - self._order.write_entry(generator, position, None) + self._order.write_entry(channel, position, None) def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]: """Where each of a block's entries lands, in the reading order they are written in. @@ -49,7 +49,7 @@ def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]: is left out, which clips a block at the bottom edge rather than wrapping it round to the master row. """ - base_row = CHANNEL_AXIS.index(cell.generator) + base_row = CHANNEL_AXIS.index(cell.channel) return [ (base_row + row_offset, cell.position + position_offset, pattern_index) for (row_offset, position_offset), pattern_index in sorted(block.entries.items()) diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py index c605c8649..37e50b531 100644 --- a/src/sampletones_application/logic/sequencer/playback/protocol.py +++ b/src/sampletones_application/logic/sequencer/playback/protocol.py @@ -15,7 +15,7 @@ class ChannelGeneratorProtocol(Protocol): ``PulseGenerator``) are accepted while keeping their precise generic types. The instruction parameter is typed ``Any`` because the generator-to-instruction - pairing is a runtime invariant maintained by ``GENERATOR_CLASSES`` dispatch, which + pairing is a runtime invariant maintained by ``CHANNEL_CLASSES`` dispatch, which lies outside the static type system. ``frame_length`` is settable so the synthesiser can give each tick the span its clock diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py index 1e8ab94aa..8db60481a 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py @@ -1,8 +1,8 @@ from typing import Dict from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.generators.maps import GENERATOR_CLASSES +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators.maps import CHANNEL_CLASSES from sampletones_core.timing import TickClock from ..protocol import ChannelGeneratorProtocol @@ -23,9 +23,9 @@ def __init__(self, config: Config, rates: EngineRates) -> None: self._config = config self._rates = rates self._clock: TickClock = rates.clock() - self._states: Dict[GeneratorName, ChannelState] = { - generator_name: ChannelState(generator=generator) - for generator_name, generator in self._build_generators(rates).items() + self._states: Dict[ChannelName, ChannelState] = { + channel_name: ChannelState(generator=generator) + for channel_name, generator in self._build_generators(rates).items() } @property @@ -33,9 +33,9 @@ def clock(self) -> TickClock: """The samples each tick spans at the rates in force.""" return self._clock - def state(self, generator_name: GeneratorName) -> ChannelState: - """What ``generator_name`` carries from row to row.""" - return self._states[generator_name] + def state(self, channel_name: ChannelName) -> ChannelState: + """What ``channel_name`` carries from row to row.""" + return self._states[channel_name] def reset(self) -> None: """Returns every channel to silence at full volume, as a song starts them.""" @@ -63,20 +63,20 @@ def follow(self, rates: EngineRates) -> None: self._rates = rates self._clock = rates.clock() - for generator_name, generator in self._build_generators(rates).items(): - self._states[generator_name].generator = generator + for channel_name, generator in self._build_generators(rates).items(): + self._states[channel_name].generator = generator def _build_generators( self, rates: EngineRates, - ) -> Dict[GeneratorName, ChannelGeneratorProtocol]: + ) -> Dict[ChannelName, ChannelGeneratorProtocol]: config = self._engine_config(rates) return { - generator_name: GENERATOR_CLASSES[generator_name]( + channel_name: CHANNEL_CLASSES[channel_name]( config, - generator_name.value, + channel_name.value, ) - for generator_name in GeneratorName.items() + for channel_name in ChannelName.items() } def _engine_config(self, rates: EngineRates) -> Config: diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 2e1de2d80..411164afe 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -6,7 +6,7 @@ from sampletones_application.logic.shared.project_source import ProjectSource from sampletones_core.audio import clip_audio_inplace, silence from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.instructions import InstructionUnion from sampletones_core.project import Project @@ -64,7 +64,7 @@ def __init__( project_source: ProjectSource, config: Config, *, - active_channels: Callable[[], FrozenSet[GeneratorName]], + active_channels: Callable[[], FrozenSet[ChannelName]], sample_rate: Callable[[], int], ) -> None: self._project_source = project_source @@ -176,9 +176,9 @@ def _mix_channels( channels: ChannelBank, ) -> np.ndarray: mixed = silence(frames.total) - for generator_name in GeneratorName.items(): + for channel_name in ChannelName.items(): channel_audio = self._render_channel( - generator_name, + channel_name, project, song, frames, @@ -190,43 +190,43 @@ def _mix_channels( def _render_channel( self, - generator_name: GeneratorName, + channel_name: ChannelName, project: Project, song: Song, frames: RowFrames, channels: ChannelBank, ) -> np.ndarray: - state = channels.state(generator_name) + state = channels.state(channel_name) - row = self._resolve_row(generator_name, song) + row = self._resolve_row(channel_name, song) if row is not None: self._apply_row_to_state(state, row) sample_id = state.sample_id - if sample_id is None or generator_name not in self._active_channels(): + if sample_id is None or channel_name not in self._active_channels(): return silence(frames.total) return self._synthesize_ticks( state, sample_id, project, - generator_name, + channel_name, frames, ) def _resolve_row( self, - generator_name: GeneratorName, + channel_name: ChannelName, song: Song, ) -> Optional[Row]: if self._position.order_position >= song.order_length(): return None - order_entry = song.order[self._position.order_position].get(generator_name) + order_entry = song.order[self._position.order_position].get(channel_name) if order_entry is None: return None - pattern = song.pattern(generator_name, order_entry) + pattern = song.pattern(channel_name, order_entry) if pattern is None or self._position.row_index >= len(pattern.rows): return None @@ -255,18 +255,18 @@ def _synthesize_ticks( state: ChannelState, sample_id: str, project: Project, - generator_name: GeneratorName, + channel_name: ChannelName, frames: RowFrames, ) -> np.ndarray: sample = project.sample(sample_id) if sample is None: return silence(frames.total) - instructions = sample.reconstruction.instructions[generator_name] + instructions = sample.reconstruction.instructions[channel_name] if not instructions: return silence(frames.total) - voice = SampleVoice.read(sample.reconstruction, generator_name) + voice = SampleVoice.read(sample.reconstruction, channel_name) output = silence(frames.total) silence_frame = silence(frames.longest) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py index 51b348328..cb5dfde8d 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from typing import Dict, Tuple -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, ExporterTypeUnion +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, ExporterTypeUnion from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction @@ -33,21 +33,21 @@ class SampleVoice: def read( cls, reconstruction: Reconstruction, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> SampleVoice: """The voice one channel of ``reconstruction`` is played through. Args: reconstruction: The sample's reconstruction. - generator_name: The channel being sounded. + channel_name: The channel being sounded. Returns: SampleVoice: The reading of that channel's frames. """ return cls( - exporter=GENERATOR_NAME_TO_EXPORTER_MAP[generator_name], - initial_pitch=reconstruction.initial_pitches[generator_name], - held_features=reconstruction.held_features[generator_name], + exporter=CHANNEL_TO_EXPORTER_MAP[channel_name], + initial_pitch=reconstruction.initial_pitches[channel_name], + held_features=reconstruction.held_features[channel_name], ) def sound( diff --git a/src/sampletones_application/logic/sequencer/tracker/adjuster.py b/src/sampletones_application/logic/sequencer/tracker/adjuster.py index 3e77e4b5f..aa9681c72 100644 --- a/src/sampletones_application/logic/sequencer/tracker/adjuster.py +++ b/src/sampletones_application/logic/sequencer/tracker/adjuster.py @@ -1,7 +1,7 @@ from typing import Iterator, List, Optional, Tuple from sampletones_application.view_model.sequencer.region import TrackerRegion -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from .tracker import SequencerTrackerLogic @@ -23,34 +23,34 @@ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: def adjust_transpose(self, region: TrackerRegion, delta: int) -> None: """Shifts every covered cell's transpose by ``delta`` semitones.""" - for row_index, generator in self._cells(region): - self._tracker.adjust_transpose(generator, row_index, delta) + for row_index, channel in self._cells(region): + self._tracker.adjust_transpose(channel, row_index, delta) def adjust_volume(self, region: TrackerRegion, delta: int) -> None: """Shifts every covered cell's volume by ``delta``.""" - for row_index, generator in self._cells(region): - self._tracker.adjust_volume(generator, row_index, delta) + for row_index, channel in self._cells(region): + self._tracker.adjust_volume(channel, row_index, delta) def _cells( self, region: TrackerRegion, - ) -> Iterator[Tuple[int, GeneratorName]]: + ) -> Iterator[Tuple[int, ChannelName]]: """The channel cells a region reaches, row by row and each named once.""" columns = region.columns for row_index in region.rows: - for generator in self._channels(columns, row_index): - yield row_index, generator + for channel in self._channels(columns, row_index): + yield row_index, channel def _channels( self, - columns: Tuple[Optional[GeneratorName], ...], + columns: Tuple[Optional[ChannelName], ...], row_index: int, - ) -> List[GeneratorName]: + ) -> List[ChannelName]: """The channels a row's columns reach, the sample column standing for the ones it governs.""" - channels: List[GeneratorName] = [] + channels: List[ChannelName] = [] for column in columns: if column is None: - channels.extend(self._tracker.relevant_generators(row_index)) + channels.extend(self._tracker.relevant_channels(row_index)) else: channels.append(column) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index c0ef980dc..70b085e83 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -7,7 +7,7 @@ slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row @@ -31,7 +31,7 @@ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: def read(self, region: TrackerRegion) -> TrackerBlock: """Takes the values a region covers, keeping each kind of subcolumn in a map of its own.""" - base = column_slot_base(slot_from_flat(region.first_slot).generator) + base = column_slot_base(slot_from_flat(region.first_slot).channel) return TrackerBlock( notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of), transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), @@ -57,7 +57,7 @@ def _read_subcolumn( if slot.subcolumn is not subcolumn: continue - agreement = self._agree(row_index, slot.generator, select) + agreement = self._agree(row_index, slot.channel, select) if agreement.is_unanimous: values[(row_offset, region.first_slot + position - base)] = agreement.value @@ -66,7 +66,7 @@ def _read_subcolumn( def _agree( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], select: Callable[[Optional[Row]], ValueT], ) -> Agreement[ValueT]: """What a column holds at a cell: a channel's own value, or the one its channels share. @@ -75,11 +75,11 @@ def _agree( column answers for the channels it governs, which is the group its display summarises too, so a block states about a cell exactly what the grid it came from shows there. """ - if generator is not None: - return Agreement.collapse([select(self._tracker.row(generator, row_index))]) + if channel is not None: + return Agreement.collapse([select(self._tracker.row(channel, row_index))]) return Agreement.collapse( - select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_generators(row_index) + select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_channels(row_index) ) @staticmethod diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index b9ad587ff..e510b8d89 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -10,7 +10,7 @@ SequencerRowViewModel, SequencerTrackerViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff @@ -40,8 +40,8 @@ class SequencerTrackerLogic(CallbackMixin): controller's change events are wired (by the coordinator) back to the push methods here, so a single mutation round-trips into a refreshed view. - Cell-level edits take an ``Optional[GeneratorName]`` naming the column they - address: a generator reaches that channel alone, while ``None`` addresses the + Cell-level edits take an ``Optional[ChannelName]`` naming the column they + address: a channel reaches that channel alone, while ``None`` addresses the sample column and spreads the edit over the channels that column governs. """ @@ -98,7 +98,7 @@ def frame_row_count(self) -> int: return song.rows_per_pattern - def _frame_patterns(self) -> Dict[GeneratorName, Pattern]: + def _frame_patterns(self) -> Dict[ChannelName, Pattern]: """The patterns the current frame's channels point at. A channel contributes an entry once its slot names a pattern the song holds, @@ -108,12 +108,12 @@ def _frame_patterns(self) -> Dict[GeneratorName, Pattern]: if self._frame_index >= song.order_length(): return {} - patterns: Dict[GeneratorName, Pattern] = {} - for generator in GeneratorName.items(): - index = song.order[self._frame_index].get(generator) - pattern = song.pattern(generator, index) if index is not None else None + patterns: Dict[ChannelName, Pattern] = {} + for channel in ChannelName.items(): + index = song.order[self._frame_index].get(channel) + pattern = song.pattern(channel, index) if index is not None else None if pattern is not None: - patterns[generator] = pattern + patterns[channel] = pattern return patterns @@ -144,17 +144,17 @@ def set_speed(self, speed: int) -> None: def clear_cell( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: - if generator is None: - self.clear_all_generators(row_index) + if channel is None: + self.clear_all_channels(row_index) else: - self.clear_row(generator, row_index) + self.clear_row(channel, row_index) def clear_cell_subcolumn( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], subcolumn: SubColumn, ) -> None: """Empties one subcolumn of a cell. @@ -166,9 +166,9 @@ def clear_cell_subcolumn( instrument = subcolumn is SubColumn.INSTRUMENT transpose = subcolumn is SubColumn.TRANSPOSE volume = subcolumn is SubColumn.VOLUME - if generator is not None: + if channel is not None: self.clear_subcolumn( - generator, + channel, row_index, instrument=instrument, transpose=transpose, @@ -186,7 +186,7 @@ def clear_cell_subcolumn( def write_cell( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], sample_id: Optional[str], transpose: Optional[int], volume: Optional[int], @@ -197,11 +197,11 @@ def write_cell( arrives, and an offset lands on its own otherwise. """ if sample_id is not None: - self.place_note(row_index, generator, sample_id) + self.place_note(row_index, channel, sample_id) elif transpose is not None or volume is not None: self.set_cell_subcolumn( row_index, - generator, + channel, transpose=transpose, volume=volume, ) @@ -209,40 +209,40 @@ def write_cell( def place_note( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], sample_id: str, ) -> None: - if generator is None: + if channel is None: self.set_sample_instrument(row_index, sample_id) else: self.set_row( - generator, + channel, row_index, command=Instrument( sample_id=sample_id, - generator_name=generator, + channel_name=channel, ), ) def cut_note( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: - if generator is None: + if channel is None: self.set_note_off_all_generators(row_index) else: - self.set_note_off(generator, row_index) + self.set_note_off(channel, row_index) def set_cell_subcolumn( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], *, transpose: Optional[int] = None, volume: Optional[int] = None, ) -> None: - if generator is None: + if channel is None: self.set_sample_subcolumn( row_index, transpose=transpose, @@ -250,7 +250,7 @@ def set_cell_subcolumn( ) else: self.set_row( - generator, + channel, row_index, transpose=transpose, volume=volume, @@ -258,22 +258,22 @@ def set_cell_subcolumn( def set_row( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, *, command: Optional[NoteCommand] = None, transpose: Optional[int] = None, volume: Optional[int] = None, ) -> None: - pattern_index = self._pattern_index_at_frame(generator) + pattern_index = self._pattern_index_at_frame(channel) if pattern_index is None: - pattern_index = self._create_frame_pattern(generator) + pattern_index = self._create_frame_pattern(channel) if pattern_index is None: return self._controller.update_row( - generator, + channel, pattern_index, row_index, command=command, @@ -281,28 +281,28 @@ def set_row( volume=volume, ) - def clear_row(self, generator: GeneratorName, row_index: int) -> None: - pattern_index = self._pattern_index_at_frame(generator) + def clear_row(self, channel: ChannelName, row_index: int) -> None: + pattern_index = self._pattern_index_at_frame(channel) if pattern_index is None: return - self._controller.clear_row(generator, pattern_index, row_index) + self._controller.clear_row(channel, pattern_index, row_index) def clear_subcolumn( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, *, instrument: bool = False, transpose: bool = False, volume: bool = False, ) -> None: - pattern_index = self._pattern_index_at_frame(generator) + pattern_index = self._pattern_index_at_frame(channel) if pattern_index is None: return self._controller.clear_row( - generator, + channel, pattern_index, row_index, instrument=instrument, @@ -310,9 +310,9 @@ def clear_subcolumn( volume=volume, ) - def clear_all_generators(self, row_index: int) -> None: - for generator in GeneratorName.items(): - self.clear_row(generator, row_index) + def clear_all_channels(self, row_index: int) -> None: + for channel in ChannelName.items(): + self.clear_row(channel, row_index) def clear_subcolumn_all_generators( self, @@ -322,9 +322,9 @@ def clear_subcolumn_all_generators( transpose: bool = False, volume: bool = False, ) -> None: - for generator in GeneratorName.items(): + for channel in ChannelName.items(): self.clear_subcolumn( - generator, + channel, row_index, instrument=instrument, transpose=transpose, @@ -339,12 +339,12 @@ def set_sample_instrument( """Places a sample across the channels its reconstruction uses. The sample column is authoritative: the instrument is written to every - generator the sample covers, and the remaining channels on that row are + channel the sample covers, and the remaining channels on that row are cleared so the row reflects exactly that sample. Clearing an empty sample id wipes the whole row. """ if sample_id is None: - self.clear_all_generators(row_index) + self.clear_all_channels(row_index) return sample = self._controller.project.samples.get(sample_id) @@ -352,27 +352,27 @@ def set_sample_instrument( return used = self._used_generators(sample) - for generator in GeneratorName.items(): - if generator in used: + for channel in ChannelName.items(): + if channel in used: self.set_row( - generator, + channel, row_index, command=Instrument( sample_id=sample_id, - generator_name=generator, + channel_name=channel, ), ) else: - self.clear_row(generator, row_index) + self.clear_row(channel, row_index) - def set_note_off(self, generator: GeneratorName, row_index: int) -> None: + def set_note_off(self, channel: ChannelName, row_index: int) -> None: """Writes a note-off into one channel's cell, materialising the pattern if needed.""" - self.set_row(generator, row_index, command=NoteOff()) + self.set_row(channel, row_index, command=NoteOff()) def set_note_off_all_generators(self, row_index: int) -> None: """Cuts every channel at this row, the sample-column counterpart of :meth:`set_note_off`.""" - for generator in GeneratorName.items(): - self.set_note_off(generator, row_index) + for channel in ChannelName.items(): + self.set_note_off(channel, row_index) def set_sample_subcolumn( self, @@ -387,9 +387,9 @@ def set_sample_subcolumn( sample's channels when one is present, and otherwise reach every channel, so a value typed in the sample column always lands somewhere. """ - for generator in self._subcolumn_generators(row_index): + for channel in self._subcolumn_generators(row_index): self.set_row( - generator, + channel, row_index, transpose=transpose, volume=volume, @@ -402,9 +402,9 @@ def clear_sample_subcolumn( transpose: bool = False, volume: bool = False, ) -> None: - for generator in self._subcolumn_generators(row_index): + for channel in self._subcolumn_generators(row_index): self.clear_subcolumn( - generator, + channel, row_index, transpose=transpose, volume=volume, @@ -412,7 +412,7 @@ def clear_sample_subcolumn( def adjust_transpose( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, delta: int, ) -> None: @@ -422,14 +422,14 @@ def adjust_transpose( ``delta``; the controller clamps the result to the transpose range. """ self.set_row( - generator, + channel, row_index, - transpose=self._current_transpose(generator, row_index) + delta, + transpose=self._current_transpose(channel, row_index) + delta, ) def adjust_volume( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, delta: int, ) -> None: @@ -439,22 +439,22 @@ def adjust_volume( the first decrement steps down from the maximum. """ self.set_row( - generator, + channel, row_index, - volume=self._current_volume(generator, row_index) + delta, + volume=self._current_volume(channel, row_index) + delta, ) def row( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, ) -> Optional[Row]: """The row stored at a cell, present while its channel holds a pattern reaching that far.""" - pattern_index = self._pattern_index_at_frame(generator) + pattern_index = self._pattern_index_at_frame(channel) if pattern_index is None: return None - pattern = self._controller.project.song.pattern(generator, pattern_index) + pattern = self._controller.project.song.pattern(channel, pattern_index) if pattern is None or row_index >= pattern.length: return None @@ -462,17 +462,17 @@ def row( def _current_transpose( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, ) -> int: - row = self.row(generator, row_index) + row = self.row(channel, row_index) if row is None or row.transpose is None: return 0 return row.transpose - def _current_volume(self, generator: GeneratorName, row_index: int) -> int: - row = self.row(generator, row_index) + def _current_volume(self, channel: ChannelName, row_index: int) -> int: + row = self.row(channel, row_index) if row is None or row.volume is None: return MAX_VOLUME @@ -490,7 +490,7 @@ def holds_sample(self, sample_id: str) -> bool: """Whether the project holds the sample a note names, which is what makes the note placeable.""" return self._controller.project.samples.get(sample_id) is not None - def used_generators(self, sample_id: str) -> List[GeneratorName]: + def used_generators(self, sample_id: str) -> List[ChannelName]: """The channels a sample provides instructions for, empty when it is unknown.""" sample = self._controller.project.samples.get(sample_id) if sample is None: @@ -498,7 +498,7 @@ def used_generators(self, sample_id: str) -> List[GeneratorName]: return self._used_generators(sample) - def relevant_generators(self, row_index: int) -> List[GeneratorName]: + def relevant_channels(self, row_index: int) -> List[ChannelName]: """The channels a sample-column subcolumn edit reaches at ``row_index``. Follows the row's sample channels when one governs it, and otherwise every @@ -506,14 +506,14 @@ def relevant_generators(self, row_index: int) -> List[GeneratorName]: """ return self._subcolumn_generators(row_index) - def _pattern_index_at_frame(self, generator: GeneratorName) -> Optional[int]: + def _pattern_index_at_frame(self, channel: ChannelName) -> Optional[int]: song = self._controller.project.song if self._frame_index < song.order_length(): - return song.order[self._frame_index].get(generator) + return song.order[self._frame_index].get(channel) return None - def _create_frame_pattern(self, generator: GeneratorName) -> Optional[int]: + def _create_frame_pattern(self, channel: ChannelName) -> Optional[int]: """Materialises a pattern for an empty slot at the current frame, on first edit. Providing content to a channel whose current frame is an empty (None) slot @@ -525,68 +525,64 @@ def _create_frame_pattern(self, generator: GeneratorName) -> Optional[int]: if self._frame_index >= song.order_length(): return None - pattern_index = self._controller.add_pattern(generator) + pattern_index = self._controller.add_pattern(channel) self._controller.set_order_entry( - generator, + channel, self._frame_index, pattern_index, ) return pattern_index - def _used_generators(self, sample: Sample) -> List[GeneratorName]: + def _used_generators(self, sample: Sample) -> List[ChannelName]: """The channels a sample's reconstruction provides instructions for.""" - return [ - generator - for generator in GeneratorName.items() - if sample.reconstruction.get_generator_instructions(generator) - ] + return [channel for channel in ChannelName.items() if sample.reconstruction.get_channel_instructions(channel)] - def _subcolumn_generators(self, row_index: int) -> List[GeneratorName]: + def _subcolumn_generators(self, row_index: int) -> List[ChannelName]: """Channels a sample-column transpose/volume edit writes to. Falls back to every channel when no sample constrains the row, mirroring - :attr:`SequencerRowViewModel.subcolumn_generators`. + :attr:`SequencerRowViewModel.subcolumn_channels`. """ - referenced = self.referenced_generators(row_index) + referenced = self.referenced_channels(row_index) if not referenced: - return GeneratorName.items() + return ChannelName.items() - return [generator for generator in GeneratorName.items() if generator in referenced] + return [channel for channel in ChannelName.items() if channel in referenced] - def referenced_generators(self, row_index: int) -> FrozenSet[GeneratorName]: + def referenced_channels(self, row_index: int) -> FrozenSet[ChannelName]: """The channels spanned by the samples a row names. Reads the row from every channel's pattern, so it reports a sample's whole span even where some of its cells stand empty. A row naming no sample - references no channel, which is what :meth:`relevant_generators` widens to + references no channel, which is what :meth:`relevant_channels` widens to every channel. """ - rows: Dict[GeneratorName, Optional[Row]] = {} - for generator in GeneratorName.items(): - pattern_index = self._pattern_index_at_frame(generator) + rows: Dict[ChannelName, Optional[Row]] = {} + for channel in ChannelName.items(): + pattern_index = self._pattern_index_at_frame(channel) pattern = ( self._controller.project.song.pattern( - generator, + channel, pattern_index, ) if pattern_index is not None else None ) - rows[generator] = pattern.rows[row_index] if pattern is not None else None + rows[channel] = pattern.rows[row_index] if pattern is not None else None return self._referenced_generators_from_rows(rows) def _referenced_generators_from_rows( self, - rows: Dict[GeneratorName, Optional[Row]], - ) -> FrozenSet[GeneratorName]: + rows: Dict[ChannelName, Optional[Row]], + ) -> FrozenSet[ChannelName]: """The channels spanned by the samples referenced on a row. Each referenced sample contributes the channels its reconstruction covers, so the sample column reasons about a sample's whole channel span, including channels whose cells are empty. """ - relevant: Set[GeneratorName] = set() + relevant: Set[ChannelName] = set() resolved: Set[str] = set() for row in rows.values(): command = row.command if row is not None else None @@ -600,7 +596,7 @@ def _referenced_generators_from_rows( resolved.add(sample_id) sample = self._controller.project.samples.get(sample_id) if sample is None: - relevant.add(command.generator_name) + relevant.add(command.channel_name) else: relevant.update(self._used_generators(sample)) @@ -609,24 +605,24 @@ def _referenced_generators_from_rows( def _build_row( self, index: int, - patterns: Dict[GeneratorName, Pattern], + patterns: Dict[ChannelName, Pattern], ) -> SequencerRowViewModel: - rows: Dict[GeneratorName, Optional[Row]] = {} - cells: Dict[GeneratorName, SequencerCellViewModel] = {} - for generator in GeneratorName.items(): - pattern = patterns.get(generator) + rows: Dict[ChannelName, Optional[Row]] = {} + cells: Dict[ChannelName, SequencerCellViewModel] = {} + for channel in ChannelName.items(): + pattern = patterns.get(channel) if pattern is not None and index < pattern.length: row = pattern.rows[index] - rows[generator] = row - cells[generator] = self._build_cell(row) + rows[channel] = row + cells[channel] = self._build_cell(row) else: - rows[generator] = None - cells[generator] = _EMPTY_CELL + rows[channel] = None + cells[channel] = _EMPTY_CELL return SequencerRowViewModel( index=index, cells=cells, - relevant_generators=self._referenced_generators_from_rows(rows), + relevant_channels=self._referenced_generators_from_rows(rows), ) def _build_cell(self, row: Row) -> SequencerCellViewModel: diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py index 22a984380..cafb88ce7 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -11,7 +11,7 @@ slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.note_off import NoteOff from .block import BlockKey, BlockNote, TrackerBlock @@ -38,7 +38,7 @@ def write(self, block: TrackerBlock, cell: TrackerCell) -> None: sample column decides the whole row, so the transposes and volumes sharing that row land on top of the channels it settled. """ - base = column_slot_base(cell.generator) + base = column_slot_base(cell.channel) self._write_pass(block.notes, cell, base, self._write_note) self._write_pass(block.transposes, cell, base, self._write_transpose) self._write_pass(block.volumes, cell, base, self._write_volume) @@ -49,7 +49,7 @@ def clear(self, region: TrackerRegion) -> None: for slot in region.slots: self._tracker.clear_cell_subcolumn( row_index, - slot.generator, + slot.channel, slot.subcolumn, ) @@ -58,7 +58,7 @@ def _write_pass( values: Dict[BlockKey, ValueT], cell: TrackerCell, base: int, - write: Callable[[int, Optional[GeneratorName], ValueT], None], + write: Callable[[int, Optional[ChannelName], ValueT], None], ) -> None: """Writes one kind of subcolumn across the block, dropping what falls outside the grid. @@ -74,12 +74,12 @@ def _write_pass( if row_index >= row_count or slot_index >= SLOT_COUNT: continue - write(row_index, slot_from_flat(slot_index).generator, value) + write(row_index, slot_from_flat(slot_index).channel, value) def _write_note( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], note: Optional[BlockNote], ) -> None: """Writes the note a cell carries: a sample by id, a cut, or the emptiness of neither. @@ -90,51 +90,51 @@ def _write_note( """ match note: case NoteOff(): - self._tracker.cut_note(row_index, generator) + self._tracker.cut_note(row_index, channel) case str() as sample_id: if self._tracker.holds_sample(sample_id): - self._tracker.place_note(row_index, generator, sample_id) + self._tracker.place_note(row_index, channel, sample_id) case None: self._tracker.clear_cell_subcolumn( row_index, - generator, + channel, SubColumn.INSTRUMENT, ) def _write_transpose( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], transpose: Optional[int], ) -> None: if transpose is None: self._tracker.clear_cell_subcolumn( row_index, - generator, + channel, SubColumn.TRANSPOSE, ) else: self._tracker.set_cell_subcolumn( row_index, - generator, + channel, transpose=transpose, ) def _write_volume( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], volume: Optional[int], ) -> None: if volume is None: self._tracker.clear_cell_subcolumn( row_index, - generator, + channel, SubColumn.VOLUME, ) else: self._tracker.set_cell_subcolumn( row_index, - generator, + channel, volume=volume, ) diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 5602803d6..ac5d2c0ac 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -10,8 +10,8 @@ ServiceSuccess, ) from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, Features +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features from sampletones_core.generators import GeneratorUnion from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction @@ -27,7 +27,7 @@ class RegeneratedInstrument: """ reconstruction: Reconstruction - generator_name: GeneratorName + channel_name: ChannelName feature_key: FeatureKey @@ -56,7 +56,7 @@ def __init__(self, priority: int = 0) -> None: def start( self, reconstruction: Reconstruction, - generator_name: GeneratorName, + channel_name: ChannelName, features: Features, feature_key: FeatureKey, value: FeatureValue, @@ -67,7 +67,7 @@ def start( return self._executor.submit( lambda: self._run( reconstruction, - generator_name, + channel_name, features, feature_key, value, @@ -83,7 +83,7 @@ def cancel(self) -> None: def _run( self, reconstruction: Reconstruction, - generator_name: GeneratorName, + channel_name: ChannelName, features: Features, feature_key: FeatureKey, value: FeatureValue, @@ -92,7 +92,7 @@ def _run( self._emit(ServiceCancelled()) return try: - exporter_class = GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] + exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] generator_class = exporter_class.get_generator_type() features[feature_key] = value @@ -100,12 +100,12 @@ def _run( List[InstructionUnion], exporter_class.from_features(features), ) - generator = generator_class(reconstruction.config, generator_name) + generator = generator_class(reconstruction.config, channel_name) audio = self._render(generator, instructions) updated = reconstruction.model_copy(deep=True) - updated.update_generator_data( - generator_name, + updated.update_channel_data( + channel_name, instructions, audio, features.initial_pitch, @@ -115,7 +115,7 @@ def _run( ServiceSuccess( value=RegeneratedInstrument( reconstruction=updated, - generator_name=generator_name, + channel_name=channel_name, feature_key=feature_key, ) ) diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 6477969c8..50b805d73 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -51,7 +51,7 @@ from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_application.viewport import ViewportManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.trackers.format import TrackerFormat from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import Callback, PathCallback @@ -98,7 +98,7 @@ class ShortcutBindings: toggle_autoplay: Callback set_follow_mode: Callable[[FollowMode], None] toggle_loop_song: Callback - toggle_channel: Callable[[GeneratorName], None] + toggle_channel: Callable[[ChannelName], None] unmute_all_channels: Callback audio_settings: Callback display_settings: Callback diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index f50d7bea7..973135f9a 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -248,4 +248,4 @@ "summary_hint", ) -PRE_MAIN_RECONSTRUCTOR_GENERATOR = "gen" +PRE_MAIN_RECONSTRUCTOR_CHANNEL = "channel" diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 90d3f9e03..cabbad612 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -74,11 +74,11 @@ Widget.PANEL, "plot", ) -TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_GENERATORS = TagName( +TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, Widget.GROUP, - "generators", + "channels", ) TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE = TagName( Page.RECONSTRUCTIONS, @@ -129,7 +129,7 @@ "sample_size", ) -PRE_RECONSTRUCTION_GENERATOR = compose_tag("reconstruction", "generator") +PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel") SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message" SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size" SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window" diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index a782ab12c..58ba202e8 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -32,7 +32,7 @@ from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.library import InstructionLibraryFragment from sampletones_shared.types.application import Sender @@ -191,13 +191,13 @@ def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> No def _extract_reconstruction_layer_data( self, waveform_data: WaveformData, - selected_generators: Optional[List[GeneratorName]] = None, + selected_channels: Optional[List[ChannelName]] = None, ) -> Tuple[Optional[np.ndarray], np.ndarray, float]: - if selected_generators is None: - selected_generators = list(waveform_data.approximations.keys()) + if selected_channels is None: + selected_channels = list(waveform_data.approximations.keys()) original_audio = waveform_data.original_audio - approximation = waveform_data.partials(selected_generators) + approximation = waveform_data.partials(selected_channels) full_approximation = waveform_data.approximation if not self.reconstruction_autoscale or original_audio is None: @@ -215,7 +215,7 @@ def _extract_reconstruction_layer_data( def _display_layers( self, waveform_data: WaveformData, - selected_generators: Optional[List[GeneratorName]] = None, + selected_channels: Optional[List[ChannelName]] = None, ) -> List[Union[ArrayLayer, InstructionLayer]]: """Builds the ordered waveform layers for the current data. @@ -225,7 +225,7 @@ def _display_layers( """ original_audio, approximation_data, _ = self._extract_reconstruction_layer_data( waveform_data, - selected_generators, + selected_channels, ) reconstruction_layer = self.reconstruction_layer(approximation_data) if original_audio is None: @@ -237,13 +237,13 @@ def _display_layers( def update_waveform_data( self, waveform_data: WaveformData, - selected_generators: Optional[List[GeneratorName]] = None, + selected_channels: Optional[List[ChannelName]] = None, ) -> None: if not isinstance(self.current_data, WaveformData): return self.current_data = waveform_data - for layer in self._display_layers(waveform_data, selected_generators): + for layer in self._display_layers(waveform_data, selected_channels): self.layers[layer.name] = layer self._update_display() @@ -251,12 +251,12 @@ def update_waveform_data( def load_waveform_data( self, waveform_data: WaveformData, - selected_generators: Optional[List[GeneratorName]] = None, + selected_channels: Optional[List[ChannelName]] = None, ) -> None: self._reconstruction_dimmed = False self.clear_layers() self.current_data = waveform_data - for layer in self._display_layers(waveform_data, selected_generators): + for layer in self._display_layers(waveform_data, selected_channels): self.add_layer(layer) def set_reconstruction_dimmed(self, dimmed: bool) -> None: diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 6a3c48f04..30547bb31 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -83,7 +83,7 @@ SingleThreadExecutor, ) from sampletones_core.configs.display import ( - format_generators, + format_channels, format_nes_frequency, format_sample_rate, format_spectrum_method, @@ -173,7 +173,7 @@ def __init__( self._lbl_detail_spectrum_method = language_manager["global.context.label.detail_spectrum_method"] self._lbl_detail_transformation_gamma = language_manager["global.context.label.detail_transformation_gamma"] self._lbl_detail_window_size = language_manager["global.context.label.detail_window_size"] - self._lbl_detail_generators = language_manager["global.context.label.detail_generators"] + self._lbl_detail_channels = language_manager["global.context.label.detail_channels"] self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"] self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None @@ -811,7 +811,7 @@ def _reconstruction_detail_items( (self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)), (self._lbl_detail_spectrum_method, format_spectrum_method(fields.sm)), (self._lbl_detail_transformation_gamma, str(fields.tg)), - (self._lbl_detail_generators, format_generators(fields.generators)), + (self._lbl_detail_channels, format_channels(fields.channels)), (self._lbl_detail_configuration, short_hash(fields.ch)), ] diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 0ef75f5d0..91b08164e 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -87,7 +87,7 @@ ) from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from sampletones_application.view_model.shared.menu import MenuBarViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback @@ -132,7 +132,7 @@ def __init__( on_play_from_start: VoidCallback, on_pause_or_resume: VoidCallback, on_stop: VoidCallback, - on_channel_muted: Callable[[GeneratorName], None], + on_channel_muted: Callable[[ChannelName], None], ) -> None: self._shortcut_manager = shortcut_manager self._fps_theme = fps_theme @@ -507,14 +507,14 @@ def _create_channels_menu(self, state: MenuBarViewModel) -> None: tag=TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS, label=self._label(MenuElements.GROUP_PLAYBACK_CHANNELS), ): - for generator, shortcut_id in CHANNEL_SHORTCUT_IDS.items(): + for channel, shortcut_id in CHANNEL_SHORTCUT_IDS.items(): self._shortcut_manager.add_menu_item( shortcut_id, - callback=partial(self._on_channel_muted, generator), - tag=self._channel_menu_item_tag(generator), - label=channel_label(self._language_manager, generator), + callback=partial(self._on_channel_muted, channel), + tag=self._channel_menu_item_tag(channel), + label=channel_label(self._language_manager, channel), check=True, - default_value=not state.channels.is_muted(generator), + default_value=not state.channels.is_muted(channel), ) dpg.add_separator() self._shortcut_manager.add_menu_item( @@ -705,8 +705,8 @@ def _update_follow_mode(self, state: MenuBarViewModel) -> None: def _update_channels(self, state: MenuBarViewModel) -> None: """Shows the mute set the sequencer's tables show: a check on every channel that sounds.""" - for generator in CHANNEL_SHORTCUT_IDS: - dpg_set_value(self._channel_menu_item_tag(generator), not state.channels.is_muted(generator)) + for channel in CHANNEL_SHORTCUT_IDS: + dpg_set_value(self._channel_menu_item_tag(channel), not state.channels.is_muted(channel)) dpg_configure_item( TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, @@ -741,9 +741,9 @@ def update_fps(self, fps: float) -> None: ) @staticmethod - def _channel_menu_item_tag(generator: GeneratorName) -> str: - """The tag of the Channels submenu item that switches ``generator``.""" - return compose_tag(TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS, generator.value) + def _channel_menu_item_tag(channel: ChannelName) -> str: + """The tag of the Channels submenu item that switches ``channel``.""" + return compose_tag(TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS, channel.value) @staticmethod def _follow_menu_item_tag(mode: FollowMode) -> str: diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 5c98d0921..b1fac84de 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -347,7 +347,7 @@ def message_function( assert isinstance(node, GeneratorNode), "Node is not a GeneratorNode" assert isinstance(parent, LibraryNode), "Generator node parent is not a LibraryNode" message = self._language_manager["instructions.library.message.status_node_generator"].format( - generator=node.generator_name, + generator=node.channel_name, library_key=parent.library_key.filename, ) case _: @@ -367,7 +367,7 @@ def _on_generator_node_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: assert isinstance(node.parent, LibraryNode), "Generator node parent is not a LibraryNode" - self.call(self.on_generator_selected, node.parent.library_key, node.generator_name) + self.call(self.on_generator_selected, node.parent.library_key, node.channel_name) if mouse_button == dpg.mvMouseButton_Right: self._show_generator_context_menu(node) @@ -450,5 +450,5 @@ def _on_load_generator( self.call( self.on_generator_selected, user_data.parent.library_key, - user_data.generator_name, + user_data.channel_name, ) diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index 398f686a3..dc24775ce 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -9,7 +9,7 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.main import ( - PRE_MAIN_RECONSTRUCTOR_GENERATOR, + PRE_MAIN_RECONSTRUCTOR_CHANNEL, TAG_MAIN_RECONSTRUCTOR_PANEL, TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, ) @@ -28,7 +28,7 @@ ) from sampletones_application.view_model.main.updates import GenerationSettingsUpdate from sampletones_core.constants.algorithm import MAX_DRIVE -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender @@ -78,27 +78,27 @@ def _setup_handlers(self) -> None: dpg.add_item_edited_handler(callback=self._on_parameter_change) def _create_generator_selection(self) -> None: - subheader(self._language_manager["main.reconstructor.label.section_generators"]) + subheader(self._language_manager["main.reconstructor.label.section_channels"]) with dpg.group(): - for generator, label, theme_tag in self._generator_chips(): - checkbox_tag = self._get_generator_checkbox_tag(generator) + for channel, label, theme_tag in self._channel_chips(): + checkbox_tag = self._get_generator_checkbox_tag(channel) dpg.add_checkbox( label=label, - default_value=generator in self._view.generators, + default_value=channel in self._view.channels, tag=checkbox_tag, callback=self._on_parameter_change, ) ThemeRegistry.get(theme_tag).bind_to_item(checkbox_tag) - def _generator_chips(self) -> List[Tuple[GeneratorName, str, str]]: + def _channel_chips(self) -> List[Tuple[ChannelName, str, str]]: return [ ( - generator_name, - channel_label(self._language_manager, generator_name), - CHANNEL_THEME_TAGS[generator_name], + channel_name, + channel_label(self._language_manager, channel_name), + CHANNEL_THEME_TAGS[channel_name], ) - for generator_name in GeneratorName.items() + for channel_name in ChannelName.items() ] def _create_drive_slider(self) -> None: @@ -131,13 +131,13 @@ def _create_tooltips(self) -> None: self._language_manager["main.reconstructor.tooltip.tooltip_drive"], ) - def toggle_generator(self, generator: GeneratorName) -> None: - """Switches one generator in or out of the set a reconstruction is built from. + def toggle_channel(self, channel: ChannelName) -> None: + """Switches one channel in or out of the set a reconstruction is built from. - This is the gesture a click on the generator's checkbox makes, reached by the key the + This is the gesture a click on the channel's checkbox makes, reached by the key the channel answers to, so the panel reports the settings either way. """ - checkbox_tag = self._get_generator_checkbox_tag(generator) + checkbox_tag = self._get_generator_checkbox_tag(channel) dpg_set_value(checkbox_tag, not dpg.get_value(checkbox_tag)) self._report_generation_settings() @@ -145,21 +145,19 @@ def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None: self._report_generation_settings() def _report_generation_settings(self) -> None: - generators = [ - generator for generator in GeneratorName if dpg.get_value(self._get_generator_checkbox_tag(generator)) - ] + channels = [channel for channel in ChannelName if dpg.get_value(self._get_generator_checkbox_tag(channel))] generation_update = GenerationSettingsUpdate( drive=float(clamp_widget_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE)), - generators=generators, + channels=channels, ) self.call(self.on_generation_settings_changed, generation_update) def update_view(self, view_model: ReconstructorPanelViewModel) -> None: self._view = view_model dpg.set_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, view_model.drive) - for generator in GeneratorName: - dpg_set_value(self._get_generator_checkbox_tag(generator), generator in view_model.generators) + for channel in ChannelName: + dpg_set_value(self._get_generator_checkbox_tag(channel), channel in view_model.channels) @staticmethod - def _get_generator_checkbox_tag(generator: GeneratorName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_GENERATOR, generator.value) + def _get_generator_checkbox_tag(channel: ChannelName) -> str: + return compose_tag(PRE_MAIN_RECONSTRUCTOR_CHANNEL, channel.value) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index c33aa6d57..021c48b5a 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -69,12 +69,12 @@ ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ( + ChannelName, FeatureKey, - GeneratorName, LibraryGeneratorName, ) from sampletones_core.exporters import Features -from sampletones_core.features import GENERATOR_KIND, resting_reference, supported_features +from sampletones_core.features import CHANNEL_GENERATOR_KIND, resting_reference, supported_features from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) @@ -88,7 +88,7 @@ from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp -OnInstrumentExportCallback = Callable[[GeneratorName], None] +OnInstrumentExportCallback = Callable[[ChannelName], None] OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None] @@ -107,9 +107,9 @@ def __init__( self._language_manager = language_manager self._status_bar = status_bar - self.generator_plots: Dict[GeneratorName, Dict[FeatureKey, GUIBarGraph]] = {} - self._pitch_steppers: Dict[GeneratorName, GUIPitchStepper] = {} - self._export_buttons: Dict[GeneratorName, GUIButton] = {} + self.channel_plots: Dict[ChannelName, Dict[FeatureKey, GUIBarGraph]] = {} + self._pitch_steppers: Dict[ChannelName, GUIPitchStepper] = {} + self._export_buttons: Dict[ChannelName, GUIButton] = {} self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE) @@ -118,7 +118,7 @@ def __init__( self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) self._graphs: Dict[str, GUIBarGraph] = {} - self._sequence_lengths: Dict[Tuple[GeneratorName, FeatureKey], int] = {} + self._sequence_lengths: Dict[Tuple[ChannelName, FeatureKey], int] = {} self._pitch_stepper_style = pitch_stepper_style self._copy_width = copy_width self._layout_graphs = layout_graphs @@ -134,9 +134,9 @@ def __init__( self.on_instrument_export: Optional[OnInstrumentExportCallback] = None self.on_reconstruction_instrument_hovered: Optional[OnReconstructionInstrumentHoveredCallback] = None - self.on_pitch_value_changed: Optional[Callable[[GeneratorName, int], None]] = None - self.on_bar_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None - self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None + self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None + self.on_bar_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None + self.on_raw_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) @@ -147,8 +147,8 @@ def __init__( language_manager, language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"], ) - self._generator_labels: Dict[GeneratorName, str] = { - generator_name: channel_label(language_manager, generator_name) for generator_name in GeneratorName.items() + self._channel_labels: Dict[ChannelName, str] = { + channel_name: channel_label(language_manager, channel_name) for channel_name in ChannelName.items() } super().__init__( @@ -232,13 +232,13 @@ def _create_size_field( tag=compose_tag(value_tag, SUF_TOOLTIP), ) - def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str: - return compose_tag(self.tab_bar_tag, generator_name) + def _get_generator_tab_tag(self, channel_name: ChannelName) -> str: + return compose_tag(self.tab_bar_tag, channel_name) - def _get_instrument_size_tag(self, generator_name: GeneratorName) -> str: + def _get_instrument_size_tag(self, channel_name: ChannelName) -> str: return compose_tag( self.tab_bar_tag, - generator_name, + channel_name, SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, ) @@ -247,80 +247,80 @@ def _get_window_tag(self, tab_tag: str) -> str: def _get_feature_group_tag( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, ) -> str: - return compose_tag(self.tab_bar_tag, generator_name, feature_key, SUF_GROUP) + return compose_tag(self.tab_bar_tag, channel_name, feature_key, SUF_GROUP) def _get_feature_text_group_tag( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, ) -> str: - return compose_tag(self.tab_bar_tag, generator_name, feature_key, SUF_GRAPH_RAW_DATA) + return compose_tag(self.tab_bar_tag, channel_name, feature_key, SUF_GRAPH_RAW_DATA) def _get_feature_text_tag(self, text_group_tag: str) -> str: return compose_tag(text_group_tag, SUF_TEXT) def _get_feature_plot_tag( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, ) -> str: - return compose_tag(self.tab_bar_tag, generator_name, feature_key, SUF_GRAPH) + return compose_tag(self.tab_bar_tag, channel_name, feature_key, SUF_GRAPH) def _setup_mouse_event_handler(self) -> None: with dpg.handler_registry(tag=self.mouse_item_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_move) - def _export_callback(self, generator_name: GeneratorName) -> VoidCallback: - """The press handler for one generator's export button. - the generator is captured in a closure, which carries one. + def _export_callback(self, channel_name: ChannelName) -> VoidCallback: + """The press handler for one channel's export button. + the channel is captured in a closure, which carries one. """ - return lambda: self.call(self.on_instrument_export, generator_name) + return lambda: self.call(self.on_instrument_export, channel_name) def _create_tabs_for_generators(self) -> None: - for generator_name in GeneratorName.items(): - self._create_generator_tab(generator_name) + for channel_name in ChannelName.items(): + self._create_generator_tab(channel_name) def _generator_kind( self, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> LibraryGeneratorName: - return GENERATOR_KIND[generator_name] + return CHANNEL_GENERATOR_KIND[channel_name] def _generator_features( self, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> List[FeatureKey]: - return supported_features(self._generator_kind(generator_name)) + return supported_features(self._generator_kind(channel_name)) - def _feature_plot_config(self, generator_name: GeneratorName, feature_key: FeatureKey) -> FeaturePlotConfig: - return self._feature_plot_configs[self._generator_kind(generator_name)][feature_key] + def _feature_plot_config(self, channel_name: ChannelName, feature_key: FeatureKey) -> FeaturePlotConfig: + return self._feature_plot_configs[self._generator_kind(channel_name)][feature_key] - def _create_generator_tab(self, generator_name: GeneratorName) -> None: - tab_tag = self._get_generator_tab_tag(generator_name) + def _create_generator_tab(self, channel_name: ChannelName) -> None: + tab_tag = self._get_generator_tab_tag(channel_name) window_tag = self._get_window_tag(tab_tag) with dpg.tab( - label=self._generator_labels[generator_name], + label=self._channel_labels[channel_name], tag=tab_tag, parent=self.tab_bar_tag, show=False, ): - self.generator_plots[generator_name] = {} + self.channel_plots[channel_name] = {} button_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab_tag) - self._export_buttons[generator_name] = GUIButton( + self._export_buttons[channel_name] = GUIButton( tag=button_tag, parent=tab_tag, label=self._language_manager["reconstructions.instruments.label.export_instrument_button"], width=-1, - callback=self._export_callback(generator_name), + callback=self._export_callback(channel_name), ) self._status_bar.bind_to_item( button_tag, self._language_manager["reconstructions.instruments.message.status_export_instrument"].format( - generator=self._generator_labels[generator_name] + channel=self._channel_labels[channel_name] ), ) @@ -329,7 +329,7 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: parent=tab_tag, height=-1, ): - self._create_generator_content(generator_name, window_tag) + self._create_generator_content(channel_name, window_tag) ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_INSTRUMENT).bind_to_item(window_tag) @@ -337,41 +337,41 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: def _create_generator_content( self, - generator_name: GeneratorName, + channel_name: ChannelName, window_tag: str, ) -> None: - initial_pitch = self._default_initial_pitch(generator_name) + initial_pitch = self._default_initial_pitch(channel_name) self._create_size_field( self._lbl_instrument_size, - self._get_instrument_size_tag(generator_name), + self._get_instrument_size_tag(channel_name), window_tag, ) - self._create_pitch_stepper(generator_name, initial_pitch, window_tag) - self._create_generator_feature_displays(generator_name, window_tag) + self._create_pitch_stepper(channel_name, initial_pitch, window_tag) + self._create_generator_feature_displays(channel_name, window_tag) - def _default_initial_pitch(self, generator_name: GeneratorName) -> int: - return resting_reference(generator_name) + def _default_initial_pitch(self, channel_name: ChannelName) -> int: + return resting_reference(channel_name) def _create_generator_feature_displays( self, - generator_name: GeneratorName, + channel_name: ChannelName, window_tag: str, ) -> None: - for feature_key in self._generator_features(generator_name): + for feature_key in self._generator_features(channel_name): self._add_generator_feature_display( - generator_name, + channel_name, feature_key, window_tag, ) def _add_generator_feature_display( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, window_tag: str, ) -> None: feature_group_tag = self._get_feature_group_tag( - generator_name, + channel_name, feature_key, ) with dpg.group( @@ -381,29 +381,29 @@ def _add_generator_feature_display( dpg.add_separator(parent=window_tag) feature_data_array = np.empty(0, dtype=np.int8) plot = self._create_feature_display( - generator_name, + channel_name, feature_key, feature_data_array, feature_group_tag, ) - self.generator_plots[generator_name][feature_key] = plot + self.channel_plots[channel_name][feature_key] = plot def _apply_pitch_display( self, - generator_name: GeneratorName, + channel_name: ChannelName, value: int, ) -> None: - stepper = self._pitch_steppers.get(generator_name) + stepper = self._pitch_steppers.get(channel_name) if stepper is not None: stepper.set_value(value) def _update_generator_plot( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, ) -> None: - plots = self.generator_plots.get(generator_name) + plots = self.channel_plots.get(channel_name) if plots is None: return @@ -411,10 +411,10 @@ def _update_generator_plot( if plot is None: return - config = self._feature_plot_config(generator_name, feature_key) + config = self._feature_plot_config(channel_name, feature_key) self._configure_plot_data( plot, - generator_name, + channel_name, feature_key, config, data, @@ -422,18 +422,18 @@ def _update_generator_plot( def _update_raw_data_text( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, ) -> None: text_group_tag = self._get_feature_text_group_tag( - generator_name, + channel_name, feature_key, ) raw_data_tag = self._get_feature_text_tag(text_group_tag) raw_data_text = self._format_data(data) dpg_set_value(raw_data_tag, raw_data_text) - self._apply_input_theme(generator_name, feature_key, len(data)) + self._apply_input_theme(channel_name, feature_key, len(data)) def update_view( self, @@ -451,17 +451,17 @@ def update_view( dpg_configure_item(self.sample_size_group_tag, show=is_loaded) self._update_sizes(view_model.footprint) - for generator_name in GeneratorName.items(): - tab_tag = self._get_generator_tab_tag(generator_name) + for channel_name in ChannelName.items(): + tab_tag = self._get_generator_tab_tag(channel_name) dpg_configure_item(tab_tag, show=is_loaded) self._apply_playing_state( - generator_name, - generator_name in view_model.playing_generators, + channel_name, + channel_name in view_model.playing_channels, ) def _apply_playing_state( self, - generator_name: GeneratorName, + channel_name: ChannelName, is_playing: bool, ) -> None: """Marks one channel's tab as playing or standing by. @@ -470,9 +470,9 @@ def _apply_playing_state( so a channel standing by stays as readable to edit as one that plays. """ theme_tag = TAG_GLOBAL_THEME_INSTRUMENT_TABS if is_playing else TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED - ThemeRegistry.get(theme_tag).bind_to_item(self._get_generator_tab_tag(generator_name)) + ThemeRegistry.get(theme_tag).bind_to_item(self._get_generator_tab_tag(channel_name)) - export_button = self._export_buttons.get(generator_name) + export_button = self._export_buttons.get(channel_name) if export_button is not None: export_button.set_enabled(is_playing) @@ -488,10 +488,10 @@ def _update_sizes( return dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes)) - for generator_name in GeneratorName.items(): - instrument_bytes = footprint.bytes_for(generator_name) + for channel_name in ChannelName.items(): + instrument_bytes = footprint.bytes_for(channel_name) dpg_set_value( - self._get_instrument_size_tag(generator_name), + self._get_instrument_size_tag(channel_name), self._format_size(instrument_bytes if instrument_bytes is not None else 0), ) @@ -500,45 +500,45 @@ def _format_size(self, byte_count: int) -> str: def update_feature_data( self, - generators: Optional[Dict[GeneratorName, Features]], + generators: Optional[Dict[ChannelName, Features]], ) -> None: if generators is None: return - for generator_name in GeneratorName.items(): - generator_features = generators.get(generator_name) + for channel_name in ChannelName.items(): + generator_features = generators.get(channel_name) if generator_features is None: continue self._update_generator_feature_data( - generator_name, + channel_name, generator_features, ) def _update_generator_feature_data( self, - generator_name: GeneratorName, + channel_name: ChannelName, generator_features: Features, ) -> None: initial_pitch = cast(int, generator_features[FeatureKey.INITIAL_PITCH]) - self._apply_pitch_display(generator_name, initial_pitch) + self._apply_pitch_display(channel_name, initial_pitch) - for feature_key in self._generator_features(generator_name): + for feature_key in self._generator_features(channel_name): self._update_generator_feature_display( - generator_name, + channel_name, generator_features, feature_key, ) def _update_generator_feature_display( self, - generator_name: GeneratorName, + channel_name: ChannelName, generator_features: Features, feature_key: FeatureKey, ) -> None: feature = self._feature_array(generator_features, feature_key) - self._update_generator_plot(generator_name, feature_key, feature) - self._update_raw_data_text(generator_name, feature_key, feature) + self._update_generator_plot(channel_name, feature_key, feature) + self._update_raw_data_text(channel_name, feature_key, feature) def _feature_array( self, @@ -550,17 +550,17 @@ def _feature_array( return np.array([], dtype=np.int8) return feature - def _pitch_kind(self, generator_name: GeneratorName) -> PitchValueKind: - return PERIOD_VALUE_KIND if generator_name == GeneratorName.NOISE else PITCH_VALUE_KIND + def _pitch_kind(self, channel_name: ChannelName) -> PitchValueKind: + return PERIOD_VALUE_KIND if channel_name == ChannelName.NOISE else PITCH_VALUE_KIND def _create_pitch_stepper( self, - generator_name: GeneratorName, + channel_name: ChannelName, initial_pitch: int, parent: str, ) -> None: - is_noise = generator_name == GeneratorName.NOISE - kind = self._pitch_kind(generator_name) + is_noise = channel_name == ChannelName.NOISE + kind = self._pitch_kind(channel_name) stepper = GUIPitchStepper( tag=parent, parent=parent, @@ -584,16 +584,16 @@ def _create_pitch_stepper( ) stepper.on_value_changed = partial( self._on_pitch_value_changed, - generator_name, + channel_name, ) - self._pitch_steppers[generator_name] = stepper + self._pitch_steppers[channel_name] = stepper def _on_pitch_value_changed( self, - generator_name: GeneratorName, + channel_name: ChannelName, value: int, ) -> None: - self.call(self.on_pitch_value_changed, generator_name, value) + self.call(self.on_pitch_value_changed, channel_name, value) def _on_mouse_move(self, _sender: Sender, _app_data: Tuple[int, int]) -> None: tab = dpg.get_value(self.tab_bar_tag) @@ -608,22 +608,22 @@ def _on_mouse_move(self, _sender: Sender, _app_data: Tuple[int, int]) -> None: def _create_feature_display( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, parent: str, ) -> GUIBarGraph: - config = self._feature_plot_config(generator_name, feature_key) + config = self._feature_plot_config(channel_name, feature_key) plot = self._add_bar_plot( parent, config, data, - generator_name, + channel_name, feature_key, ) self._add_raw_data_text( parent, - generator_name, + channel_name, feature_key, config, plot, @@ -656,10 +656,10 @@ def _add_bar_plot( parent: str, config: FeaturePlotConfig, data: np.ndarray, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, ) -> GUIBarGraph: - plot_tag = self._get_feature_plot_tag(generator_name, feature_key) + plot_tag = self._get_feature_plot_tag(channel_name, feature_key) y_min, y_max, _ = self._calculate_plot_limits(config, data) plot = GUIBarGraph( tag=plot_tag, @@ -677,7 +677,7 @@ def _add_bar_plot( self._configure_plot_data( plot, - generator_name, + channel_name, feature_key, config, data, @@ -688,15 +688,15 @@ def _add_bar_plot( def _configure_plot_data( self, plot: GUIBarGraph, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, config: FeaturePlotConfig, data: np.ndarray, ) -> None: - self._load_plot_data(plot, generator_name, feature_key, config, data) + self._load_plot_data(plot, channel_name, feature_key, config, data) plot.set_callbacks( on_bar_point_clicked=lambda data: self._on_bar_point_clicked( - generator_name, + channel_name, feature_key, data, plot.plot_tag, @@ -706,14 +706,14 @@ def _configure_plot_data( def _on_bar_point_clicked( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, plot_tag: str, ) -> None: raw_data_tag = compose_tag(plot_tag, SUF_GRAPH_RAW_DATA) dpg_set_value(raw_data_tag, self._format_data(data)) - self.call(self.on_bar_data_changed, generator_name, feature_key, data) + self.call(self.on_bar_data_changed, channel_name, feature_key, data) def _on_bar_point_hovered( self, @@ -731,14 +731,14 @@ def _on_bar_point_hovered( def _add_raw_data_text( self, parent: str, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, config: FeaturePlotConfig, plot: GUIBarGraph, data: np.ndarray, ) -> None: text_group_tag = self._get_feature_text_group_tag( - generator_name, + channel_name, feature_key, ) raw_data_text = self._format_data(data) @@ -765,7 +765,7 @@ def _add_raw_data_text( decimal=False, callback=self._parse_raw_data_input, user_data=( - generator_name, + channel_name, feature_key, config, plot, @@ -779,18 +779,18 @@ def _add_raw_data_text( ) self._status_bar.bind_to_item( raw_data_tag, - partial(self._sequence_status_message, generator_name, feature_key), + partial(self._sequence_status_message, channel_name, feature_key), ) def _sequence_status_message( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, *_args: Any, **_kwargs: Any, ) -> str: """Describes the sequence input, naming the export limit once a sequence passes it.""" - item_count = self._sequence_lengths.get((generator_name, feature_key), 0) + item_count = self._sequence_lengths.get((channel_name, feature_key), 0) if item_count > MAX_SEQUENCE_ITEMS: return self._language_manager["reconstructions.instruments.message.status_sequence_too_long"].format( instrument_feature=feature_key.capitalized, @@ -804,7 +804,7 @@ def _sequence_status_message( def _apply_input_theme( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, item_count: int, ) -> None: @@ -814,8 +814,8 @@ def _apply_input_theme( input carries the warning colour to show which part of the envelope reaches a FamiTracker file. """ - self._sequence_lengths[(generator_name, feature_key)] = item_count - text_group_tag = self._get_feature_text_group_tag(generator_name, feature_key) + self._sequence_lengths[(channel_name, feature_key)] = item_count + text_group_tag = self._get_feature_text_group_tag(channel_name, feature_key) raw_data_tag = self._get_feature_text_tag(text_group_tag) theme = self.warning_input_theme if item_count > MAX_SEQUENCE_ITEMS else self.theme theme.bind_to_item(raw_data_tag) @@ -824,9 +824,9 @@ def _parse_raw_data_input( self, sender: Sender, app_data: str, - user_data: Tuple[GeneratorName, FeatureKey, FeaturePlotConfig, GUIBarGraph], + user_data: Tuple[ChannelName, FeatureKey, FeaturePlotConfig, GUIBarGraph], ) -> None: - generator_name, feature_key, config, plot = user_data + channel_name, feature_key, config, plot = user_data data_range = config.data_range if config.data_range is not None else (-128, 127) try: @@ -836,14 +836,14 @@ def _parse_raw_data_input( dtype=np.int8, ) except ValueError: - logger.error(f"Invalid {generator_name.name} data input for {feature_key.name}: {app_data}") + logger.error(f"Invalid {channel_name.name} data input for {feature_key.name}: {app_data}") self.invalid_input_theme.bind_to_item(sender) return - self._apply_input_theme(generator_name, feature_key, len(raw_data)) + self._apply_input_theme(channel_name, feature_key, len(raw_data)) dpg.set_value(sender, self._format_data(raw_data)) - self.call(self.on_raw_data_changed, generator_name, feature_key, raw_data) - self._load_plot_data(plot, generator_name, feature_key, config, raw_data) + self.call(self.on_raw_data_changed, channel_name, feature_key, raw_data) + self._load_plot_data(plot, channel_name, feature_key, config, raw_data) def _format_data(self, data: np.ndarray) -> str: string_data = [str(clamp(int(value), -128, 127)) for value in data] @@ -852,13 +852,13 @@ def _format_data(self, data: np.ndarray) -> str: def _load_plot_data( self, plot: GUIBarGraph, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, config: FeaturePlotConfig, data: np.ndarray, ) -> None: _, _, y_ticks = self._calculate_plot_limits(config, data) - name = f"{generator_name.capitalize()}: {feature_key.capitalized}" + name = f"{channel_name.capitalize()}: {feature_key.capitalized}" plot.load_data( data=data, name=name, diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index d19132337..b7eab2ddf 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -7,9 +7,9 @@ from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.reconstructions import ( - PRE_RECONSTRUCTION_GENERATOR, + PRE_RECONSTRUCTION_CHANNEL, SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE, - TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_GENERATORS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM, ) @@ -26,7 +26,7 @@ ReconstructionViewModel, ) from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import MessageCallback @@ -47,7 +47,7 @@ def __init__( self.waveform_display: GUIWaveformGraph self._frame_length: Optional[int] = None - self.on_generators_changed: Optional[Callable[[List[GeneratorName]], None]] = None + self.on_channels_changed: Optional[Callable[[List[ChannelName]], None]] = None self.autoscale_tag = compose_tag( TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE @@ -71,7 +71,7 @@ def create_panel(self, parent: str) -> None: ): self._create_autoscale_checkbox() self._create_waveform_display() - self._create_generator_checkboxes() + self._create_channel_checkboxes() self._create_tooltips() def update_view(self, view_model: ReconstructionViewModel) -> None: @@ -80,10 +80,10 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: The channels an edit puts in play arrive already selected and one switched off by hand arrives as it was left, so the boxes report what plays without overruling a choice. """ - for generator_name in GeneratorName: - tag = self._get_generator_checkbox_tag(generator_name) - is_playing = generator_name in view_model.playing_generators - is_selected = generator_name in view_model.selected_generators + for channel_name in ChannelName: + tag = self._get_generator_checkbox_tag(channel_name) + is_playing = channel_name in view_model.playing_channels + is_selected = channel_name in view_model.selected_channels dpg_configure_item( tag, enabled=is_playing, @@ -91,14 +91,14 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: ) dpg_set_value(tag, is_selected) if is_playing: - ThemeRegistry.get(CHANNEL_THEME_TAGS[generator_name]).bind_to_item(tag) + ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(tag) else: dpg.bind_item_theme(tag, 0) def load_waveform_data( self, waveform_data: WaveformData, - generators: List[GeneratorName], + generators: List[ChannelName], ) -> None: self._frame_length = waveform_data.frame_length self.waveform_display.load_waveform_data(waveform_data, generators) @@ -106,7 +106,7 @@ def load_waveform_data( def update_waveform_data( self, waveform_data: WaveformData, - generators: List[GeneratorName], + generators: List[ChannelName], ) -> None: self.waveform_display.update_waveform_data(waveform_data, generators) @@ -154,19 +154,18 @@ def _create_waveform_display(self) -> None: status_bar=self._status_bar, ) - def _create_generator_checkboxes(self) -> None: + def _create_channel_checkboxes(self) -> None: generator_labels = { - generator_name: channel_label(self._language_manager, generator_name) - for generator_name in GeneratorName.items() + channel_name: channel_label(self._language_manager, channel_name) for channel_name in ChannelName.items() } with dpg.group( - tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_GENERATORS, + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, parent=self._body_container, horizontal=True, ): - for generator_name, label in generator_labels.items(): - tag = self._get_generator_checkbox_tag(generator_name) + for channel_name, label in generator_labels.items(): + tag = self._get_generator_checkbox_tag(channel_name) dpg.add_checkbox( label=label, tag=tag, @@ -177,7 +176,7 @@ def _create_generator_checkboxes(self) -> None: self._status_bar.bind_to_item( tag, - self._create_message_function_for_generator_checkbox(generator_name), + self._create_message_function_for_generator_checkbox(channel_name), ) def _create_tooltips(self) -> None: @@ -188,19 +187,19 @@ def _create_tooltips(self) -> None: def _create_message_function_for_generator_checkbox( self, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> MessageCallback: - tag = self._get_generator_checkbox_tag(generator_name) - name = generator_name.capitalized + tag = self._get_generator_checkbox_tag(channel_name) + name = channel_name.capitalized def message_function(*_args: Any, **_kwargs: Any) -> str: if not dpg.is_item_enabled(tag): return self._language_manager[ - "reconstructions.instruments.message.status_generator_not_available" - ].format(generator_name=name) + "reconstructions.instruments.message.status_channel_not_available" + ].format(channel_name=name) - return self._language_manager["reconstructions.instruments.message.status_generator_toggle"].format( - generator_name=name, + return self._language_manager["reconstructions.instruments.message.status_channel_toggle"].format( + channel_name=name, on_or_off=( self._language_manager["global.dialog.template.off"] if dpg.get_value(tag) @@ -211,25 +210,25 @@ def message_function(*_args: Any, **_kwargs: Any) -> str: return message_function @staticmethod - def _get_generator_checkbox_tag(generator_name: GeneratorName) -> str: - return compose_tag(PRE_RECONSTRUCTION_GENERATOR, generator_name.value) + def _get_generator_checkbox_tag(channel_name: ChannelName) -> str: + return compose_tag(PRE_RECONSTRUCTION_CHANNEL, channel_name.value) - def _read_selected_generators(self) -> List[GeneratorName]: - selected_generators: List[GeneratorName] = [] - for generator_name in GeneratorName: - if dpg.get_value(self._get_generator_checkbox_tag(generator_name)): - selected_generators.append(generator_name) + def _read_selected_generators(self) -> List[ChannelName]: + selected_channels: List[ChannelName] = [] + for channel_name in ChannelName: + if dpg.get_value(self._get_generator_checkbox_tag(channel_name)): + selected_channels.append(channel_name) - return selected_generators + return selected_channels - def toggle_generator(self, generator_name: GeneratorName) -> None: - """Switches one generator's slice in and out of the waveform and of what plays. + def toggle_channel(self, channel_name: ChannelName) -> None: + """Switches one channel's slice in and out of the waveform and of what plays. - This is the gesture a click on the generator's checkbox makes, reached by the key the - channel answers to. A generator the loaded reconstruction holds none of keeps the + This is the gesture a click on the channel's checkbox makes, reached by the key the + channel answers to. A channel the loaded reconstruction holds none of keeps the checkbox its disabled state already shows. """ - tag = self._get_generator_checkbox_tag(generator_name) + tag = self._get_generator_checkbox_tag(channel_name) if not dpg.is_item_enabled(tag): return @@ -237,8 +236,8 @@ def toggle_generator(self, generator_name: GeneratorName) -> None: self._on_generator_checkbox_changed() def _on_generator_checkbox_changed(self) -> None: - selected_generators = self._read_selected_generators() - self.call(self.on_generators_changed, selected_generators) + selected_channels = self._read_selected_generators() + self.call(self.on_channels_changed, selected_channels) def _on_autoscale_changed(self, _sender: Sender, app_data: bool) -> None: self.waveform_display.set_autoscale(app_data) diff --git a/src/sampletones_application/ui/panels/sequencer/channels.py b/src/sampletones_application/ui/panels/sequencer/channels.py index a491c76e3..c6f9efc4f 100644 --- a/src/sampletones_application/ui/panels/sequencer/channels.py +++ b/src/sampletones_application/ui/panels/sequencer/channels.py @@ -12,11 +12,11 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback -OnChannelCallback = Callable[[GeneratorName], None] +OnChannelCallback = Callable[[ChannelName], None] NOTHING_MUTED: Final[SequencerChannelsViewModel] = SequencerChannelsViewModel(muted=frozenset()) @@ -72,7 +72,7 @@ def __init__( self._on_muted = on_muted self._on_unmuted = on_unmuted - def click(self, sender: Sender, generator: Optional[GeneratorName]) -> None: + def click(self, sender: Sender, channel: Optional[ChannelName]) -> None: """Routes a click on a channel's name: plain mutes, ``Ctrl`` solos, the master name switches every channel at once. @@ -80,18 +80,18 @@ def click(self, sender: Sender, generator: Optional[GeneratorName]) -> None: reports the mix through its colour, and the edit cursor stays where it is. """ dpg.set_value(sender, False) - if generator is None: + if channel is None: self._on_toggled() return if Modifier.CTRL in capture_modifiers(): - self._on_soloed(generator) + self._on_soloed(channel) else: - self._on_mute_toggled(generator) + self._on_mute_toggled(channel) def add_menu_items( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], channels: Optional[SequencerChannelsViewModel], ) -> None: """Fills the open menu for one channel, or for the master name that stands for them all. @@ -100,15 +100,15 @@ def add_menu_items( each channel's, and either one reaches "everything" and "everything back". """ mix = channels if channels is not None else NOTHING_MUTED - if generator is not None: - self._add_channel_items(generator, mix) + if channel is not None: + self._add_channel_items(channel, mix) dpg.add_separator() self._add_all_channels_items(mix) def _add_channel_items( self, - generator: GeneratorName, + channel: ChannelName, mix: SequencerChannelsViewModel, ) -> None: """Offers the two gestures a click on this channel's name carries, each named for what it @@ -118,12 +118,12 @@ def _add_channel_items( than as a switch whose direction the user infers from the table. """ dpg.add_menu_item( - label=self._labels.unmute if mix.is_muted(generator) else self._labels.mute, - callback=lambda: self._on_mute_toggled(generator), + label=self._labels.unmute if mix.is_muted(channel) else self._labels.mute, + callback=lambda: self._on_mute_toggled(channel), ) dpg.add_menu_item( - label=self._labels.unsolo if mix.is_soloed(generator) else self._labels.solo, - callback=lambda: self._on_soloed(generator), + label=self._labels.unsolo if mix.is_soloed(channel) else self._labels.solo, + callback=lambda: self._on_soloed(channel), ) def _add_all_channels_items(self, mix: SequencerChannelsViewModel) -> None: diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 7d2ed4ca9..11e403fed 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -2,32 +2,32 @@ from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName _LEADING_TABLE_COLUMNS: Final[int] = 2 SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS DIVIDER_TABLE_COLUMN: Final[int] = SAMPLE_TABLE_COLUMN + 1 _FIRST_CHANNEL_TABLE_COLUMN: Final[int] = DIVIDER_TABLE_COLUMN + 1 _TRAILING_TABLE_COLUMNS: Final[int] = 1 -TRACKER_TABLE_COLUMNS: Final[int] = _FIRST_CHANNEL_TABLE_COLUMN + len(GeneratorName.items()) + _TRAILING_TABLE_COLUMNS +TRACKER_TABLE_COLUMNS: Final[int] = _FIRST_CHANNEL_TABLE_COLUMN + len(ChannelName.items()) + _TRAILING_TABLE_COLUMNS HEADER_TABLE_ROW: Final[int] = 0 HEADER_TABLE_ROWS: Final[int] = HEADER_TABLE_ROW + 1 -def channel_color(colors: ChannelColors, generator: GeneratorName) -> BaseColor: - match generator: - case GeneratorName.PULSE1: +def channel_color(colors: ChannelColors, channel: ChannelName) -> BaseColor: + match channel: + case ChannelName.PULSE1: return colors.pulse1 - case GeneratorName.PULSE2: + case ChannelName.PULSE2: return colors.pulse2 - case GeneratorName.TRIANGLE: + case ChannelName.TRIANGLE: return colors.triangle - case GeneratorName.NOISE: + case ChannelName.NOISE: return colors.noise -def tracker_table_column(generator: Optional[GeneratorName]) -> int: +def tracker_table_column(channel: Optional[ChannelName]) -> int: """Maps a logical column to its DPG table column index. The visual divider between the sample column and the channels occupies a table @@ -35,10 +35,10 @@ def tracker_table_column(generator: Optional[GeneratorName]) -> int: logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers only the cursor-addressable columns. """ - if generator is None: + if channel is None: return SAMPLE_TABLE_COLUMN - return _FIRST_CHANNEL_TABLE_COLUMN + GeneratorName.items().index(generator) + return _FIRST_CHANNEL_TABLE_COLUMN + ChannelName.items().index(channel) def tracker_table_row(row_index: int) -> int: diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index 4b99693ac..796873595 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -4,10 +4,10 @@ from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import SequencerCellViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id, display_transpose, display_volume -CellKey = Tuple[int, Optional[GeneratorName], SubColumn] +CellKey = Tuple[int, Optional[ChannelName], SubColumn] CellValues = Dict[CellKey, str] CELL_TITLE_SEPARATOR: Final[str] = " | " @@ -57,18 +57,16 @@ def format_committed(subcolumn: SubColumn, value: Optional[int]) -> str: def subcolumn_label( row: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], subcolumn: SubColumn, *, cursor: Optional[TrackerCursor], pending: str, cell_values: CellValues, ) -> str: - is_active = ( - cursor is not None and cursor.row == row and cursor.generator == generator and cursor.subcolumn == subcolumn - ) + is_active = cursor is not None and cursor.row == row and cursor.channel == channel and cursor.subcolumn == subcolumn stored = cell_values.get( - (row, generator, subcolumn), + (row, channel, subcolumn), _DEFAULT_LABELS[subcolumn], ) if is_active: diff --git a/src/sampletones_application/ui/panels/sequencer/input/edit.py b/src/sampletones_application/ui/panels/sequencer/input/edit.py index 3a8a2704e..ed8260d12 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/edit.py +++ b/src/sampletones_application/ui/panels/sequencer/input/edit.py @@ -3,13 +3,13 @@ from pydantic.dataclasses import dataclass from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName @dataclass(frozen=True) class EditAction: row: int - generator: Optional[GeneratorName] + channel: Optional[ChannelName] sample_index: Optional[int] transpose: Optional[int] volume: Optional[int] @@ -19,5 +19,5 @@ class EditAction: @dataclass class ClearAction: row: int - generator: Optional[GeneratorName] + channel: Optional[ChannelName] subcolumn: Optional[SubColumn] = None diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index f54cb2029..ff44a55fb 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -6,7 +6,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.state import GridInputState from sampletones_application.view_model.sequencer.region import OrderRegion -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.constants.general import HEXADECIMAL_BASE INDEX_DIGITS: Final[int] = 2 @@ -14,7 +14,7 @@ @dataclass(frozen=True) class OrderCursor: - generator: Optional[GeneratorName] + channel: Optional[ChannelName] position: int @@ -39,8 +39,8 @@ def _region_between( first: OrderCursor, second: OrderCursor, ) -> OrderRegion: - first_row = CHANNEL_AXIS.index(first.generator) - second_row = CHANNEL_AXIS.index(second.generator) + first_row = CHANNEL_AXIS.index(first.channel) + second_row = CHANNEL_AXIS.index(second.channel) return OrderRegion( first_row=min(first_row, second_row), last_row=max(first_row, second_row), @@ -49,7 +49,7 @@ def _region_between( ) def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool: - return region.covers(cell.generator, cell.position) + return region.covers(cell.channel, cell.position) def select_all(self, position_count: int) -> OrderInputState: """Selects the whole order: every channel row, across every position it holds.""" @@ -65,12 +65,12 @@ def select_row( The master row is an ordinary member of the axis here, so selecting it selects a row the way selecting a channel does. """ - return self._select_rows(cell.generator, cell.generator, position_count) + return self._select_rows(cell.channel, cell.channel, position_count) def _select_rows( self, - first_generator: Optional[GeneratorName], - last_generator: Optional[GeneratorName], + first_generator: Optional[ChannelName], + last_generator: Optional[ChannelName], position_count: int, ) -> OrderInputState: """Selects a run of rows across the whole order, the cursor landing on its far corner.""" @@ -94,7 +94,7 @@ def extend_position( new_position = value if absolute else self.cursor.position + value new_position = max(0, min(new_position, position_count - 1)) - return self.extend_to(OrderCursor(self.cursor.generator, new_position)) + return self.extend_to(OrderCursor(self.cursor.channel, new_position)) def extend_channel(self, value: int) -> OrderInputState: """Carries the selection's moving end across the channel axis, stopping at either end. @@ -105,7 +105,7 @@ def extend_channel(self, value: int) -> OrderInputState: if self.cursor is None: return self - current = CHANNEL_AXIS.index(self.cursor.generator) + current = CHANNEL_AXIS.index(self.cursor.channel) row = max(0, min(current + value, len(CHANNEL_AXIS) - 1)) return self.extend_to(OrderCursor(CHANNEL_AXIS[row], self.cursor.position)) @@ -121,7 +121,7 @@ def navigate_position( new_position = value if absolute else self.cursor.position + value new_position = max(0, min(new_position, position_count - 1)) return OrderInputState( - cursor=OrderCursor(self.cursor.generator, new_position), + cursor=OrderCursor(self.cursor.channel, new_position), pending="", ) @@ -129,7 +129,7 @@ def navigate_channel(self, value: int) -> OrderInputState: if self.cursor is None: return self - current = CHANNEL_AXIS.index(self.cursor.generator) + current = CHANNEL_AXIS.index(self.cursor.channel) new_generator = CHANNEL_AXIS[(current + value) % len(CHANNEL_AXIS)] return OrderInputState( cursor=OrderCursor(new_generator, self.cursor.position), diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py index 20570b4c2..66a5cb232 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/target.py +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -32,7 +32,7 @@ def anchor(self) -> TrackerCell: """ return TrackerCell( row=self.cell.row, - generator=self.cell.generator, + channel=self.cell.channel, ) @@ -52,6 +52,6 @@ def anchor(self) -> OrderCell: """The cell a pasted block is written from, which is the target's own channel row and position.""" return OrderCell( - generator=self.cell.generator, + channel=self.cell.channel, position=self.cell.position, ) diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py index 80117343d..a0670ed5c 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -18,7 +18,7 @@ slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_shared.constants.general import HEXADECIMAL_BASE from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS @@ -33,7 +33,7 @@ @dataclass(frozen=True) class TrackerCursor: row: int - generator: Optional[GeneratorName] + channel: Optional[ChannelName] subcolumn: SubColumn @@ -43,7 +43,7 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: case SubColumn.INSTRUMENT: return EditAction( row=cursor.row, - generator=cursor.generator, + channel=cursor.channel, sample_index=int(pending, HEXADECIMAL_BASE), transpose=None, volume=None, @@ -51,7 +51,7 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: case SubColumn.VOLUME: return EditAction( row=cursor.row, - generator=cursor.generator, + channel=cursor.channel, sample_index=None, transpose=None, volume=min(int(pending, HEXADECIMAL_BASE), MAX_VOLUME), @@ -64,7 +64,7 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: return EditAction( row=cursor.row, - generator=cursor.generator, + channel=cursor.channel, sample_index=None, transpose=sign * int(magnitude, HEXADECIMAL_BASE), volume=None, @@ -87,8 +87,8 @@ def _region_between( first: TrackerCursor, second: TrackerCursor, ) -> TrackerRegion: - first_slot = TrackerSlot(first.generator, first.subcolumn).flat_index - second_slot = TrackerSlot(second.generator, second.subcolumn).flat_index + first_slot = TrackerSlot(first.channel, first.subcolumn).flat_index + second_slot = TrackerSlot(second.channel, second.subcolumn).flat_index return TrackerRegion( first_row=min(first.row, second.row), last_row=max(first.row, second.row), @@ -97,7 +97,7 @@ def _region_between( ) def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool: - return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn)) + return region.covers(cell.row, TrackerSlot(cell.channel, cell.subcolumn)) def select_all(self, row_count: int) -> TrackerInputState: """Selects the whole frame: every row of it, across every slot the axis lays out.""" @@ -113,7 +113,7 @@ def select_column( The sample column is an ordinary member of the axis here, so selecting it selects a column the way selecting a channel does. """ - base = column_slot_base(cell.generator) + base = column_slot_base(cell.channel) return self._select_slots(base, base + len(SUBCOLUMNS) - 1, row_count) def select_subcolumn( @@ -122,7 +122,7 @@ def select_subcolumn( row_count: int, ) -> TrackerInputState: """Selects the subcolumn ``cell`` stands in: every row of it, at that one slot.""" - slot = TrackerSlot(cell.generator, cell.subcolumn).flat_index + slot = TrackerSlot(cell.channel, cell.subcolumn).flat_index return self._select_slots(slot, slot, row_count) def _select_slots( @@ -138,8 +138,8 @@ def _select_slots( first = slot_from_flat(first_slot) last = slot_from_flat(last_slot) return self.select_between( - TrackerCursor(0, first.generator, first.subcolumn), - TrackerCursor(row_count - 1, last.generator, last.subcolumn), + TrackerCursor(0, first.channel, first.subcolumn), + TrackerCursor(row_count - 1, last.channel, last.subcolumn), ) def extend_row( @@ -157,7 +157,7 @@ def extend_row( return self.extend_to( TrackerCursor( new_row, - self.cursor.generator, + self.cursor.channel, self.cursor.subcolumn, ) ) @@ -172,14 +172,14 @@ def extend_slot(self, value: int) -> TrackerInputState: return self current = TrackerSlot( - self.cursor.generator, + self.cursor.channel, self.cursor.subcolumn, ).flat_index slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) return self.extend_to( TrackerCursor( self.cursor.row, - slot.generator, + slot.channel, slot.subcolumn, ) ) @@ -198,7 +198,7 @@ def navigate_row( return TrackerInputState( cursor=TrackerCursor( new_row, - self.cursor.generator, + self.cursor.channel, self.cursor.subcolumn, ), pending="", @@ -223,21 +223,21 @@ def navigate_subcolumn( return TrackerInputState( cursor=TrackerCursor( self.cursor.row, - self.cursor.generator, + self.cursor.channel, new_sub, ), pending="", ) current = TrackerSlot( - self.cursor.generator, + self.cursor.channel, self.cursor.subcolumn, ).flat_index slot = slot_from_flat((current + value) % SLOT_COUNT) return TrackerInputState( cursor=TrackerCursor( self.cursor.row, - slot.generator, + slot.channel, slot.subcolumn, ), pending="", @@ -247,7 +247,7 @@ def navigate_column_by(self, delta: int) -> TrackerInputState: if self.cursor is None: return self - current_idx = CHANNEL_AXIS.index(self.cursor.generator) + current_idx = CHANNEL_AXIS.index(self.cursor.channel) next_idx = (current_idx + delta) % len(CHANNEL_AXIS) return TrackerInputState( cursor=TrackerCursor( @@ -285,7 +285,7 @@ def type_char( def _note_off_action(self, cursor: TrackerCursor) -> EditAction: return EditAction( row=cursor.row, - generator=cursor.generator, + channel=cursor.channel, sample_index=None, transpose=None, volume=None, @@ -341,14 +341,14 @@ def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: def clear(self) -> Tuple[TrackerInputState, ClearAction]: action = ClearAction( row=self.cursor.row if self.cursor else 0, - generator=self.cursor.generator if self.cursor else None, + channel=self.cursor.channel if self.cursor else None, ) return self.reset_pending(), action def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: action = ClearAction( row=self.cursor.row if self.cursor else 0, - generator=self.cursor.generator if self.cursor else None, + channel=self.cursor.channel if self.cursor else None, subcolumn=self.cursor.subcolumn if self.cursor else None, ) return self.reset_pending(), action diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 0be02c15a..0b4e81ce7 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -93,21 +93,21 @@ SequencerOrderTrackerViewModel, ) from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback -OrderKey = Tuple[Optional[GeneratorName], int] +OrderKey = Tuple[Optional[ChannelName], int] OnFrameSelectedCallback = Callable[[int], None] OnRemoveCallback = Callable[[int], None] OnFrameActionCallback = Callable[[int], None] OnMoveCallback = Callable[[int, int], None] -OnSetOrderEntryCallback = Callable[[GeneratorName, int, Optional[int]], None] +OnSetOrderEntryCallback = Callable[[ChannelName, int, Optional[int]], None] OnSetMasterEntryCallback = Callable[[int, Optional[int]], None] -OnChannelMuteToggledCallback = Callable[[GeneratorName], None] -OnChannelSoloedCallback = Callable[[GeneratorName], None] +OnChannelMuteToggledCallback = Callable[[ChannelName], None] +OnChannelSoloedCallback = Callable[[ChannelName], None] OnBlockRegionCallback = Callable[[OrderRegion], None] OnPasteBlockCallback = Callable[[OrderCell], None] CanPasteBlockQuery = Callable[[], bool] @@ -174,7 +174,7 @@ def __init__( self._cell_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_REGISTRY) self._label_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_HEADER) self._drag_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_DRAG) - self._label_rows: Dict[Sender, Optional[GeneratorName]] = {} + self._label_rows: Dict[Sender, Optional[ChannelName]] = {} self._entry_theme: int = 0 self._muted_entry_theme: int = 0 self._label_theme: int = 0 @@ -238,12 +238,12 @@ def _label(language_manager: LanguageManager, element: SequencerOrderElements) - def _load_row_labels(self, language_manager: LanguageManager) -> None: """Reads the name each row carries, which its label and its menu title show.""" - self._row_labels: Dict[Optional[GeneratorName], str] = { + self._row_labels: Dict[Optional[ChannelName], str] = { None: self._label(language_manager, SequencerOrderElements.ROW_MASTER), - GeneratorName.PULSE1: self._label(language_manager, SequencerOrderElements.ROW_PULSE_1), - GeneratorName.PULSE2: self._label(language_manager, SequencerOrderElements.ROW_PULSE_2), - GeneratorName.TRIANGLE: self._label(language_manager, SequencerOrderElements.ROW_TRIANGLE), - GeneratorName.NOISE: self._label(language_manager, SequencerOrderElements.ROW_NOISE), + ChannelName.PULSE1: self._label(language_manager, SequencerOrderElements.ROW_PULSE_1), + ChannelName.PULSE2: self._label(language_manager, SequencerOrderElements.ROW_PULSE_2), + ChannelName.TRIANGLE: self._label(language_manager, SequencerOrderElements.ROW_TRIANGLE), + ChannelName.NOISE: self._label(language_manager, SequencerOrderElements.ROW_NOISE), } def _load_context_labels(self, language_manager: LanguageManager) -> None: @@ -288,8 +288,8 @@ def _create_channel_switch(self, language_manager: LanguageManager) -> None: ) self._channel_switch = ChannelSwitch( labels=labels, - on_mute_toggled=lambda generator: self.call(self.on_channel_mute_toggled, generator), - on_soloed=lambda generator: self.call(self.on_channel_soloed, generator), + on_mute_toggled=lambda channel: self.call(self.on_channel_mute_toggled, channel), + on_soloed=lambda channel: self.call(self.on_channel_soloed, channel), on_toggled=lambda: self.call(self.on_channels_toggled), on_muted=lambda: self.call(self.on_channels_muted), on_unmuted=lambda: self.call(self.on_channels_unmuted), @@ -405,7 +405,7 @@ def _follow_frame(self, frame: int) -> None: return new_state = OrderInputState( - cursor=OrderCursor(cursor.generator, frame), + cursor=OrderCursor(cursor.channel, frame), ) if 0 <= frame < self._position_count: self._apply_state(new_state, notify=False) @@ -463,26 +463,26 @@ def _apply_channel_cues(self) -> None: self._tint_channel_rows() self._bind_label_themes() - for generator in GeneratorName.items(): - self._bind_channel_entry_themes(generator) + for channel in ChannelName.items(): + self._bind_channel_entry_themes(channel) def _bind_label_themes(self) -> None: - for label, generator in self._label_rows.items(): - muted = generator is not None and self._is_muted(generator) + for label, channel in self._label_rows.items(): + muted = channel is not None and self._is_muted(channel) dpg.bind_item_theme( label, self._muted_label_theme if muted else self._label_theme, ) - def _bind_channel_entry_themes(self, generator: GeneratorName) -> None: - theme = self._muted_entry_theme if self._is_muted(generator) else self._entry_theme + def _bind_channel_entry_themes(self, channel: ChannelName) -> None: + theme = self._muted_entry_theme if self._is_muted(channel) else self._entry_theme for position in range(self._position_count): - widget = self._order.widget((generator, position)) + widget = self._order.widget((channel, position)) if widget is not None: dpg.bind_item_theme(widget, theme) - def _is_muted(self, generator: GeneratorName) -> bool: - return self._current_channels is not None and self._current_channels.is_muted(generator) + def _is_muted(self, channel: ChannelName) -> bool: + return self._current_channels is not None and self._current_channels.is_muted(channel) def _compute_cell_values( self, @@ -491,9 +491,9 @@ def _compute_cell_values( cell_values: Dict[OrderKey, str] = {} for position in range(view_model.position_count): cell_values[(None, position)] = view_model.master_label(position) - for generator in GeneratorName.items(): - cell_values[(generator, position)] = view_model.entry_label( - generator, + for channel in ChannelName.items(): + cell_values[(channel, position)] = view_model.entry_label( + channel, position, ) @@ -546,7 +546,7 @@ def _build_table(self, position_count: int) -> None: label="", parent=TAG_SEQUENCER_ORDER_TABLE, width_fixed=True, - init_width_or_weight=self._layout.table_cells.generator, + init_width_or_weight=self._layout.table_cells.channel, ) for position in range(position_count): dpg.add_table_column( @@ -557,9 +557,9 @@ def _build_table(self, position_count: int) -> None: ) self._label_rows = {} - for generator in CHANNEL_AXIS: - self._build_row(generator, position_count) - if generator is None: + for channel in CHANNEL_AXIS: + self._build_row(channel, position_count) + if channel is None: self._build_divider_row(position_count) self._apply_column_backgrounds() @@ -636,20 +636,20 @@ def _tint_channel_rows(self) -> None: tint, sharing the fraction. A silenced channel trades that identity for a neutral dark shade, so its row recedes as a whole. """ - for generator in GeneratorName.items(): + for channel in ChannelName.items(): dpg.highlight_table_row( TAG_SEQUENCER_ORDER_TABLE, - self._table_row(generator), - self._channel_row_tint(generator), + self._table_row(channel), + self._channel_row_tint(channel), ) - def _channel_row_tint(self, generator: GeneratorName) -> ColorRGBA: - if self._is_muted(generator): + def _channel_row_tint(self, channel: ChannelName) -> ColorRGBA: + if self._is_muted(channel): return self._layout.colors.muted.background.rgba - channel = channel_color(self._layout.colors.channels, generator) + tint_color = channel_color(self._layout.colors.channels, channel) return FadedColor( - color=channel, + color=tint_color, fraction=self._layout.tracker.channel_column_tint, ).rgba @@ -685,15 +685,15 @@ def _clear_column_highlight(self) -> None: def _build_row( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], position_count: int, ) -> None: - font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL + font = Font.MONO_BOLD_SMALL if channel is None else Font.MONO_SMALL row_id = dpg.add_table_row(parent=TAG_SEQUENCER_ORDER_TABLE) - self._add_row_label(row_id, generator) + self._add_row_label(row_id, channel) for position in range(position_count): cell = dpg.add_table_cell(parent=row_id) - key: OrderKey = (generator, position) + key: OrderKey = (channel, position) selectable = dpg.add_selectable( parent=cell, label=self._render_cell(key), @@ -708,7 +708,7 @@ def _build_row( def _add_row_label( self, row_id: Sender, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: """Places one clickable row label: a channel's mute target, or the master target. @@ -719,17 +719,17 @@ def _add_row_label( label_cell = dpg.add_table_cell(parent=row_id) label = dpg.add_selectable( parent=label_cell, - label=self._row_labels[generator], - user_data=generator, + label=self._row_labels[channel], + user_data=channel, callback=self._on_label_clicked, ) FontRegistry.bind_to_item(label, Font.MONO_BOLD_SMALL) dpg.bind_item_handler_registry(label, self._label_handler_tag) show_tooltip( label, - self._tooltip_label_master if generator is None else self._tooltip_label_channel, + self._tooltip_label_master if channel is None else self._tooltip_label_channel, ) - self._label_rows[label] = generator + self._label_rows[label] = channel def _build_divider_row(self, position_count: int) -> None: """Inserts the thin rule that sets the master row apart from the channel @@ -747,7 +747,7 @@ def _build_divider_row(self, position_count: int) -> None: def _render_cell(self, key: OrderKey) -> str: cursor = self._input_state.cursor stored = self._order.values.get(key, _EMPTY_LABEL) - if cursor is not None and (cursor.generator, cursor.position) == key: + if cursor is not None and (cursor.channel, cursor.position) == key: return pending_label( self._input_state.pending, stored, @@ -756,15 +756,15 @@ def _render_cell(self, key: OrderKey) -> str: return stored - def _table_row(self, generator: Optional[GeneratorName]) -> int: - if generator is None: + def _table_row(self, channel: Optional[ChannelName]) -> int: + if channel is None: return MASTER_TABLE_ROW - return CHANNEL_AXIS.index(generator) + 1 + return CHANNEL_AXIS.index(channel) + 1 def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: dpg.highlight_table_cell( TAG_SEQUENCER_ORDER_TABLE, - self._table_row(cursor.generator), + self._table_row(cursor.channel), cursor.position + 1, color=self._layout.colors.cell_cursor.rgba, ) @@ -777,10 +777,10 @@ def _selected_cells(self) -> FrozenSet[OrderKey]: return frozenset() keys: Set[OrderKey] = set() - for generator in region.generators: + for channel in region.channels: for position in region.positions: if position < self._position_count: - keys.add((generator, position)) + keys.add((channel, position)) return frozenset(keys) @@ -790,12 +790,12 @@ def _clear_cursor_highlight(self) -> None: cursor = self._highlighted self._highlighted = None - if cursor.generator is None: + if cursor.channel is None: self._highlight_master_cell_at(cursor.position + 1) else: dpg.unhighlight_table_cell( TAG_SEQUENCER_ORDER_TABLE, - self._table_row(cursor.generator), + self._table_row(cursor.channel), cursor.position + 1, ) @@ -813,7 +813,7 @@ def _restore_cursor(self) -> None: return position = min(cursor.position, self._position_count - 1) - clamped = OrderCursor(cursor.generator, position) + clamped = OrderCursor(cursor.channel, position) self._input_state = OrderInputState(cursor=clamped) self._apply_cursor_highlight(clamped) self._apply_column_highlight(clamped.position, focused=True) @@ -855,7 +855,7 @@ def _apply_state( self._refresh_remove_enabled() def _update_cell_display(self, cursor: OrderCursor) -> None: - key: OrderKey = (cursor.generator, cursor.position) + key: OrderKey = (cursor.channel, cursor.position) widget = self._order.widget(key) if widget is not None: dpg.configure_item(widget, label=self._render_cell(key)) @@ -867,8 +867,8 @@ def _update_caret(self) -> None: CaretOverlay.clear(TAG_SEQUENCER_ORDER_TABLE) return - key: OrderKey = (cursor.generator, cursor.position) - font = Font.MONO_BOLD_SMALL if cursor.generator is None else Font.MONO_SMALL + key: OrderKey = (cursor.channel, cursor.position) + font = Font.MONO_BOLD_SMALL if cursor.channel is None else Font.MONO_SMALL CaretOverlay.set_target( owner=TAG_SEQUENCER_ORDER_TABLE, widget=self._order.widget(key), @@ -892,8 +892,8 @@ def _on_cell_clicked( return state = self._committed_state() - generator, position = user_data - cursor = OrderCursor(generator, position) + channel, position = user_data + cursor = OrderCursor(channel, position) if Modifier.SHIFT in capture_modifiers(): self._apply_state(state.extend_to(cursor)) return @@ -960,21 +960,21 @@ def _cell_at(self) -> Optional[OrderKey]: return (self._generator_at(top), position) - def _generator_at(self, top: float) -> Optional[GeneratorName]: + def _generator_at(self, top: float) -> Optional[ChannelName]: """Which channel row stands at a height, the master row reading ``None``. The master row stands apart from the channels beneath it, so the walk asks each row where it was drawn and takes the first one reaching past the pointer. """ - for generator in CHANNEL_AXIS: - widget = self._order.widget((generator, 0)) + for channel in CHANNEL_AXIS: + widget = self._order.widget((channel, 0)) if widget is None: continue _, row_top = dpg.get_item_rect_min(widget) _, row_height = dpg.get_item_rect_size(widget) if top < row_top + row_height: - return generator + return channel return CHANNEL_AXIS[-1] @@ -1022,14 +1022,14 @@ def _on_cell_right_clicked( if key is None: return - generator, position = key - self._show_context_menu(generator, position) + channel, position = key + self._show_context_menu(channel, position) def _on_label_clicked( self, sender: Sender, _app_data: bool, - user_data: Optional[GeneratorName], + user_data: Optional[ChannelName], ) -> None: self._channel_switch.click(sender, user_data) @@ -1052,28 +1052,28 @@ def _on_label_right_clicked( self._show_channel_menu(self._label_rows[clicked_item]) - def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None: + def _show_channel_menu(self, channel: Optional[ChannelName]) -> None: """Opens the menu behind a row label, titled with the row's own name.""" with context_menu(): - header = dpg.add_text(self._row_labels[generator]) + header = dpg.add_text(self._row_labels[channel]) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() self._channel_switch.add_menu_items( - generator, + channel, self._current_channels, ) def _show_context_menu( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], position: int, ) -> None: - target = self._surface.target_at(OrderCursor(generator, position)) + target = self._surface.target_at(OrderCursor(channel, position)) with context_menu(): header = dpg.add_text( cell_title( position, - self._row_labels[generator], + self._row_labels[channel], ) ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1511,12 +1511,12 @@ def _emit( if cursor is None: return - if cursor.generator is None: + if cursor.channel is None: self.call(self.on_set_master_entry, cursor.position, index) else: self.call( self.on_set_order_entry, - cursor.generator, + cursor.channel, cursor.position, index, ) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 6814c5b02..066d119aa 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -47,7 +47,7 @@ SequencerSamplesViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import StringCallback @@ -583,12 +583,12 @@ def _footprint_items(self, sample_id: str) -> List[Tuple[str, str]]: return [] items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))] - for generator_name in GeneratorName.items(): - instrument_bytes = footprint.bytes_for(generator_name) + for channel_name in ChannelName.items(): + instrument_bytes = footprint.bytes_for(channel_name) if instrument_bytes is not None: items.append( ( - channel_label(self._language_manager, generator_name), + channel_label(self._language_manager, channel_name), self._format_size(instrument_bytes), ) ) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 8427560d8..06a1cfd72 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -106,7 +106,7 @@ SequencerRowViewModel, SequencerTrackerViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.project.song_position import SongPosition from sampletones_core.utils.display import NOTE_OFF, display_id @@ -114,16 +114,16 @@ from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback -OnClearRowCallback = Callable[[int, Optional[GeneratorName]], None] -OnClearSubcolumnCallback = Callable[[int, Optional[GeneratorName], SubColumn], None] -OnSetRowCallback = Callable[[int, Optional[GeneratorName], Optional[str], Optional[int], Optional[int]], None] -OnSetNoteOffCallback = Callable[[int, Optional[GeneratorName]], None] +OnClearRowCallback = Callable[[int, Optional[ChannelName]], None] +OnClearSubcolumnCallback = Callable[[int, Optional[ChannelName], SubColumn], None] +OnSetRowCallback = Callable[[int, Optional[ChannelName], Optional[str], Optional[int], Optional[int]], None] +OnSetNoteOffCallback = Callable[[int, Optional[ChannelName]], None] OnCellSelectedCallback = VoidCallback OnPlayFromRowCallback = Callable[[int], None] OnPlayFromFrameCallback = VoidCallback OnAdjustCallback = Callable[[TrackerRegion, int], None] -OnChannelMuteToggledCallback = Callable[[GeneratorName], None] -OnChannelSoloedCallback = Callable[[GeneratorName], None] +OnChannelMuteToggledCallback = Callable[[ChannelName], None] +OnChannelSoloedCallback = Callable[[ChannelName], None] OnBlockRegionCallback = Callable[[TrackerRegion], None] OnPasteBlockCallback = Callable[[TrackerCell], None] CanPasteBlockQuery = Callable[[], bool] @@ -224,7 +224,7 @@ def __init__( self._drag_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_DRAG) self._rows: Dict[Optional[int], Sender] = {} - self._header_columns: Dict[Sender, Optional[GeneratorName]] = {} + self._header_columns: Dict[Sender, Optional[ChannelName]] = {} self._editable_cells: EditableCells[CellKey] = EditableCells() self._current_row_count: int = 0 self._highlighted_row: Optional[int] = None @@ -306,12 +306,12 @@ def __init__( def _load_column_labels(self, language_manager: LanguageManager) -> None: """Reads the name each column carries, which its header label and its menu title show.""" self._lbl_col_row = self._label(language_manager, SequencerTrackerElements.COLUMN_ROW) - self._column_labels: Dict[Optional[GeneratorName], str] = { + self._column_labels: Dict[Optional[ChannelName], str] = { None: self._label(language_manager, SequencerTrackerElements.COLUMN_SAMPLE), - GeneratorName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1), - GeneratorName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2), - GeneratorName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE), - GeneratorName.NOISE: self._label(language_manager, SequencerTrackerElements.COLUMN_NOISE), + ChannelName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1), + ChannelName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2), + ChannelName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE), + ChannelName.NOISE: self._label(language_manager, SequencerTrackerElements.COLUMN_NOISE), } @staticmethod @@ -370,8 +370,8 @@ def _create_channel_switch(self, language_manager: LanguageManager) -> None: ) self._channel_switch = ChannelSwitch( labels=labels, - on_mute_toggled=lambda generator: self.call(self.on_channel_mute_toggled, generator), - on_soloed=lambda generator: self.call(self.on_channel_soloed, generator), + on_mute_toggled=lambda channel: self.call(self.on_channel_mute_toggled, channel), + on_soloed=lambda channel: self.call(self.on_channel_soloed, channel), on_toggled=lambda: self.call(self.on_channels_toggled), on_muted=lambda: self.call(self.on_channels_muted), on_unmuted=lambda: self.call(self.on_channels_unmuted), @@ -513,10 +513,10 @@ def _create_tracker_view(self, parent: str) -> None: width_fixed=True, init_width_or_weight=self._layout.table_cells.divider, ) - for _ in GeneratorName.items(): + for _ in ChannelName.items(): dpg.add_table_column( width_fixed=True, - init_width_or_weight=self._layout.table_cells.generator, + init_width_or_weight=self._layout.table_cells.channel, no_clip=True, ) dpg.add_table_column(width_stretch=True) @@ -653,10 +653,10 @@ def _apply_row_backgrounds(self) -> None: self._paint_row(row_index) def _render_cell(self, key: CellKey) -> str: - row, generator, subcolumn = key + row, channel, subcolumn = key return tracker_display.subcolumn_label( row, - generator, + channel, subcolumn, cursor=self._input_state.cursor, pending=self._input_state.pending, @@ -704,20 +704,20 @@ def _tint_channel_columns(self) -> None: table carries in its row labels. A silenced channel trades that identity for a neutral dark shade, so its column recedes as a whole. """ - for generator in GeneratorName.items(): + for channel in ChannelName.items(): dpg.highlight_table_column( TAG_SEQUENCER_TRACKER_TABLE, - tracker_table_column(generator), - self._channel_column_tint(generator), + tracker_table_column(channel), + self._channel_column_tint(channel), ) - def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA: - if self._is_muted(generator): + def _channel_column_tint(self, channel: ChannelName) -> ColorRGBA: + if self._is_muted(channel): return self._layout.colors.muted.background.rgba - channel = channel_color(self._layout.colors.channels, generator) + tint_color = channel_color(self._layout.colors.channels, channel) return FadedColor( - color=channel, + color=tint_color, fraction=self._layout.tracker.channel_column_tint, ).rgba @@ -730,13 +730,13 @@ def _compute_cell_values( cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample_instrument cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.sample_transpose cell_values[(row.index, None, SubColumn.VOLUME)] = row.sample_volume - for generator in GeneratorName.items(): - cell = row.cells[generator] + for channel in ChannelName.items(): + cell = row.cells[channel] for subcolumn in SubColumn: cell_values[ ( row.index, - generator, + channel, subcolumn, ) ] = tracker_display.cell_display( @@ -766,8 +766,8 @@ def _build_header_row(self) -> None: self._add_header_label_cell(row_id) self._add_header_selectable(row_id, None) self._add_empty_cell(row_id) - for generator in GeneratorName.items(): - self._add_header_selectable(row_id, generator) + for channel in ChannelName.items(): + self._add_header_selectable(row_id, channel) def _add_header_label_cell(self, row_id: Sender) -> None: """Places the row-number column's label, which names a column the user reads only. @@ -786,7 +786,7 @@ def _add_header_label_cell(self, row_id: Sender) -> None: def _add_header_selectable( self, row_id: Sender, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: """Places one clickable column label: a channel's mute target, or the master target. @@ -798,17 +798,17 @@ def _add_header_selectable( header_cell = dpg.add_table_cell(parent=row_id) selectable = dpg.add_selectable( parent=header_cell, - label=self._column_labels[generator], + label=self._column_labels[channel], height=self._layout.tracker.header_height, - user_data=generator, + user_data=channel, callback=self._on_header_clicked, ) dpg.bind_item_handler_registry(selectable, self._header_handler_tag) show_tooltip( selectable, - self._tooltip_header_sample if generator is None else self._tooltip_header_channel, + self._tooltip_header_sample if channel is None else self._tooltip_header_channel, ) - self._header_columns[selectable] = generator + self._header_columns[selectable] = channel def _build_table_row(self, row: SequencerRowViewModel) -> None: """Builds one tracker row. @@ -824,8 +824,8 @@ def _build_table_row(self, row: SequencerRowViewModel) -> None: self._add_row_number_cell(row_id, row.index) self._add_column_cell(row_id, row.index, None) self._add_empty_cell(row_id) - for generator in GeneratorName.items(): - self._add_column_cell(row_id, row.index, generator) + for channel in ChannelName.items(): + self._add_column_cell(row_id, row.index, channel) def _add_empty_cell(self, row_id: Sender) -> None: empty_cell = dpg.add_table_cell(parent=row_id) @@ -850,9 +850,9 @@ def _add_column_cell( self, row_id: Sender, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: - font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL + font = Font.MONO_BOLD_SMALL if channel is None else Font.MONO_SMALL cell = dpg.add_table_cell(parent=row_id) group = dpg.add_group( horizontal=True, @@ -863,7 +863,7 @@ def _add_column_cell( self._add_subcolumn_selectable( group, row_index, - generator, + channel, subcolumn, font, ) @@ -872,11 +872,11 @@ def _add_subcolumn_selectable( self, group: Sender, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], subcolumn: SubColumn, font: Font, ) -> None: - key = (row_index, generator, subcolumn) + key = (row_index, channel, subcolumn) selectable = dpg.add_selectable( parent=group, label=self._render_cell(key), @@ -894,7 +894,7 @@ def _update_cursor(self) -> None: cursor = self._input_state.cursor if cursor is not None: if cursor.row < self._current_row_count: - self._apply_cell_highlight(cursor.row, cursor.generator) + self._apply_cell_highlight(cursor.row, cursor.channel) else: self._input_state = TrackerInputState() @@ -905,7 +905,7 @@ def deselect_cell(self) -> None: cursor = self._input_state.cursor if cursor is not None: self._input_state = TrackerInputState() - self._remove_cell_highlight(cursor.row, cursor.generator) + self._remove_cell_highlight(cursor.row, cursor.channel) self._selection.repaint() self._update_caret() @@ -914,21 +914,21 @@ def _apply_state(self, new_state: TrackerInputState) -> None: old_cursor = self._input_state.cursor new_cursor = new_state.cursor - old_pos = (old_cursor.row, old_cursor.generator) if old_cursor is not None else None - new_pos = (new_cursor.row, new_cursor.generator) if new_cursor is not None else None + old_pos = (old_cursor.row, old_cursor.channel) if old_cursor is not None else None + new_pos = (new_cursor.row, new_cursor.channel) if new_cursor is not None else None self._input_state = new_state if old_pos != new_pos and old_cursor is not None: - self._remove_cell_highlight(old_cursor.row, old_cursor.generator) + self._remove_cell_highlight(old_cursor.row, old_cursor.channel) if old_cursor is not None: - self._update_cell_display(old_cursor.row, old_cursor.generator) + self._update_cell_display(old_cursor.row, old_cursor.channel) if new_cursor is not None: if old_pos != new_pos: - self._apply_cell_highlight(new_cursor.row, new_cursor.generator) - self._update_cell_display(new_cursor.row, new_cursor.generator) + self._apply_cell_highlight(new_cursor.row, new_cursor.channel) + self._update_cell_display(new_cursor.row, new_cursor.channel) if new_pos != old_pos and new_cursor is not None: self.call(self.on_cell_selected) @@ -960,27 +960,27 @@ def _apply_channel_cues(self) -> None: self._tint_channel_columns() self._bind_header_themes() - for generator in GeneratorName.items(): - self._bind_channel_cell_themes(generator) + for channel in ChannelName.items(): + self._bind_channel_cell_themes(channel) def _bind_header_themes(self) -> None: - for selectable, generator in self._header_columns.items(): - muted = generator is not None and self._is_muted(generator) + for selectable, channel in self._header_columns.items(): + muted = channel is not None and self._is_muted(channel) dpg.bind_item_theme( selectable, self._muted_header_theme if muted else self._header_theme, ) - def _bind_channel_cell_themes(self, generator: GeneratorName) -> None: - themes = self._muted_subcolumn_themes if self._is_muted(generator) else self._subcolumn_themes + def _bind_channel_cell_themes(self, channel: ChannelName) -> None: + themes = self._muted_subcolumn_themes if self._is_muted(channel) else self._subcolumn_themes for row_index in range(self._current_row_count): for subcolumn in SubColumn: - cell_id = self._editable_cells.widget((row_index, generator, subcolumn)) + cell_id = self._editable_cells.widget((row_index, channel, subcolumn)) if cell_id is not None: dpg.bind_item_theme(cell_id, themes[subcolumn]) - def _is_muted(self, generator: GeneratorName) -> bool: - return self._current_channels is not None and self._current_channels.is_muted(generator) + def _is_muted(self, channel: ChannelName) -> bool: + return self._current_channels is not None and self._current_channels.is_muted(channel) def set_enabled(self, enabled: bool) -> None: dpg.configure_item(TAG_SEQUENCER_TRACKER_GROUP, enabled=enabled) @@ -988,10 +988,10 @@ def set_enabled(self, enabled: bool) -> None: def _update_cell_display( self, row: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: for subcolumn in SubColumn: - key = (row, generator, subcolumn) + key = (row, channel, subcolumn) cell_id = self._editable_cells.widget(key) if cell_id is not None: dpg.configure_item(cell_id, label=self._render_cell(key)) @@ -1003,8 +1003,8 @@ def _update_caret(self) -> None: CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE) return - key = (cursor.row, cursor.generator, cursor.subcolumn) - font = Font.MONO_BOLD_SMALL if cursor.generator is None else Font.MONO_SMALL + key = (cursor.row, cursor.channel, cursor.subcolumn) + font = Font.MONO_BOLD_SMALL if cursor.channel is None else Font.MONO_SMALL CaretOverlay.set_target( owner=TAG_SEQUENCER_TRACKER_TABLE, widget=self._editable_cells.widget(key), @@ -1031,11 +1031,11 @@ def _handle_edit_action(self, action: EditAction) -> None: the others are ``None`` meaning "leave unchanged". Forwarding those ``None`` values lets the downstream partial update preserve the rest of the row. """ - row, generator = action.row, action.generator + row, channel = action.row, action.channel if action.note_off: - self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = NOTE_OFF - self.call(self.on_set_note_off, row, generator) + self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = NOTE_OFF + self.call(self.on_set_note_off, row, channel) return sample_id: Optional[str] = None @@ -1044,19 +1044,19 @@ def _handle_edit_action(self, action: EditAction) -> None: resolved = self._resolve_sample_id(action.sample_index) sample_index = resolved[0] if resolved is not None else None sample_id = resolved[1] if resolved is not None else None - self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = tracker_display.format_committed( + self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = tracker_display.format_committed( SubColumn.INSTRUMENT, sample_index, ) if action.transpose is not None: - self._editable_cells.values[(row, generator, SubColumn.TRANSPOSE)] = tracker_display.format_committed( + self._editable_cells.values[(row, channel, SubColumn.TRANSPOSE)] = tracker_display.format_committed( SubColumn.TRANSPOSE, action.transpose, ) if action.volume is not None: - self._editable_cells.values[(row, generator, SubColumn.VOLUME)] = tracker_display.format_committed( + self._editable_cells.values[(row, channel, SubColumn.VOLUME)] = tracker_display.format_committed( SubColumn.VOLUME, action.volume, ) @@ -1064,7 +1064,7 @@ def _handle_edit_action(self, action: EditAction) -> None: self.call( self.on_set_row, row, - generator, + channel, sample_id, action.transpose, action.volume, @@ -1074,33 +1074,33 @@ def _handle_clear_action(self, action: ClearAction) -> None: if action.subcolumn is None: for subcolumn in SubColumn: self._editable_cells.values.pop( - (action.row, action.generator, subcolumn), + (action.row, action.channel, subcolumn), None, ) - self.call(self.on_clear_row, action.row, action.generator) + self.call(self.on_clear_row, action.row, action.channel) else: self._editable_cells.values.pop( - (action.row, action.generator, action.subcolumn), + (action.row, action.channel, action.subcolumn), None, ) self.call( self.on_clear_subcolumn, action.row, - action.generator, + action.channel, action.subcolumn, ) def _apply_cell_highlight( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: """Marks the cursor: its cell on the cell layer, its row through the row background.""" self._paint_row(row_index) dpg.highlight_table_cell( TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row_index), - tracker_table_column(generator), + tracker_table_column(channel), color=self._layout.colors.cell_cursor.rgba, ) @@ -1120,14 +1120,14 @@ def _selected_cells(self) -> FrozenSet[CellKey]: continue for slot in region.slots: - keys.add((row_index, slot.generator, slot.subcolumn)) + keys.add((row_index, slot.channel, slot.subcolumn)) return frozenset(keys) def _remove_cell_highlight( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: """Clears the cursor cell and returns its row to the background the row itself carries. @@ -1137,7 +1137,7 @@ def _remove_cell_highlight( dpg.unhighlight_table_cell( TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row_index), - tracker_table_column(generator), + tracker_table_column(channel), ) self._paint_row(row_index) @@ -1145,7 +1145,7 @@ def _on_cell_clicked( self, sender: Sender, _app_data: bool, - user_data: Tuple[int, Optional[GeneratorName], SubColumn], + user_data: Tuple[int, Optional[ChannelName], SubColumn], ) -> None: """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. @@ -1156,8 +1156,8 @@ def _on_cell_clicked( return state = self._committed_state() - row_index, generator, subcolumn = user_data - cursor = TrackerCursor(row_index, generator, subcolumn) + row_index, channel, subcolumn = user_data + cursor = TrackerCursor(row_index, channel, subcolumn) if Modifier.SHIFT in capture_modifiers(): self._apply_state(state.extend_to(cursor)) return @@ -1218,7 +1218,7 @@ def _cell_at(self) -> Optional[CellKey]: if row_index is None or slot is None: return None - return (row_index, slot.generator, slot.subcolumn) + return (row_index, slot.channel, slot.subcolumn) def _row_at(self, top: float) -> Optional[int]: """Which pattern row stands at a height, counted from the first row's top edge. @@ -1244,7 +1244,7 @@ def _slot_at(self, left: float) -> Optional[TrackerSlot]: for index in range(SLOT_COUNT): slot = slot_from_flat(index) - widget = self._editable_cells.widget((0, slot.generator, slot.subcolumn)) + widget = self._editable_cells.widget((0, slot.channel, slot.subcolumn)) if widget is None: return None @@ -1259,7 +1259,7 @@ def _on_header_clicked( self, sender: Sender, _app_data: bool, - user_data: Optional[GeneratorName], + user_data: Optional[ChannelName], ) -> None: self._channel_switch.click(sender, user_data) @@ -1284,14 +1284,14 @@ def _on_header_right_clicked( def _show_header_context_menu( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: """Opens the menu behind a column header, titled with the column's own name.""" with context_menu(): - header = dpg.add_text(self._column_labels[generator]) + header = dpg.add_text(self._column_labels[channel]) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() - self._channel_switch.add_menu_items(generator, self._current_channels) + self._channel_switch.add_menu_items(channel, self._current_channels) def _on_cell_right_clicked( self, @@ -1311,19 +1311,19 @@ def _on_cell_right_clicked( if key is None: return - row_index, generator, subcolumn = key - self._show_context_menu(row_index, generator, subcolumn) + row_index, channel, subcolumn = key + self._show_context_menu(row_index, channel, subcolumn) def _show_context_menu( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], subcolumn: SubColumn, ) -> None: - target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn)) + target = self._surface.target_at(TrackerCursor(row_index, channel, subcolumn)) with context_menu(): header = dpg.add_text( - tracker_display.cell_title(row_index, self._column_labels[generator]), + tracker_display.cell_title(row_index, self._column_labels[channel]), ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() @@ -1367,7 +1367,7 @@ def add_action_items(self, target: TrackerTarget) -> None: self._add_instrument_submenu(target.cell) dpg.add_menu_item( label=self._lbl_context_note_off, - callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator), + callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.channel), ) dpg.add_separator() self._add_transpose_items(target) @@ -1412,7 +1412,7 @@ def _add_instrument_submenu(self, cell: TrackerCursor) -> None: for index, sample in enumerate(samples): dpg.add_menu_item( label=tracker_display.indexed_label(index, sample.name), - user_data=(cell.row, cell.generator, sample.sample_id), + user_data=(cell.row, cell.channel, sample.sample_id), callback=self._on_set_instrument_menu, ) @@ -1447,10 +1447,10 @@ def _on_set_instrument_menu( self, _sender: Sender, _app_data: None, - user_data: Tuple[int, Optional[GeneratorName], str], + user_data: Tuple[int, Optional[ChannelName], str], ) -> None: - row_index, generator, sample_id = user_data - self.call(self.on_set_row, row_index, generator, sample_id, None, None) + row_index, channel, sample_id = user_data + self.call(self.on_set_row, row_index, channel, sample_id, None, None) def _on_transpose_menu( self, @@ -1481,17 +1481,17 @@ def _add_clear_items(self, cell: TrackerCursor) -> None: callback=lambda: self.call( self.on_clear_subcolumn, cell.row, - cell.generator, + cell.channel, cell.subcolumn, ), ) - if cell.generator is not None: + if cell.channel is not None: dpg.add_menu_item( label=self._lbl_context_clear_cell, callback=lambda: self.call( self.on_clear_row, cell.row, - cell.generator, + cell.channel, ), ) dpg.add_menu_item( @@ -1892,13 +1892,13 @@ def _on_row_number_clicked( ) -> None: dpg.set_value(sender, False) existing = self._input_state.cursor - generator = existing.generator if existing is not None else None + channel = existing.channel if existing is not None else None subcolumn = existing.subcolumn if existing is not None else SubColumn.INSTRUMENT self._apply_state( TrackerInputState( cursor=TrackerCursor( user_data, - generator, + channel, subcolumn, ), pending="", diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py index c37865540..2c2e94802 100644 --- a/src/sampletones_application/ui/themes/channels.py +++ b/src/sampletones_application/ui/themes/channels.py @@ -6,11 +6,11 @@ TAG_GLOBAL_THEME_CHANNEL_PULSE2, TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName -CHANNEL_THEME_TAGS: Final[Dict[GeneratorName, str]] = { - GeneratorName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1, - GeneratorName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2, - GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, - GeneratorName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE, +CHANNEL_THEME_TAGS: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1, + ChannelName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2, + ChannelName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, + ChannelName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE, } diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index dde05a076..02c078b52 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -3,7 +3,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.constants.playback import FollowMode -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.trackers.format import TrackerFormat @@ -213,11 +213,11 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: Tab.INSTRUCTIONS: ShortcutId.SELECT_TAB_INSTRUCTIONS, } -CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = { - GeneratorName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1, - GeneratorName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2, - GeneratorName.TRIANGLE: ShortcutId.TOGGLE_CHANNEL_TRIANGLE, - GeneratorName.NOISE: ShortcutId.TOGGLE_CHANNEL_NOISE, +CHANNEL_SHORTCUT_IDS: Final[Dict[ChannelName, ShortcutId]] = { + ChannelName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1, + ChannelName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2, + ChannelName.TRIANGLE: ShortcutId.TOGGLE_CHANNEL_TRIANGLE, + ChannelName.NOISE: ShortcutId.TOGGLE_CHANNEL_NOISE, } PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { diff --git a/src/sampletones_application/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/reconstructor.py index 06c155402..7799a6d61 100644 --- a/src/sampletones_application/view_model/main/reconstructor.py +++ b/src/sampletones_application/view_model/main/reconstructor.py @@ -2,9 +2,9 @@ from pydantic import BaseModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class ReconstructorPanelViewModel(BaseModel, frozen=True): - generators: FrozenSet[GeneratorName] + channels: FrozenSet[ChannelName] drive: float diff --git a/src/sampletones_application/view_model/main/updates.py b/src/sampletones_application/view_model/main/updates.py index 1c5b0d5ee..a42aeecb8 100644 --- a/src/sampletones_application/view_model/main/updates.py +++ b/src/sampletones_application/view_model/main/updates.py @@ -3,7 +3,7 @@ from pydantic import BaseModel -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.constants.enums import ChannelName, SpectrumMethod class AudioSettingsUpdate(BaseModel, frozen=True): @@ -18,7 +18,7 @@ class LibrarySettingsUpdate(BaseModel, frozen=True): class GenerationSettingsUpdate(BaseModel, frozen=True): drive: float - generators: List[GeneratorName] + channels: List[ChannelName] class AdvancedSettingsUpdate(BaseModel, frozen=True): diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 15c808fb7..200d972ff 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -3,17 +3,17 @@ from pydantic import BaseModel from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): """What the instruments panel renders: every channel, and which of them play. A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays - editable and giving it an envelope puts it in play. :attr:`playing_generators` is what the + editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel reads to mark the standing-by tabs and to offer their export. """ reconstruction_loaded: bool - playing_generators: FrozenSet[GeneratorName] + playing_channels: FrozenSet[ChannelName] footprint: Optional[SampleFootprintViewModel] diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index 7c48cf0e3..5d7ed84a1 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -3,7 +3,7 @@ from pydantic import BaseModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class ReconstructionPathState(StrEnum): @@ -36,13 +36,13 @@ class ReconstructionViewModel(BaseModel, frozen=True): """What the reconstruction view renders, including which channels the waveform offers. A channel plays once its instruction stream describes a frame, which is what makes its - generator checkbox reachable; :attr:`selected_generators` is the subset the reader keeps + channel checkbox reachable; :attr:`selected_channels` is the subset the reader keeps switched on, so a channel switched off by hand stays off across an edit. """ reconstruction_loaded: bool - playing_generators: FrozenSet[GeneratorName] - selected_generators: FrozenSet[GeneratorName] + playing_channels: FrozenSet[ChannelName] + selected_channels: FrozenSet[ChannelName] reconstruction_file: ReconstructionPathViewModel original_audio: ReconstructionPathViewModel diff --git a/src/sampletones_application/view_model/reconstruction/update.py b/src/sampletones_application/view_model/reconstruction/update.py index 81f525015..58242ebd9 100644 --- a/src/sampletones_application/view_model/reconstruction/update.py +++ b/src/sampletones_application/view_model/reconstruction/update.py @@ -1,10 +1,10 @@ from typing import NamedTuple -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.types.feature import FeatureValue class ReconstructionUpdate(NamedTuple): - generator_name: GeneratorName + channel_name: ChannelName feature_key: FeatureKey data: FeatureValue diff --git a/src/sampletones_application/view_model/sequencer/channels.py b/src/sampletones_application/view_model/sequencer/channels.py index bd2cb0cf3..258136fd0 100644 --- a/src/sampletones_application/view_model/sequencer/channels.py +++ b/src/sampletones_application/view_model/sequencer/channels.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class SequencerChannelsViewModel(BaseModel, frozen=True): @@ -12,18 +12,18 @@ class SequencerChannelsViewModel(BaseModel, frozen=True): export, and the history stack all read the full song. """ - muted: FrozenSet[GeneratorName] + muted: FrozenSet[ChannelName] - def is_muted(self, generator: GeneratorName) -> bool: - return generator in self.muted + def is_muted(self, channel: ChannelName) -> bool: + return channel in self.muted - def is_soloed(self, generator: GeneratorName) -> bool: - """Whether ``generator`` is the one channel left sounding. + def is_soloed(self, channel: ChannelName) -> bool: + """Whether ``channel`` is the one channel left sounding. Solo is read back from the mute set the same way it is applied, so the menu names the gesture by what the mix currently sounds like. """ - return self.muted == frozenset(GeneratorName.items()) - {generator} + return self.muted == frozenset(ChannelName.items()) - {channel} @property def any_muted(self) -> bool: @@ -33,4 +33,4 @@ def any_muted(self) -> bool: @property def all_muted(self) -> bool: """Whether every channel is silenced, the state the master column's click restores from.""" - return self.muted == frozenset(GeneratorName.items()) + return self.muted == frozenset(ChannelName.items()) diff --git a/src/sampletones_application/view_model/sequencer/order.py b/src/sampletones_application/view_model/sequencer/order.py index 8a2246705..e9ce815b9 100644 --- a/src/sampletones_application/view_model/sequencer/order.py +++ b/src/sampletones_application/view_model/sequencer/order.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from sampletones_application.view_model.sequencer.aggregate import aggregate_labels -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id @@ -19,7 +19,7 @@ def label(self) -> str: class SequencerOrderViewModel(BaseModel, frozen=True): """The pattern sequence for one channel.""" - generator: GeneratorName + channel: ChannelName entries: Tuple[OrderEntryViewModel, ...] @@ -32,15 +32,15 @@ class SequencerOrderTrackerViewModel(BaseModel, frozen=True): """ position_count: int - channels: Dict[GeneratorName, SequencerOrderViewModel] + channels: Dict[ChannelName, SequencerOrderViewModel] - def entry_label(self, generator: GeneratorName, position: int) -> str: - entries = self.channels[generator].entries + def entry_label(self, channel: ChannelName, position: int) -> str: + entries = self.channels[channel].entries if position < len(entries): return entries[position].label return display_id(None) def master_label(self, position: int) -> str: - values = {self.entry_label(generator, position) for generator in self.channels} + values = {self.entry_label(channel, position) for channel in self.channels} return aggregate_labels(values, default=display_id(None)) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index 04249d7b8..8ddd0aa2d 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -8,7 +8,7 @@ TrackerSlot, slot_from_flat, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class TrackerCell(BaseModel, frozen=True): @@ -20,13 +20,13 @@ class TrackerCell(BaseModel, frozen=True): """ row: int = Field(ge=0) - generator: Optional[GeneratorName] + channel: Optional[ChannelName] class OrderCell(BaseModel, frozen=True): """The order cell a block is written from: a channel row, and the position it starts in.""" - generator: Optional[GeneratorName] + channel: Optional[ChannelName] position: int = Field(ge=0) @@ -81,14 +81,14 @@ def slots(self) -> Tuple[TrackerSlot, ...]: return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) @property - def columns(self) -> Tuple[Optional[GeneratorName], ...]: + def columns(self) -> Tuple[Optional[ChannelName], ...]: """The columns the region reaches, each named once and in the order the axis lays them out. A region names its edges as subcolumns, while a gesture acting on whole cells — a transpose or a volume shift — reaches the columns behind them. The sample column reads ``None``, as it does everywhere the axis is read. """ - return tuple(dict.fromkeys(slot.generator for slot in self.slots)) + return tuple(dict.fromkeys(slot.channel for slot in self.slots)) def covers(self, row: int, slot: TrackerSlot) -> bool: """Whether a cell of the grid falls inside the rectangle. @@ -125,13 +125,13 @@ def positions(self) -> range: return range(self.first_position, self.last_position + 1) @property - def generators(self) -> Tuple[Optional[GeneratorName], ...]: + def channels(self) -> Tuple[Optional[ChannelName], ...]: """The rows the region covers, each as the channel it addresses, master reading ``None``.""" return tuple(CHANNEL_AXIS[row] for row in self.rows) def covers( self, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], position: int, ) -> bool: """Whether a cell of the table falls inside the rectangle. @@ -139,4 +139,4 @@ def covers( This is what a gesture raised on a cell asks to learn which block it belongs to: one landing inside a selection acts on the whole of it, and one landing outside acts alone. """ - return self.covers_row(CHANNEL_AXIS.index(generator)) and position in self.positions + return self.covers_row(CHANNEL_AXIS.index(channel)) and position in self.positions diff --git a/src/sampletones_application/view_model/sequencer/slot.py b/src/sampletones_application/view_model/sequencer/slot.py index f4902ea38..5c4e6f354 100644 --- a/src/sampletones_application/view_model/sequencer/slot.py +++ b/src/sampletones_application/view_model/sequencer/slot.py @@ -6,7 +6,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn) SLOT_COUNT: Final[int] = len(CHANNEL_AXIS) * len(SUBCOLUMNS) @@ -23,22 +23,22 @@ class TrackerSlot: while an edit addresses the column and the subcolumn it lands in. """ - generator: Optional[GeneratorName] + channel: Optional[ChannelName] subcolumn: SubColumn @property def flat_index(self) -> int: - return column_slot_base(self.generator) + SUBCOLUMNS.index(self.subcolumn) + return column_slot_base(self.channel) + SUBCOLUMNS.index(self.subcolumn) -def column_slot_base(generator: Optional[GeneratorName]) -> int: - """The flat index of ``generator``'s first subcolumn. +def column_slot_base(channel: Optional[ChannelName]) -> int: + """The flat index of ``channel``'s first subcolumn. Every base is a multiple of ``len(SUBCOLUMNS)``, which is what keeps an offset measured from one column's base addressing the same kind of subcolumn at any other column it is replayed against. """ - return CHANNEL_AXIS.index(generator) * len(SUBCOLUMNS) + return CHANNEL_AXIS.index(channel) * len(SUBCOLUMNS) def slot_from_flat(index: int) -> TrackerSlot: diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index 54012c51f..ae4b457e7 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from sampletones_application.view_model.sequencer.aggregate import aggregate_labels -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import ( display_id, display_transpose, @@ -30,8 +30,8 @@ def label(self) -> str: class SequencerRowViewModel(BaseModel, frozen=True): index: int - cells: Dict[GeneratorName, SequencerCellViewModel] - relevant_generators: FrozenSet[GeneratorName] + cells: Dict[ChannelName, SequencerCellViewModel] + relevant_channels: FrozenSet[ChannelName] """Channels the row's sample(s) span — the union of their reconstructions' channels. The sample column summarises a subcolumn only across these channels, so a @@ -39,14 +39,14 @@ class SequencerRowViewModel(BaseModel, frozen=True): """ @property - def subcolumn_generators(self) -> FrozenSet[GeneratorName]: + def subcolumn_channels(self) -> FrozenSet[ChannelName]: """Channels every sample column summary spans. A sample governs the channels its reconstruction covers, so its subcolumns summarise exactly those. Transpose and volume exist independently of an instrument, so a row with no sample spans every channel. """ - return self.relevant_generators or frozenset(self.cells) + return self.relevant_channels or frozenset(self.cells) @property def sample_instrument(self) -> str: @@ -72,7 +72,7 @@ def _aggregate( its channels, a transpose set on some of them, or a row cut on some and blank on the rest. A row with no cells at all shows the empty default. """ - values: Set[str] = {select(self.cells[generator]) for generator in self.subcolumn_generators} + values: Set[str] = {select(self.cells[channel]) for channel in self.subcolumn_channels} return aggregate_labels(values, default=default) diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index b8578a31f..9349642e0 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import ( InstrumentFootprint, total_footprint, @@ -16,7 +16,7 @@ class InstrumentSizeViewModel(BaseModel, frozen=True): whole and one naming a region read the same figure. """ - generator: GeneratorName + channel: ChannelName footprint: InstrumentFootprint @property @@ -39,17 +39,17 @@ class SampleFootprintViewModel(BaseModel, frozen=True): @classmethod def from_footprints( cls, - footprints: Dict[GeneratorName, InstrumentFootprint], + footprints: Dict[ChannelName, InstrumentFootprint], ) -> Self: """Collects measured channels in the generators' own order, so displays list them alike.""" return cls( instruments=tuple( InstrumentSizeViewModel( - generator=generator_name, - footprint=footprints[generator_name], + channel=channel_name, + footprint=footprints[channel_name], ) - for generator_name in GeneratorName.items() - if generator_name in footprints + for channel_name in ChannelName.items() + if channel_name in footprints ), ) @@ -62,10 +62,10 @@ def total_bytes(self) -> int: """ return total_footprint(instrument.footprint for instrument in self.instruments).total_bytes - def bytes_for(self, generator: GeneratorName) -> Optional[int]: + def bytes_for(self, channel: ChannelName) -> Optional[int]: """The bytes one channel's instrument occupies, where the sample covers that channel.""" for instrument in self.instruments: - if instrument.generator == generator: + if instrument.channel == channel: return instrument.total_bytes return None diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py index 4f70af87c..c3b22c98e 100644 --- a/src/sampletones_application/view_model/shared/waveform_data.py +++ b/src/sampletones_application/view_model/shared/waveform_data.py @@ -3,30 +3,28 @@ import numpy as np -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName @dataclass(frozen=True) class WaveformData: original_audio: Optional[np.ndarray] approximation: np.ndarray - approximations: Dict[GeneratorName, np.ndarray] + approximations: Dict[ChannelName, np.ndarray] coefficient: float frame_length: int - def partials(self, generator_names: List[GeneratorName]) -> np.ndarray: + def partials(self, channel_names: List[ChannelName]) -> np.ndarray: """Sums the selected generators' approximations, silent when none apply. The approximation sets the length, so the silent result matches the waveform even when no original audio is present. """ - if not generator_names: + if not channel_names: return np.zeros_like(self.approximation) selected_approximations = [ - self.approximations[generator_name] - for generator_name in generator_names - if generator_name in self.approximations + self.approximations[channel_name] for channel_name in channel_names if channel_name in self.approximations ] if not selected_approximations: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8c1dc25e1..56bd4fbfc 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -161,7 +161,7 @@ global.context.label.pulse_2: "Pulse 2" global.context.label.noise: "Noise" global.context.label.detail_sample_rate: "Sample rate" global.context.label.detail_nes_frequency: "NES frequency" -global.context.label.detail_generators: "Generators" +global.context.label.detail_channels: "Channels" global.context.label.detail_spectrum_method: "Generation method" global.context.label.detail_transformation_gamma: "Transformation gamma" global.context.label.detail_window_size: "Window size" @@ -312,7 +312,7 @@ main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for # ============================================================================= # Main tab — Reconstructor panel # ============================================================================= -main.reconstructor.label.section_generators: "Generators" +main.reconstructor.label.section_channels: "Channels" main.reconstructor.label.section_settings: "Reconstructor settings" main.reconstructor.label.slider_drive: "Drive" main.reconstructor.tooltip.tooltip_drive: "Amplify NES audio during instruction selection and output.\nAt 1.0 amplitudes are calibrated, higher values push the selection harder, introducing a distortion-like effect." @@ -332,7 +332,7 @@ main.converter.label.convert_directory_button: "Convert directory" main.converter.message.status_error: "Reconstruction failed." main.converter.message.status_reconstruction_completed: "Reconstruction completed!" main.converter.message.status_no_files: "No WAV files found to process." -main.converter.message.status_no_generators: "No generators are enabled. Enable at least one generator to reconstruct." +main.converter.message.status_no_channels: "No channels are enabled. Enable at least one channel to reconstruct." main.converter.message.status_idle: "No tasks in progress." main.converter.message.status_waiting: "Waiting to start..." main.converter.message.status_generating_library: "Generating instructions library... (this may take a while)" @@ -427,9 +427,9 @@ reconstructions.instruments.message.status_bar: "Click to change {instrument_fea reconstructions.instruments.message.status_sequence: "Edit and press Enter to change {instrument_feature}." reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on a FamiTracker export." reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." -reconstructions.instruments.message.status_generator_toggle: "Click to turn {on_or_off} {generator_name}." -reconstructions.instruments.message.status_generator_not_available: "{generator_name} is not available." -reconstructions.instruments.message.status_export_instrument: "Writes the {generator} generator's instrument, for the tracker the chosen extension names." +reconstructions.instruments.message.status_channel_toggle: "Click to turn {on_or_off} {channel_name}." +reconstructions.instruments.message.status_channel_not_available: "{channel_name} is not available." +reconstructions.instruments.message.status_export_instrument: "Writes the {channel} channel's instrument, for the tracker the chosen extension names." reconstructions.instruments.message.export_instrument_success: "Instrument saved successfully." reconstructions.instruments.message.export_instruments_success: "Reconstruction instruments saved successfully." reconstructions.instruments.message.export_instrument_truncated: "The envelope was truncated from {source_frames} to {frames} frames." diff --git a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml index 2210edf9a..3eb40df3f 100644 --- a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml @@ -1,7 +1,7 @@ row: 30 sample: 80 divider: 4 -generator: 80 +channel: 80 instrument: id: 40 name: 1 diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py new file mode 100644 index 000000000..2ad32df20 --- /dev/null +++ b/src/sampletones_core/compatibility/fields.py @@ -0,0 +1,16 @@ +from typing import Final + +CONFIG: Final = "config" +GENERATION: Final = "generation" +GENERATORS: Final = "generators" + +CHANNELS: Final = "channels" +GENERATOR: Final = "generator" + +SONG: Final = "song" +PATTERNS: Final = "patterns" +ROWS: Final = "rows" +COMMAND: Final = "command" + +CHANNEL_NAME: Final = "channel_name" +GENERATOR_NAME: Final = "generator_name" diff --git a/src/sampletones_core/compatibility/project/__init__.py b/src/sampletones_core/compatibility/project/__init__.py index 81c9116f6..ca3fac1d0 100644 --- a/src/sampletones_core/compatibility/project/__init__.py +++ b/src/sampletones_core/compatibility/project/__init__.py @@ -2,4 +2,6 @@ from sampletones_core.compatibility.update import VersionUpdate -UPDATES: Final[Tuple[VersionUpdate, ...]] = () +from .v1_1 import V1_1 + +UPDATES: Final[Tuple[VersionUpdate, ...]] = (V1_1,) diff --git a/src/sampletones_core/compatibility/project/v1_1.py b/src/sampletones_core/compatibility/project/v1_1.py new file mode 100644 index 000000000..f53a657fe --- /dev/null +++ b/src/sampletones_core/compatibility/project/v1_1.py @@ -0,0 +1,110 @@ +from typing import Final + +from sampletones_core.compatibility.fields import ( + CHANNEL_NAME, + CHANNELS, + COMMAND, + GENERATOR, + GENERATOR_NAME, + PATTERNS, + ROWS, + SONG, +) +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.update import VersionUpdate +from sampletones_shared.deployment.version import Version +from sampletones_shared.types.data import SerializedData + + +def update(data: SerializedData) -> SerializedData: + """Names each channel pool and row command by its channel. + + Project format 1.0 stored a channel pool's channel under ``generator`` and a + row instrument's channel under ``generator_name``. Project format 1.1 names + both ``channel_name``. + """ + updated = dict(data) + song = data.get(SONG) + if not isinstance(song, dict): + return updated + + channels = song.get(CHANNELS) + if not isinstance(channels, dict): + return updated + + renamed_channels = { + name: ( + _renamed_pool(channel) + if isinstance( + channel, + dict, + ) + else channel + ) + for name, channel in channels.items() + } + updated["song"] = {**song, CHANNELS: renamed_channels} + + return updated + + +def _renamed_pool(channel: SerializedData) -> SerializedData: + renamed = dict(channel) + if GENERATOR in renamed: + renamed[CHANNEL_NAME] = renamed.pop(GENERATOR) + + patterns = channel.get(PATTERNS) + if isinstance(patterns, dict): + renamed[PATTERNS] = { + index: ( + _renamed_pattern(pattern) + if isinstance( + pattern, + dict, + ) + else pattern + ) + for index, pattern in patterns.items() + } + + return renamed + + +def _renamed_pattern(pattern: SerializedData) -> SerializedData: + rows = pattern.get(ROWS) + if not isinstance(rows, dict): + return pattern + + return { + **pattern, + ROWS: { + index: ( + _renamed_row(row) + if isinstance( + row, + dict, + ) + else row + ) + for index, row in rows.items() + }, + } + + +def _renamed_row(row: SerializedData) -> SerializedData: + command = row.get(COMMAND) + if not isinstance(command, dict) or GENERATOR_NAME not in command: + return row + + renamed_command = dict(command) + renamed_command[CHANNEL_NAME] = renamed_command.pop(GENERATOR_NAME) + + return {**row, COMMAND: renamed_command} + + +V1_1: Final[VersionUpdate] = VersionUpdate( + kind=ObjectKind.PROJECT, + base=Version.model_validate("1.0"), + target=Version.model_validate("1.1"), + apply=update, +) diff --git a/src/sampletones_core/compatibility/reconstruction/__init__.py b/src/sampletones_core/compatibility/reconstruction/__init__.py index 81c9116f6..ce557a3a9 100644 --- a/src/sampletones_core/compatibility/reconstruction/__init__.py +++ b/src/sampletones_core/compatibility/reconstruction/__init__.py @@ -2,4 +2,6 @@ from sampletones_core.compatibility.update import VersionUpdate -UPDATES: Final[Tuple[VersionUpdate, ...]] = () +from .v2_2 import V2_2 + +UPDATES: Final[Tuple[VersionUpdate, ...]] = (V2_2,) diff --git a/src/sampletones_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py new file mode 100644 index 000000000..ddc18a137 --- /dev/null +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -0,0 +1,59 @@ +from typing import Final + +from sampletones_core.compatibility.fields import CHANNEL_NAME, CHANNELS, CONFIG, GENERATION, GENERATOR_NAME, GENERATORS +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.update import VersionUpdate +from sampletones_core.compatibility.utils import renamed +from sampletones_shared.deployment.version import Version +from sampletones_shared.types.data import SerializedData + + +def update(data: SerializedData) -> SerializedData: + """Names each stored stream and approximation by its channel. + + Data version 2.1 stored a channel's stream and approximation under the key + ``generator_name`` and the channel selection under + ``config.generation.generators``. Data version 2.2 names them ``channel_name`` + and ``config.generation.channels``. + """ + updated = dict(data) + approximations = data.get("approximations_data") + if isinstance(approximations, list): + updated["approximations_data"] = [ + renamed( + item, + GENERATOR_NAME, + CHANNEL_NAME, + ) + for item in approximations + ] + + instructions = data.get("instructions_data") + if isinstance(instructions, list): + updated["instructions_data"] = [ + renamed( + item, + GENERATOR_NAME, + CHANNEL_NAME, + ) + for item in instructions + ] + + config = data.get(CONFIG) + if isinstance(config, dict): + generation = config.get(GENERATION) + if isinstance(generation, dict): + updated["config"] = { + **config, + GENERATION: renamed(generation, GENERATORS, CHANNELS), + } + + return updated + + +V2_2: Final[VersionUpdate] = VersionUpdate( + kind=ObjectKind.RECONSTRUCTION, + base=Version.model_validate("2.1"), + target=Version.model_validate("2.2"), + apply=update, +) diff --git a/src/sampletones_core/compatibility/utils.py b/src/sampletones_core/compatibility/utils.py new file mode 100644 index 000000000..a55ca3e04 --- /dev/null +++ b/src/sampletones_core/compatibility/utils.py @@ -0,0 +1,9 @@ +from sampletones_shared.types.data import SerializedData + + +def renamed(data: SerializedData, old_key: str, new_key: str) -> SerializedData: + renamed = dict(data) + if old_key in renamed: + renamed[new_key] = renamed.pop(old_key) + + return renamed diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index 2dafc7968..5ed7c6a67 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -5,7 +5,7 @@ from pydantic import ConfigDict, Field -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.data.metadata import Metadata from sampletones_shared.paths.user import CONFIG_PATH @@ -111,8 +111,8 @@ def drive(self) -> float: return self.generation.drive @property - def generators(self) -> List[GeneratorName]: - return self.generation.generators.copy() + def channels(self) -> List[ChannelName]: + return self.generation.channels.copy() @property def normalize(self) -> bool: diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 04a1750db..69e9af48e 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,7 +1,7 @@ from collections import Counter from typing import Dict, Final, Sequence, Tuple -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_shared.constants.symbols import HASH DISPLAY_SEPARATOR: Final[str] = "·" @@ -45,9 +45,9 @@ def format_transformation_gamma(transformation_gamma: int) -> str: return f"{GAMMA_PREFIX}{transformation_gamma}" -def format_generators(generators: Sequence[GeneratorName]) -> str: - """Renders the generators a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``).""" - return GENERATOR_SEPARATOR.join(generator.capitalized for generator in generators) +def format_channels(channels: Sequence[ChannelName]) -> str: + """Renders the channels a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``).""" + return GENERATOR_SEPARATOR.join(channel.capitalized for channel in channels) def format_frequencies(sample_rate: int, nes_frequency: int) -> str: diff --git a/src/sampletones_core/configs/generation.py b/src/sampletones_core/configs/generation.py index df8e54dbb..2aaa8c74c 100644 --- a/src/sampletones_core/configs/generation.py +++ b/src/sampletones_core/configs/generation.py @@ -24,8 +24,8 @@ TRANSITION_VOLUME_WEIGHT, ) from sampletones_core.constants.enums import ( - DEFAULT_GENERATORS, - GeneratorName, + DEFAULT_CHANNELS, + ChannelName, PhaseAlignerName, SelectorName, SpectralDistance, @@ -84,7 +84,13 @@ class GenerationConfig(DataModel): reset_phase: bool = Field(default=RESET_PHASE) final_regeneration: bool = Field(default=FINAL_REGENERATION) - generators: List[GeneratorName] = Field(default_factory=DEFAULT_GENERATORS.copy) + channels: List[ChannelName] = Field( + default_factory=DEFAULT_CHANNELS.copy, + validation_alias=AliasChoices( + "channels", + "generators", + ), + ) calculation: CalculationConfig = Field(default_factory=CalculationConfig) weights: WeightsConfig = Field(default_factory=WeightsConfig) metric: MetricConfig = Field(default_factory=MetricConfig) diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index 9d887fe9c..3649a4acf 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -11,7 +11,7 @@ class LibraryGeneratorName(StrEnum): NOISE = "noise" -class GeneratorName(StrEnum): +class ChannelName(StrEnum): PULSE1 = "pulse1" PULSE2 = "pulse2" TRIANGLE = "triangle" @@ -23,7 +23,7 @@ def capitalized(self) -> str: return spaced_value.capitalize() @classmethod - def items(cls) -> List[GeneratorName]: + def items(cls) -> List[ChannelName]: return [cls.PULSE1, cls.PULSE2, cls.TRIANGLE, cls.NOISE] @@ -84,28 +84,28 @@ class CQTWindow(StrEnum): RECTANGULAR = "rectangular" -GENERATOR_ABBREVIATIONS: Final[Dict[GeneratorName, Literal["P", "p", "T", "N"]]] = { - GeneratorName.PULSE1: "P", - GeneratorName.PULSE2: "p", - GeneratorName.TRIANGLE: "T", - GeneratorName.NOISE: "N", +CHANNEL_ABBREVIATIONS: Final[Dict[ChannelName, Literal["P", "p", "T", "N"]]] = { + ChannelName.PULSE1: "P", + ChannelName.PULSE2: "p", + ChannelName.TRIANGLE: "T", + ChannelName.NOISE: "N", } -GENERATOR_ABBREVIATION_TO_NAME: Final[Dict[str, GeneratorName]] = { - abbreviation: name for name, abbreviation in GENERATOR_ABBREVIATIONS.items() +CHANNEL_ABBREVIATION_TO_NAME: Final[Dict[str, ChannelName]] = { + abbreviation: name for name, abbreviation in CHANNEL_ABBREVIATIONS.items() } -GENERATOR_ABBREVIATION_PATTERN: Final[str] = rf"^[{''.join(GENERATOR_ABBREVIATIONS.values())}]+$" +CHANNEL_ABBREVIATION_PATTERN: Final[str] = rf"^[{''.join(CHANNEL_ABBREVIATIONS.values())}]+$" -DEFAULT_GENERATORS: Final[List[GeneratorName]] = [ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, +DEFAULT_CHANNELS: Final[List[ChannelName]] = [ + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, ] -def abbreviate_generator_names(generator_names: List[GeneratorName]) -> str: - return "".join(GENERATOR_ABBREVIATIONS[name] for name in generator_names) +def abbreviate_channel_names(channel_names: List[ChannelName]) -> str: + return "".join(CHANNEL_ABBREVIATIONS[name] for name in channel_names) diff --git a/src/sampletones_core/constants/field_aliases.py b/src/sampletones_core/constants/field_aliases.py index e8b2b1acd..e64b284c5 100644 --- a/src/sampletones_core/constants/field_aliases.py +++ b/src/sampletones_core/constants/field_aliases.py @@ -6,6 +6,6 @@ "ws": "window_size", "tg": "transformation_gamma", "sm": "spectrum_method", - "gn": "generators", + "gn": "channels", "ch": "config_hash", } diff --git a/src/sampletones_core/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py index f8a841f07..af15dee4a 100644 --- a/src/sampletones_core/exporters/__init__.py +++ b/src/sampletones_core/exporters/__init__.py @@ -3,11 +3,11 @@ from .implementation.noise import NoiseExporter from .implementation.pulse import PulseExporter from .implementation.triangle import TriangleExporter -from .maps import GENERATOR_NAME_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP +from .maps import CHANNEL_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP from .types import ExporterClass, ExporterT, ExporterTypeUnion, ExporterUnion __all__ = [ - "GENERATOR_NAME_TO_EXPORTER_MAP", + "CHANNEL_TO_EXPORTER_MAP", "INSTRUCTION_TO_EXPORTER_MAP", "Exporter", "ExporterClass", diff --git a/src/sampletones_core/exporters/maps.py b/src/sampletones_core/exporters/maps.py index 28ea82bb0..01ad7d210 100644 --- a/src/sampletones_core/exporters/maps.py +++ b/src/sampletones_core/exporters/maps.py @@ -1,6 +1,6 @@ from typing import Dict, Type -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import ( Instruction, NoiseInstruction, @@ -19,9 +19,9 @@ NoiseInstruction: NoiseExporter, } -GENERATOR_NAME_TO_EXPORTER_MAP: Dict[GeneratorName, ExporterTypeUnion] = { - GeneratorName.PULSE1: PulseExporter, - GeneratorName.PULSE2: PulseExporter, - GeneratorName.TRIANGLE: TriangleExporter, - GeneratorName.NOISE: NoiseExporter, +CHANNEL_TO_EXPORTER_MAP: Dict[ChannelName, ExporterTypeUnion] = { + ChannelName.PULSE1: PulseExporter, + ChannelName.PULSE2: PulseExporter, + ChannelName.TRIANGLE: TriangleExporter, + ChannelName.NOISE: NoiseExporter, } diff --git a/src/sampletones_core/exporters/naming.py b/src/sampletones_core/exporters/naming.py index a4871f6a3..387f6cbfd 100644 --- a/src/sampletones_core/exporters/naming.py +++ b/src/sampletones_core/exporters/naming.py @@ -1,8 +1,8 @@ -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName -def instrument_slice_name(base_name: str, generator: GeneratorName) -> str: - """Names one generator slice of a reconstruction. +def instrument_slice_name(base_name: str, channel: ChannelName) -> str: + """Names one channel slice of a reconstruction. Every export path shares this form, so a slice carries the same name whether it reaches a tracker as a standalone instrument file or as one entry of a project's @@ -11,9 +11,9 @@ def instrument_slice_name(base_name: str, generator: GeneratorName) -> str: Args: base_name: The name of the reconstruction or sample the slice came from. - generator: The NES channel the slice covers. + channel: The NES channel the slice covers. Returns: - str: The slice's name, of the form ``base (generator)``. + str: The slice's name, of the form ``base (channel)``. """ - return f"{base_name} ({generator})" + return f"{base_name} ({channel})" diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 2d3618693..da4765603 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Dict, Iterator, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.project.instruments.sample import Sample @@ -12,40 +12,40 @@ @dataclass(frozen=True) class InstrumentSlot: - """Where a sample's generator slice landed in the instrument table.""" + """Where a sample's channel slice landed in the instrument table.""" index: int initial_pitch: int -InstrumentTable = Dict[Tuple[str, GeneratorName], InstrumentSlot] +InstrumentTable = Dict[Tuple[str, ChannelName], InstrumentSlot] @dataclass(frozen=True) class SampleSlice: - """One generator slice of a project sample, numbered for the instrument table. + """One channel slice of a project sample, numbered for the instrument table. Attributes: index: Position the slice takes in the exported instrument table. sample: The sample whose reconstruction the slice came from. - generator: The NES channel the slice covers. + channel: The NES channel the slice covers. features: The per-dimension envelopes describing the slice. """ index: int sample: Sample - generator: GeneratorName + channel: ChannelName features: Features @property def instrument_name(self) -> str: """The exported instrument's name, naming both its sample and its channel.""" - return instrument_slice_name(self.sample.name, self.generator) + return instrument_slice_name(self.sample.name, self.channel) @property - def key(self) -> Tuple[str, GeneratorName]: + def key(self) -> Tuple[str, ChannelName]: """The identity a pattern row references the slice by.""" - return (self.sample.id, self.generator) + return (self.sample.id, self.channel) @property def slot(self) -> InstrumentSlot: @@ -57,7 +57,7 @@ def slot(self) -> InstrumentSlot: def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: - """Walks every generator slice of every sample in instrument-table order. + """Walks every channel slice of every sample in instrument-table order. A sample contributes one slice per channel that plays, so it yields one to four. Slices are numbered in sample order, then channel order, which fixes the instrument numbering @@ -72,16 +72,16 @@ def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: """ index = 0 for sample in project.samples: - features_by_generator = sample.reconstruction.export() - for generator in GeneratorName.items(): - features = features_by_generator[generator] + features_by_channel = sample.reconstruction.export() + for channel in ChannelName.items(): + features = features_by_channel[channel] if not features.has_frames: continue yield SampleSlice( index=index, sample=sample, - generator=generator, + channel=channel, features=features, ) index += 1 diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index d14b4b0ce..d7dea6c0b 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -1,8 +1,8 @@ from .spec import ( CHANNEL_FEATURE_DEFAULTS, + CHANNEL_GENERATOR_KIND, FEATURE_DIMENSION_ORDER, GENERATOR_FEATURE_RANGES, - GENERATOR_KIND, RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH, FeatureRange, @@ -17,7 +17,7 @@ "CHANNEL_FEATURE_DEFAULTS", "FEATURE_DIMENSION_ORDER", "GENERATOR_FEATURE_RANGES", - "GENERATOR_KIND", + "CHANNEL_GENERATOR_KIND", "RESTING_REFERENCE_PERIOD", "RESTING_REFERENCE_PITCH", "FeatureRange", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index dd3c0b0b1..68872eef9 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Dict, Final, List, Tuple -from sampletones_core.constants.enums import FeatureKey, GeneratorName, LibraryGeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey, LibraryGeneratorName from sampletones_core.constants.general import ( ARPEGGIO_MAX, ARPEGGIO_MIN, @@ -59,15 +59,15 @@ class FeatureRange: } -GENERATOR_KIND: Final[Dict[GeneratorName, LibraryGeneratorName]] = { - GeneratorName.PULSE1: LibraryGeneratorName.PULSE, - GeneratorName.PULSE2: LibraryGeneratorName.PULSE, - GeneratorName.TRIANGLE: LibraryGeneratorName.TRIANGLE, - GeneratorName.NOISE: LibraryGeneratorName.NOISE, +CHANNEL_GENERATOR_KIND: Final[Dict[ChannelName, LibraryGeneratorName]] = { + ChannelName.PULSE1: LibraryGeneratorName.PULSE, + ChannelName.PULSE2: LibraryGeneratorName.PULSE, + ChannelName.TRIANGLE: LibraryGeneratorName.TRIANGLE, + ChannelName.NOISE: LibraryGeneratorName.NOISE, } -def resting_reference(generator_name: GeneratorName) -> int: +def resting_reference(channel_name: ChannelName) -> int: """The reference an arpeggio envelope is measured against while a channel describes no frame. A channel with no frames still carries a reference, since the first envelope given to it @@ -75,12 +75,12 @@ def resting_reference(generator_name: GeneratorName) -> int: audible note, and on a noise period between the extremes. Args: - generator_name: The channel whose resting reference is read. + channel_name: The channel whose resting reference is read. Returns: int: The pitch a tonal channel rests at, or the period the noise channel rests at. """ - match GENERATOR_KIND[generator_name]: + match CHANNEL_GENERATOR_KIND[channel_name]: case LibraryGeneratorName.NOISE: return RESTING_REFERENCE_PERIOD case _: @@ -88,7 +88,7 @@ def resting_reference(generator_name: GeneratorName) -> int: def resting_held_features( - generator_name: GeneratorName, + channel_name: ChannelName, ) -> Tuple[FeatureKey, ...]: """The dimensions a channel governs while it describes no frame. @@ -97,12 +97,12 @@ def resting_held_features( one edited down to empty envelopes. Args: - generator_name: The channel whose resting record is read. + channel_name: The channel whose resting record is read. Returns: Tuple[FeatureKey, ...]: The dimensions the channel offers, in dimension order. """ - return tuple(supported_features(GENERATOR_KIND[generator_name])) + return tuple(supported_features(CHANNEL_GENERATOR_KIND[channel_name])) def supported_features( diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index e91dba294..006f4510f 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Dict, List, Optional, Sequence, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.slices import iterate_sample_slices from sampletones_core.formats.bitphase.envelopes import ( @@ -28,7 +28,7 @@ ) from sampletones_core.formats.bitphase.specification.channels import ( CHANNEL_LABELS, - GENERATOR_NAME_TO_CHANNEL_INDEX, + CHANNEL_TO_INDEX, ChannelIndex, ) from sampletones_core.formats.bitphase.specification.chip import ( @@ -88,7 +88,7 @@ class Voice: number: Value a pattern's instrument column carries to play the instrument. instrument: The per-tick rows the channel takes on. table: The per-tick semitone contour that moves the note. - generator: The NES channel the slice was reconstructed for. + channel: The NES channel the slice was reconstructed for. initial_pitch: Pitch the slice's contour is measured against. ticks: How many ticks the instrument runs before it loops. """ @@ -96,24 +96,24 @@ class Voice: number: int instrument: BitphaseInstrument table: BitphaseTable - generator: GeneratorName + channel: ChannelName initial_pitch: int ticks: int -VoiceTable = Dict[Tuple[str, GeneratorName], Voice] +VoiceTable = Dict[Tuple[str, ChannelName], Voice] def _build_voice( index: int, name: str, - generator: GeneratorName, + channel: ChannelName, initial_pitch: int, envelopes: ChannelEnvelopes, *, maximum_table_id: int, ) -> Voice: - """Numbers one generator slice and packages it as an instrument-and-table pair. + """Numbers one channel slice and packages it as an instrument-and-table pair. Instruments and tables are numbered alike, so a pattern cell names the same position in both columns. The document states how far the table numbering reaches, since a song @@ -145,19 +145,19 @@ def _build_voice( loop=envelopes.loop, name=name, ), - generator=generator, + channel=channel, initial_pitch=initial_pitch, ticks=len(envelopes.rows), ) -def _note_cell(channel_generator: GeneratorName, pitch: int) -> NoteCell: +def _note_cell(channel_generator: ChannelName, pitch: int) -> NoteCell: """Resolves a pitch to the note column of the channel the row sits on. The noise channel reads its note as a period selector, so its pitch takes the mapping that reproduces that period; every other channel reads the tuning table. """ - if channel_generator == GeneratorName.NOISE: + if channel_generator == ChannelName.NOISE: return note_index_to_note_cell(noise_period_to_note_index(pitch)) return note_index_to_note_cell(pitch_to_note_index(pitch)) @@ -237,8 +237,8 @@ def _preview_patterns( ) -> Tuple[BitphasePattern, ...]: channel_rows = _empty_channels(length) for voice in voices: - channel = GENERATOR_NAME_TO_CHANNEL_INDEX[voice.generator] - note = _note_cell(voice.generator, voice.initial_pitch) + channel = CHANNEL_TO_INDEX[voice.channel] + note = _note_cell(voice.channel, voice.initial_pitch) channel_rows[channel][PREVIEW_TRIGGER_ROW] = _trigger_row( voice, note, @@ -257,7 +257,7 @@ def _preview_patterns( def sample_to_bitphase(request: SampleExport) -> BitphaseProject: """Builds a playable Bitphase document holding one reconstruction's instruments. - Every generator slice becomes an instrument and the table that carries its pitch + Every channel slice becomes an instrument and the table that carries its pitch contour, and one pattern triggers each slice on the channel it was reconstructed for, so opening the document and pressing play sounds the reconstruction. @@ -274,11 +274,11 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: _build_voice( index, instrument.name, - instrument.generator, + instrument.channel, instrument.features.initial_pitch, features_to_envelopes( instrument.features, - instrument.generator, + instrument.channel, loop=instrument.loop, ), maximum_table_id=MAX_TABLE_ID, @@ -307,7 +307,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: - """Builds a playable Bitphase document holding one generator slice. + """Builds a playable Bitphase document holding one channel slice. Args: request: The slice to write. @@ -334,13 +334,13 @@ def _build_voice_table( for sample_slice in iterate_sample_slices(project): envelopes = features_to_envelopes( sample_slice.features, - sample_slice.generator, + sample_slice.channel, loop=sample_slice.sample.loop, ) voice = _build_voice( sample_slice.index, sample_slice.instrument_name, - sample_slice.generator, + sample_slice.channel, sample_slice.features.initial_pitch, envelopes, maximum_table_id=maximum_table_id, @@ -352,11 +352,10 @@ def _build_voice_table( def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice: - voice = voices.get((reference.sample_id, reference.generator_name)) + voice = voices.get((reference.sample_id, reference.channel_name)) if voice is None: raise ValueError( - f"Row references sample '{reference.sample_id}' slice " - f"'{reference.generator_name}' that has no instrument" + f"Row references sample '{reference.sample_id}' slice " f"'{reference.channel_name}' that has no instrument" ) return voice @@ -381,7 +380,7 @@ def _volume_column(volume: Optional[int]) -> int: def _row_cell( row: Row, - channel_generator: GeneratorName, + channel_generator: ChannelName, voices: VoiceTable, ) -> BitphaseRow: """Converts one tracker line to the Bitphase row that plays it. @@ -415,10 +414,10 @@ def _row_cell( def _channel_rows( rows: Sequence[Row], length: int, - generator: GeneratorName, + channel: ChannelName, voices: VoiceTable, ) -> List[BitphaseRow]: - cells = [_row_cell(row, generator, voices) for row in rows[:length]] + cells = [_row_cell(row, channel, voices) for row in rows[:length]] cells.extend(BitphaseRow() for _ in range(length - len(cells))) return cells @@ -527,20 +526,20 @@ def _project_patterns( groove_table.id, ) - for generator in GeneratorName.items(): - index = frame.get(generator) + for channel_name in ChannelName.items(): + index = frame.get(channel_name) if index is None: continue - pattern = song.channels[generator].pattern(index) + pattern = song.channels[channel_name].pattern(index) if pattern is None: continue - channel = GENERATOR_NAME_TO_CHANNEL_INDEX[generator] - channel_rows[channel] = _channel_rows( + channel_index = CHANNEL_TO_INDEX[channel_name] + channel_rows[channel_index] = _channel_rows( pattern.rows, length, - generator, + channel_name, voices, ) diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index abea08b43..c163e958b 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -3,7 +3,7 @@ import numpy as np -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.feature import Features from sampletones_core.exporters.lengths import equalize_lengths from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow @@ -26,7 +26,7 @@ @dataclass(frozen=True) class ChannelEnvelopes: - """One generator slice expressed the way Bitphase plays it back. + """One channel slice expressed the way Bitphase plays it back. The instrument rows and the table rows advance on their own per-tick counters, so they share a length and a loop point and stay in step for as long as the note @@ -49,23 +49,23 @@ def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: return tuple(int(value) for value in array) -def _pulse_width(generator: GeneratorName, duty_cycle: int) -> int: +def _pulse_width(channel: ChannelName, duty_cycle: int) -> int: """Reads a duty-cycle item as the field the channel uses it for. A square channel takes it as the duty itself; the noise channel takes any nonzero value as its short LFSR mode; the triangle channel plays one fixed waveform. """ - match generator: - case GeneratorName.PULSE1 | GeneratorName.PULSE2: + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: return duty_cycle - case GeneratorName.NOISE: + case ChannelName.NOISE: return NOISE_MODE_SHORT if duty_cycle else NOISE_MODE_LONG - case GeneratorName.TRIANGLE: + case ChannelName.TRIANGLE: return FLAT_PULSE_WIDTH -def _table_offset(generator: GeneratorName, arpeggio: int) -> int: - if generator == GeneratorName.NOISE: +def _table_offset(channel: ChannelName, arpeggio: int) -> int: + if channel == ChannelName.NOISE: return noise_arpeggio_to_table_offset(arpeggio) return arpeggio @@ -89,11 +89,11 @@ def _held_volume(frames: int) -> Tuple[int, ...]: def features_to_envelopes( features: Features, - generator: GeneratorName, + channel: ChannelName, *, loop: bool, ) -> ChannelEnvelopes: - """Converts one generator slice's envelopes into Bitphase instrument and table rows. + """Converts one channel slice's envelopes into Bitphase instrument and table rows. Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's waveform field, and the arpeggio becomes the table contour that moves the note. A @@ -108,7 +108,7 @@ def features_to_envelopes( Args: features: The per-dimension envelopes describing the slice. - generator: The NES channel the slice was reconstructed for. + channel: The NES channel the slice was reconstructed for. loop: Whether the instrument repeats its envelopes while its note is held. Returns: @@ -135,13 +135,13 @@ def features_to_envelopes( rows = tuple( NesInstrumentRow( - pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), + pulse_width=_pulse_width(channel, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), volume_or_rate=volume, ) for frame, volume in enumerate(volumes) ) contour = arpeggios or (NO_TABLE_OFFSET,) * len(volumes) - table_rows = tuple(_table_offset(generator, arpeggio) for arpeggio in contour) + table_rows = tuple(_table_offset(channel, arpeggio) for arpeggio in contour) return ChannelEnvelopes( rows=rows, diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index fbd1a0482..e4c8a9240 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Final, Sequence, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.bitphase.envelopes import features_to_envelopes from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow from sampletones_core.formats.bitphase.notes import pitch_to_note_index @@ -24,7 +24,7 @@ def _tone_offsets( - generator: GeneratorName, + channel: ChannelName, initial_pitch: int, contour: Sequence[int], ) -> Tuple[int, ...]: @@ -37,7 +37,7 @@ def _tone_offsets( than from a period offset, so its rows hold a flat offset and the note carries the pitch. """ - if generator == GeneratorName.NOISE: + if channel == ChannelName.NOISE: return (NO_TONE_OFFSET,) * len(contour) base_index = pitch_to_note_index(initial_pitch) @@ -56,18 +56,18 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: """Builds the single-instrument file Bitphase's instruments panel loads. Args: - request: The generator slice to write. + request: The channel slice to write. Returns: BitphaseInstrumentPreset: The instrument to serialize. """ envelopes = features_to_envelopes( request.features, - request.generator, + request.channel, loop=request.loop, ) offsets = _tone_offsets( - request.generator, + request.channel, request.features.initial_pitch, envelopes.table_rows, ) diff --git a/src/sampletones_core/formats/bitphase/specification/channels.py b/src/sampletones_core/formats/bitphase/specification/channels.py index 838a6f555..25c6d4f3a 100644 --- a/src/sampletones_core/formats/bitphase/specification/channels.py +++ b/src/sampletones_core/formats/bitphase/specification/channels.py @@ -1,7 +1,7 @@ from enum import IntEnum from typing import Dict, Final, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class ChannelIndex(IntEnum): @@ -23,9 +23,9 @@ class ChannelIndex(IntEnum): ) CHANNEL_COUNT: Final[int] = len(CHANNEL_LABELS) -GENERATOR_NAME_TO_CHANNEL_INDEX: Final[Dict[GeneratorName, ChannelIndex]] = { - GeneratorName.PULSE1: ChannelIndex.SQUARE1, - GeneratorName.PULSE2: ChannelIndex.SQUARE2, - GeneratorName.TRIANGLE: ChannelIndex.TRIANGLE, - GeneratorName.NOISE: ChannelIndex.NOISE, +CHANNEL_TO_INDEX: Final[Dict[ChannelName, ChannelIndex]] = { + ChannelName.PULSE1: ChannelIndex.SQUARE1, + ChannelName.PULSE2: ChannelIndex.SQUARE2, + ChannelName.TRIANGLE: ChannelIndex.TRIANGLE, + ChannelName.NOISE: ChannelIndex.NOISE, } diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 8d797231b..1ed438872 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -1,6 +1,6 @@ from typing import List, Optional, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.slices import ( InstrumentSlot, @@ -26,7 +26,7 @@ ) from sampletones_core.formats.famitracker.specification.channels import ( CHANNEL_COUNT_2A03, - GENERATOR_NAME_TO_CHANNEL_ID, + CHANNEL_TO_ID, ChannelId, ) from sampletones_core.formats.famitracker.specification.instruments import ( @@ -68,7 +68,7 @@ def build_instrument( *, loop: bool, ) -> Instrument2A03: - """Builds one FamiTracker instrument from the envelopes of a generator slice. + """Builds one FamiTracker instrument from the envelopes of a channel slice. The slice's envelopes become the instrument's five 2A03 sequences, so an instrument reaching a ``.fti`` file on its own and one taking a slot in a module are built the same way. @@ -99,7 +99,7 @@ def build_instrument( def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], InstrumentTable]: - """Builds one FamiTracker instrument per generator slice of every sample. + """Builds one FamiTracker instrument per channel slice of every sample. Each sample contributes one instrument for every channel its reconstruction covers, so a sample yields one to four instruments. Instruments are numbered in @@ -127,11 +127,11 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst def _note_and_octave( transpose: int, - channel_generator: GeneratorName, + channel_generator: ChannelName, slot: InstrumentSlot, ) -> Tuple[int, int]: base_pitch = slot.initial_pitch + transpose - if channel_generator == GeneratorName.NOISE: + if channel_generator == ChannelName.NOISE: cell = period_to_note_cell(base_pitch) else: cell = pitch_to_note_cell(base_pitch) @@ -142,7 +142,7 @@ def _note_and_octave( def _row_cell( row: Row, row_number: int, - channel_generator: GeneratorName, + channel_generator: ChannelName, slots: InstrumentTable, ) -> Optional[RowCell]: note = EMPTY_NOTE @@ -154,11 +154,11 @@ def _row_cell( case NoteOff(): note = int(NoteValue.HALT) case Instrument() as reference: - slot = slots.get((reference.sample_id, reference.generator_name)) + slot = slots.get((reference.sample_id, reference.channel_name)) if slot is None: raise ValueError( f"Row references sample '{reference.sample_id}' slice " - f"'{reference.generator_name}' that has no instrument" + f"'{reference.channel_name}' that has no instrument" ) instrument = slot.index note, octave = _note_and_octave( @@ -191,11 +191,11 @@ def _has_data(cell: RowCell) -> bool: def _channel_patterns( - generator: GeneratorName, + name: ChannelName, channel: Channel, slots: InstrumentTable, ) -> List[PatternData]: - channel_id = GENERATOR_NAME_TO_CHANNEL_ID[generator] + channel_id = CHANNEL_TO_ID[name] patterns: List[PatternData] = [] for index in sorted(channel.patterns): @@ -209,7 +209,7 @@ def _channel_patterns( _row_cell( row, row_number, - generator, + name, slots, ) for row_number, row in enumerate(pattern.rows) @@ -239,17 +239,17 @@ def _build_order(song: Song) -> Tuple[OrderFrame, ...]: if len(song.order) > MAX_FRAMES: raise ValueError(f"Order length {len(song.order)} exceeds the FamiTracker limit of {MAX_FRAMES} frames") - empty_indices = {generator: _reserved_empty_index(song.channels[generator]) for generator in GeneratorName.items()} - for generator, empty_index in empty_indices.items(): + empty_indices = {channel: _reserved_empty_index(song.channels[channel]) for channel in ChannelName.items()} + for channel, empty_index in empty_indices.items(): if empty_index > MAX_PATTERN_INDEX: - raise ValueError(f"Channel '{generator}' has no free pattern index for empty order slots") + raise ValueError(f"Channel '{channel}' has no free pattern index for empty order slots") frames: List[OrderFrame] = [] for frame in song.order: entries: List[int] = [] - for generator in GeneratorName.items(): - index = frame.get(generator) - entries.append(index if index is not None else empty_indices[generator]) + for channel in ChannelName.items(): + index = frame.get(channel) + entries.append(index if index is not None else empty_indices[channel]) entries.append(DPCM_EMPTY_PATTERN_INDEX) frames.append(tuple(entries)) @@ -282,11 +282,11 @@ def project_to_module(project: Project) -> FamiTrackerModule: ) patterns: List[PatternData] = [] - for generator in GeneratorName.items(): + for channel in ChannelName.items(): patterns.extend( _channel_patterns( - generator, - song.channels[generator], + channel, + song.channels[channel], slots, ), ) diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index 481ad4941..40f6a9a64 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Dict, Iterable -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence @@ -71,7 +71,7 @@ def features_footprint( *, loop: bool, ) -> InstrumentFootprint: - """Measures the instrument a generator slice's envelopes export to. + """Measures the instrument a channel slice's envelopes export to. The envelopes pass through the same builder an export uses, so the measured item counts are the ones a file carries: brought to one shared length and capped at what a FamiTracker @@ -99,7 +99,7 @@ def reconstruction_footprints( reconstruction: Reconstruction, *, loop: bool, -) -> Dict[GeneratorName, InstrumentFootprint]: +) -> Dict[ChannelName, InstrumentFootprint]: """Measures one instrument per channel a reconstruction plays. An export writes an instrument for each channel that plays, so the result holds an entry @@ -111,11 +111,11 @@ def reconstruction_footprints( loop: Whether the sample carrying it loops while its note is held. Returns: - Dict[GeneratorName, InstrumentFootprint]: The footprint of each playing channel's instrument. + Dict[ChannelName, InstrumentFootprint]: The footprint of each playing channel's instrument. """ return { - generator_name: features_footprint(features, loop=loop) - for generator_name, features in reconstruction.export().items() + channel_name: features_footprint(features, loop=loop) + for channel_name, features in reconstruction.export().items() if features.has_frames } diff --git a/src/sampletones_core/formats/famitracker/specification/channels.py b/src/sampletones_core/formats/famitracker/specification/channels.py index 867d032e3..59cea03e2 100644 --- a/src/sampletones_core/formats/famitracker/specification/channels.py +++ b/src/sampletones_core/formats/famitracker/specification/channels.py @@ -1,7 +1,7 @@ from enum import IntEnum from typing import Dict, Final -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class ChannelId(IntEnum): @@ -16,9 +16,9 @@ class ChannelId(IntEnum): CHANNEL_COUNT_2A03: Final[int] = 5 -GENERATOR_NAME_TO_CHANNEL_ID: Final[Dict[GeneratorName, ChannelId]] = { - GeneratorName.PULSE1: ChannelId.SQUARE1, - GeneratorName.PULSE2: ChannelId.SQUARE2, - GeneratorName.TRIANGLE: ChannelId.TRIANGLE, - GeneratorName.NOISE: ChannelId.NOISE, +CHANNEL_TO_ID: Final[Dict[ChannelName, ChannelId]] = { + ChannelName.PULSE1: ChannelId.SQUARE1, + ChannelName.PULSE2: ChannelId.SQUARE2, + ChannelName.TRIANGLE: ChannelId.TRIANGLE, + ChannelName.NOISE: ChannelId.NOISE, } diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py index 95edc7d2b..dc45839ff 100644 --- a/src/sampletones_core/generators/__init__.py +++ b/src/sampletones_core/generators/__init__.py @@ -3,8 +3,8 @@ from .implementation.pulse import PulseGenerator from .implementation.triangle import TriangleGenerator from .maps import ( + CHANNEL_CLASSES, GENERATOR_CLASS_MAP, - GENERATOR_CLASSES, GENERATOR_TO_INSTRUCTION_MAP, INSTRUCTION_TO_GENERATOR_MAP, LIBRARY_GENERATOR_CLASS_MAP, @@ -19,13 +19,13 @@ ) from .utils import ( get_generator_by_instruction, - get_generators_by_names, + get_generators_by_channels, get_generators_map, get_remaining_generator_classes, ) __all__ = [ - "GENERATOR_CLASSES", + "CHANNEL_CLASSES", "GENERATOR_CLASS_MAP", "GENERATOR_TO_INSTRUCTION_MAP", "INSTRUCTION_TO_GENERATOR_MAP", @@ -41,7 +41,7 @@ "PulseGenerator", "TriangleGenerator", "get_generator_by_instruction", - "get_generators_by_names", + "get_generators_by_channels", "get_generators_map", "get_remaining_generator_classes", ] diff --git a/src/sampletones_core/generators/implementation/noise.py b/src/sampletones_core/generators/implementation/noise.py index 0eb662d6e..b30161838 100644 --- a/src/sampletones_core/generators/implementation/noise.py +++ b/src/sampletones_core/generators/implementation/noise.py @@ -3,7 +3,7 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.constants.general import ( MAX_VOLUME, MIXER_NOISE, @@ -20,7 +20,7 @@ class NoiseGenerator(Generator[NoiseInstruction, LFSRTimer]): def __init__( self, config: Config, - name: str = GeneratorName.NOISE, + name: str = ChannelName.NOISE, ) -> None: super().__init__(config, name) self.timer = LFSRTimer( diff --git a/src/sampletones_core/generators/implementation/pulse.py b/src/sampletones_core/generators/implementation/pulse.py index 7ef85e4ee..766a26370 100644 --- a/src/sampletones_core/generators/implementation/pulse.py +++ b/src/sampletones_core/generators/implementation/pulse.py @@ -3,7 +3,7 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.constants.general import ( DUTY_CYCLES, MAX_VOLUME, @@ -21,7 +21,7 @@ class PulseGenerator(Generator[PulseInstruction, PhaseTimer]): def __init__( self, config: Config, - name: str = GeneratorName.PULSE1, + name: str = ChannelName.PULSE1, ) -> None: super().__init__(config, name) self.timer = PhaseTimer( diff --git a/src/sampletones_core/generators/implementation/triangle.py b/src/sampletones_core/generators/implementation/triangle.py index 463ea4763..b3771cc71 100644 --- a/src/sampletones_core/generators/implementation/triangle.py +++ b/src/sampletones_core/generators/implementation/triangle.py @@ -3,7 +3,7 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.constants.general import ( MIN_PITCH, MIXER_TRIANGLE, @@ -23,7 +23,7 @@ class TriangleGenerator(Generator[TriangleInstruction, PhaseTimer]): def __init__( self, config: Config, - name: str = GeneratorName.TRIANGLE, + name: str = ChannelName.TRIANGLE, ) -> None: super().__init__(config, name) self.timer = PhaseTimer( diff --git a/src/sampletones_core/generators/maps.py b/src/sampletones_core/generators/maps.py index abc838b94..60d33e973 100644 --- a/src/sampletones_core/generators/maps.py +++ b/src/sampletones_core/generators/maps.py @@ -1,8 +1,8 @@ from typing import Dict, Final from sampletones_core.constants.enums import ( + ChannelName, GeneratorClassName, - GeneratorName, LibraryGeneratorName, ) from sampletones_core.constants.general import ( @@ -29,11 +29,11 @@ } -GENERATOR_CLASSES: Final[Dict[GeneratorName, GeneratorTypeUnion]] = { - GeneratorName.PULSE1: PulseGenerator, - GeneratorName.PULSE2: PulseGenerator, - GeneratorName.TRIANGLE: TriangleGenerator, - GeneratorName.NOISE: NoiseGenerator, +CHANNEL_CLASSES: Final[Dict[ChannelName, GeneratorTypeUnion]] = { + ChannelName.PULSE1: PulseGenerator, + ChannelName.PULSE2: PulseGenerator, + ChannelName.TRIANGLE: TriangleGenerator, + ChannelName.NOISE: NoiseGenerator, } diff --git a/src/sampletones_core/generators/utils.py b/src/sampletones_core/generators/utils.py index c1c78308e..9262c87e7 100644 --- a/src/sampletones_core/generators/utils.py +++ b/src/sampletones_core/generators/utils.py @@ -1,30 +1,30 @@ from typing import Dict, List from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.instructions import ( INSTRUCTION_CLASS_MAP, InstructionUnion, ) from .maps import ( + CHANNEL_CLASSES, GENERATOR_CLASS_MAP, - GENERATOR_CLASSES, INSTRUCTION_TO_GENERATOR_MAP, ) from .types import GeneratorUnion -def get_generators_by_names( +def get_generators_by_channels( config: Config, - generator_names: List[GeneratorName], -) -> Dict[GeneratorName, GeneratorUnion]: - names = generator_names.copy() - if GeneratorName.PULSE2 in names and not GeneratorName.PULSE1 in names: - names.remove(GeneratorName.PULSE2) - names.insert(0, GeneratorName.PULSE1) + channel_names: List[ChannelName], +) -> Dict[ChannelName, GeneratorUnion]: + names = channel_names.copy() + if ChannelName.PULSE2 in names and not ChannelName.PULSE1 in names: + names.remove(ChannelName.PULSE2) + names.insert(0, ChannelName.PULSE1) - return {name: GENERATOR_CLASSES[name](config, name) for name in names} + return {name: CHANNEL_CLASSES[name](config, name) for name in names} def get_generators_map( @@ -34,9 +34,9 @@ def get_generators_map( def get_remaining_generator_classes( - remaining_generators: Dict[GeneratorName, GeneratorUnion], + remaining_channels: Dict[ChannelName, GeneratorUnion], ) -> Dict[GeneratorClassName, GeneratorUnion]: - return {generator.class_name(): generator for generator in reversed(remaining_generators.values())} + return {generator.class_name(): generator for generator in reversed(remaining_channels.values())} def get_generator_by_instruction( diff --git a/src/sampletones_core/instructions/instruction.py b/src/sampletones_core/instructions/instruction.py index 3f52b666c..65882da47 100644 --- a/src/sampletones_core/instructions/instruction.py +++ b/src/sampletones_core/instructions/instruction.py @@ -9,10 +9,10 @@ class Instruction(DataModel, ABC): """ - A single-frame command that drives one generator channel. + A single-frame command that drives one NES channel. Each concrete instruction (pulse, triangle, noise) carries the parameters its - generator reads to synthesize one frame of audio — pitch, volume, and any timbre + channel reads to synthesize one frame of audio — pitch, volume, and any timbre controls — together with the ``on`` flag that decides whether the channel sounds. Instances are immutable and validated, so an instruction is a stable value safe to store, hash, and compare. diff --git a/src/sampletones_core/project/instruments/instrument.py b/src/sampletones_core/project/instruments/instrument.py index 3156c441e..64fdda203 100644 --- a/src/sampletones_core/project/instruments/instrument.py +++ b/src/sampletones_core/project/instruments/instrument.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class Instrument(BaseModel): @@ -15,7 +15,7 @@ class Instrument(BaseModel): model_config = ConfigDict(frozen=True) sample_id: str = Field(..., description="Stable id of the referenced sample.") - generator_name: GeneratorName = Field( + channel_name: ChannelName = Field( ..., description="Which reconstruction channel-slice to use.", ) diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py index eb4f81df2..af76fbcd1 100644 --- a/src/sampletones_core/project/patterns/channel.py +++ b/src/sampletones_core/project/patterns/channel.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row @@ -21,12 +21,12 @@ class Channel(BaseModel): position and how many positions exist. """ - generator: GeneratorName = Field(..., description="The NES channel this pool drives.") + name: ChannelName = Field(..., description="The NES channel this pool drives.") patterns: Dict[int, Pattern] = Field(..., description="Pattern pool keyed by index.") @classmethod - def empty(cls, generator: GeneratorName, rows_per_pattern: int) -> Channel: - return cls(generator=generator, patterns={0: Pattern.empty(rows_per_pattern)}) + def empty(cls, name: ChannelName, rows_per_pattern: int) -> Channel: + return cls(name=name, patterns={0: Pattern.empty(rows_per_pattern)}) def pattern(self, index: int) -> Optional[Pattern]: return self.patterns.get(index) @@ -84,4 +84,4 @@ def set_row(self, index: int, row_index: int, row: Row) -> None: self.patterns[index].rows[row_index] = row def __repr__(self) -> str: - return f"Channel(generator={self.generator}, patterns={len(self.patterns)})" + return f"Channel(name={self.name}, patterns={len(self.patterns)})" diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py index f779f8d06..d3e3862f2 100644 --- a/src/sampletones_core/project/song.py +++ b/src/sampletones_core/project/song.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row @@ -34,35 +34,35 @@ class Song(BaseModel): le=MAX_ROWS_PER_PATTERN, description="Row count enforced for every pattern.", ) - order: List[Dict[GeneratorName, Optional[int]]] = Field( + order: List[Dict[ChannelName, Optional[int]]] = Field( ..., description="Arrangement: one frame per position, mapping each channel to a pattern index." ) - channels: Dict[GeneratorName, Channel] = Field(..., description="Pattern pool per NES channel.") + channels: Dict[ChannelName, Channel] = Field(..., description="Pattern pool per NES channel.") @classmethod def empty(cls, rows_per_pattern: int) -> Song: - channels = {generator: Channel.empty(generator, rows_per_pattern) for generator in GeneratorName.items()} - first_frame: Dict[GeneratorName, Optional[int]] = {generator: 0 for generator in GeneratorName.items()} + channels = {channel: Channel.empty(channel, rows_per_pattern) for channel in ChannelName.items()} + first_frame: Dict[ChannelName, Optional[int]] = {channel: 0 for channel in ChannelName.items()} return cls(rows_per_pattern=rows_per_pattern, order=[first_frame], channels=channels) - def __getitem__(self, generator: GeneratorName) -> Channel: - return self.channels[generator] + def __getitem__(self, channel: ChannelName) -> Channel: + return self.channels[channel] - def pattern(self, generator: GeneratorName, index: int) -> Optional[Pattern]: - return self.channels[generator].pattern(index) + def pattern(self, channel_name: ChannelName, index: int) -> Optional[Pattern]: + return self.channels[channel_name].pattern(index) def order_length(self) -> int: return len(self.order) - def ordered_patterns(self, generator: GeneratorName) -> List[Optional[Pattern]]: - channel = self.channels[generator] + def ordered_patterns(self, channel_name: ChannelName) -> List[Optional[Pattern]]: + channel = self.channels[channel_name] return [ channel.patterns.get(index) if index is not None else None - for index in (frame.get(generator) for frame in self.order) + for index in (frame.get(channel_name) for frame in self.order) ] - def _empty_frame(self) -> Dict[GeneratorName, Optional[int]]: - return {generator: None for generator in GeneratorName.items()} + def _empty_frame(self) -> Dict[ChannelName, Optional[int]]: + return {channel: None for channel in ChannelName.items()} def append_frame(self) -> None: self.order.append(self._empty_frame()) @@ -70,8 +70,8 @@ def append_frame(self) -> None: def insert_frame(self, position: int) -> None: self.order.insert(position, self._empty_frame()) - def set_order_entry(self, position: int, generator: GeneratorName, index: Optional[int]) -> None: - self.order[position][generator] = index + def set_order_entry(self, position: int, channel: ChannelName, index: Optional[int]) -> None: + self.order[position][channel] = index def remove_frame(self, position: int) -> None: del self.order[position] @@ -80,26 +80,26 @@ def move_frame(self, from_position: int, to_position: int) -> None: frame = self.order.pop(from_position) self.order.insert(to_position, frame) - def add_pattern(self, generator: GeneratorName) -> int: - """Adds an empty pattern to ``generator`` at a free index and returns it. + def add_pattern(self, channel: ChannelName) -> int: + """Adds an empty pattern to ``channel`` at a free index and returns it. The index clears both the channel's pool and every index its order slots already reference, so it never aliases a slot whose pattern is unmaterialised. """ - return self.channels[generator].add_pattern( + return self.channels[channel].add_pattern( self.rows_per_pattern, - reserved_indices=self._referenced_indices(generator), + reserved_indices=self._referenced_indices(channel), ) - def clone_pattern(self, generator: GeneratorName, index: int) -> int: - """Clones ``generator``'s pattern at ``index`` into a free index and returns it. + def clone_pattern(self, channel: ChannelName, index: int) -> int: + """Clones ``channel``'s pattern at ``index`` into a free index and returns it. The clone index clears the channel's pool and every order-referenced index, so the copy stays independent of any slot the order already plays. """ - return self.channels[generator].clone_pattern( + return self.channels[channel].clone_pattern( index, - reserved_indices=self._referenced_indices(generator), + reserved_indices=self._referenced_indices(channel), ) def duplicate_frame(self, position: int) -> None: @@ -121,30 +121,30 @@ def clone_frame(self, position: int) -> None: the other unchanged. Silent slots stay silent. """ source_frame = self.order[position] - clone: Dict[GeneratorName, Optional[int]] = {} - for generator in GeneratorName.items(): - index = source_frame.get(generator) + clone: Dict[ChannelName, Optional[int]] = {} + for channel in ChannelName.items(): + index = source_frame.get(channel) if index is None: - clone[generator] = None + clone[channel] = None continue - self.channels[generator].ensure_pattern(index, self.rows_per_pattern) - clone[generator] = self.clone_pattern(generator, index) + self.channels[channel].ensure_pattern(index, self.rows_per_pattern) + clone[channel] = self.clone_pattern(channel, index) self.order.insert(position + 1, clone) - def _referenced_indices(self, generator: GeneratorName) -> Set[int]: - return {index for frame in self.order if (index := frame.get(generator)) is not None} + def _referenced_indices(self, channel: ChannelName) -> Set[int]: + return {index for frame in self.order if (index := frame.get(channel)) is not None} def clear_frame(self, position: int) -> None: self.order[position] = self._empty_frame() - def remove_pattern(self, generator: GeneratorName, index: int) -> None: + def remove_pattern(self, channel: ChannelName, index: int) -> None: """Removes a pattern from the pool and clears all order references to it.""" - self.channels[generator].remove_pattern(index) + self.channels[channel].remove_pattern(index) for frame in self.order: - if frame.get(generator) == index: - frame[generator] = None + if frame.get(channel) == index: + frame[channel] = None def references_sample(self, sample_id: str) -> bool: """Whether any row in any pattern of any channel still points at the sample.""" diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index 9ff18843c..e284dd05a 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -9,11 +9,11 @@ format_transformation, ) from sampletones_core.constants.enums import ( - GENERATOR_ABBREVIATION_PATTERN, - GENERATOR_ABBREVIATION_TO_NAME, - GeneratorName, + CHANNEL_ABBREVIATION_PATTERN, + CHANNEL_ABBREVIATION_TO_NAME, + ChannelName, SpectrumMethod, - abbreviate_generator_names, + abbreviate_channel_names, ) from sampletones_core.constants.field_aliases import ALIASES from sampletones_shared.utils.serialization import HASH_PATTERN, hash_models @@ -37,12 +37,12 @@ class ConfigDirectoryFields(BaseModel): nf: int = Field(gt=0, validation_alias=ALIASES["nf"]) sm: SpectrumMethod = Field(validation_alias=ALIASES["sm"]) tg: int = Field(ge=0, validation_alias=ALIASES["tg"]) - gn: str = Field(pattern=GENERATOR_ABBREVIATION_PATTERN, validation_alias=ALIASES["gn"]) + gn: str = Field(pattern=CHANNEL_ABBREVIATION_PATTERN, validation_alias=ALIASES["gn"]) ch: str = Field(pattern=HASH_PATTERN, validation_alias=ALIASES["ch"]) @property - def generators(self) -> Tuple[GeneratorName, ...]: - return tuple(GENERATOR_ABBREVIATION_TO_NAME[character] for character in self.gn) + def channels(self) -> Tuple[ChannelName, ...]: + return tuple(CHANNEL_ABBREVIATION_TO_NAME[character] for character in self.gn) @classmethod def from_config(cls, config: Config) -> Self: @@ -51,7 +51,7 @@ def from_config(cls, config: Config) -> Self: nf=config.library.nes_frequency, sm=config.library.spectrum_method, tg=config.library.transformation_gamma, - gn=abbreviate_generator_names(config.generation.generators), + gn=abbreviate_channel_names(config.generation.channels), ch=hash_models(config.library, config.generation), ) diff --git a/src/sampletones_core/reconstructions/reconstruction/approximations.py b/src/sampletones_core/reconstructions/reconstruction/approximations.py index 8956a60d7..55e791acc 100644 --- a/src/sampletones_core/reconstructions/reconstruction/approximations.py +++ b/src/sampletones_core/reconstructions/reconstruction/approximations.py @@ -1,7 +1,7 @@ import numpy as np from pydantic import ConfigDict, Field, field_serializer -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_shared.types.data import SerializedData from sampletones_shared.utils.serialization import serialize_array @@ -10,8 +10,8 @@ class ApproximationsItem(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) - generator_name: GeneratorName = Field(..., description="Name of the generator") - approximation: np.ndarray = Field(..., description="Audio approximation for the generator") + channel_name: ChannelName = Field(..., description="Name of the channel") + approximation: np.ndarray = Field(..., description="Audio approximation for the channel") @field_serializer("approximation") def serialize_approximation(self, approximation: np.ndarray) -> SerializedData: diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index 7fe2fd894..263e71113 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -4,7 +4,7 @@ from pydantic import ConfigDict, Field -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.data import DataModel from sampletones_core.features import resting_held_features, resting_reference from sampletones_core.instructions import InstructionData, InstructionUnion @@ -13,33 +13,33 @@ class InstructionsItem(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) - generator_name: GeneratorName = Field( + channel_name: ChannelName = Field( ..., - description="Name of the generator", + description="Name of the channel", ) instructions: List[InstructionData[InstructionUnion]] = Field( ..., - description="List of instruction data for the generator", + description="List of instruction data for the channel", ) initial_pitch: int = Field( ..., - description="Reference pitch the generator's arpeggio envelope is measured against", + description="Reference pitch the channel's arpeggio envelope is measured against", ) held_features: List[FeatureKey] = Field( ..., - description="Dimensions the channel governs, keeping the value it holds while the generator sounds", + description="Dimensions the channel governs, keeping the value it holds while the channel sounds", ) @classmethod def create( cls, - generator_name: GeneratorName, + channel_name: ChannelName, instructions: List[InstructionUnion], initial_pitch: int, held_features: Iterable[FeatureKey], ) -> InstructionsItem: return InstructionsItem( - generator_name=generator_name, + channel_name=channel_name, instructions=[ InstructionData( instruction_class=instruction.class_name(), @@ -52,7 +52,7 @@ def create( ) @classmethod - def resting(cls, generator_name: GeneratorName) -> InstructionsItem: + def resting(cls, channel_name: ChannelName) -> InstructionsItem: """The stream a channel carries while it stands by, describing no frame. A reconstruction holds one stream per channel, so a channel it leaves silent is @@ -62,14 +62,14 @@ def resting(cls, generator_name: GeneratorName) -> InstructionsItem: frame records and what an export of this stream reads back. Args: - generator_name: The channel the resting stream belongs to. + channel_name: The channel the resting stream belongs to. Returns: InstructionsItem: The stream of a channel that stands by. """ return cls.create( - generator_name=generator_name, + channel_name=channel_name, instructions=[], - initial_pitch=resting_reference(generator_name), - held_features=resting_held_features(generator_name), + initial_pitch=resting_reference(channel_name), + held_features=resting_held_features(channel_name), ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index fa3939f41..517d75611 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -23,16 +23,16 @@ from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.compatibility.upgrade import upgrade_binary from sampletones_core.configs import Config -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.data import DataModel, Metadata, MetadataContract from sampletones_core.exporters import ( - GENERATOR_NAME_TO_EXPORTER_MAP, + CHANNEL_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP, ExporterTypeUnion, ExporterUnion, Features, ) -from sampletones_core.generators.maps import GENERATOR_CLASSES +from sampletones_core.generators.maps import CHANNEL_CLASSES from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION from sampletones_shared.exceptions import ( @@ -85,11 +85,11 @@ class Reconstruction(DataModel): ) approximations_data: List[ApproximationsItem] = Field( ..., - description="Approximations per generator", + description="Approximations per channel", ) instructions_data: List[InstructionsItem] = Field( ..., - description="Instructions per generator", + description="Instructions per channel", ) coefficient: float = Field( ..., @@ -97,11 +97,11 @@ class Reconstruction(DataModel): ) @cached_property - def approximations(self) -> Dict[GeneratorName, np.ndarray]: - return {item.generator_name: item.approximation for item in self.approximations_data} + def approximations(self) -> Dict[ChannelName, np.ndarray]: + return {item.channel_name: item.approximation for item in self.approximations_data} @cached_property - def streams(self) -> Dict[GeneratorName, InstructionsItem]: + def streams(self) -> Dict[ChannelName, InstructionsItem]: """The instruction stream each channel carries, in channel order. This is where the channel set is made whole: a channel the stored data names a stream @@ -109,43 +109,43 @@ def streams(self) -> Dict[GeneratorName, InstructionsItem]: carries. Every per-channel view reads from here, so each of them covers the four channels however a reconstruction reached memory. """ - stored = {item.generator_name: item for item in self.instructions_data} + stored = {item.channel_name: item for item in self.instructions_data} return { - generator_name: stored.get(generator_name, InstructionsItem.resting(generator_name)) - for generator_name in GeneratorName.items() + channel_name: stored.get(channel_name, InstructionsItem.resting(channel_name)) + for channel_name in ChannelName.items() } @cached_property - def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]: + def instructions(self) -> Dict[ChannelName, List[InstructionUnion]]: return { - generator_name: [instruction.instruction for instruction in item.instructions] - for generator_name, item in self.streams.items() + channel_name: [instruction.instruction for instruction in item.instructions] + for channel_name, item in self.streams.items() } @cached_property - def initial_pitches(self) -> Dict[GeneratorName, int]: - """The reference pitch each generator's arpeggio envelope is measured against.""" - return {generator_name: item.initial_pitch for generator_name, item in self.streams.items()} + def initial_pitches(self) -> Dict[ChannelName, int]: + """The reference pitch each channel's arpeggio envelope is measured against.""" + return {channel_name: item.initial_pitch for channel_name, item in self.streams.items()} @cached_property - def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: - """The dimensions each generator leaves to the channel. + def held_features(self) -> Dict[ChannelName, Tuple[FeatureKey, ...]]: + """The dimensions each channel's instrument writes for itself. An instruction states every dimension of its frame, so which of them the instrument itself writes is stated here: the rest are the channel's, and an export leaves their envelopes empty for the player to fill from the value it holds. """ - return {generator_name: tuple(item.held_features) for generator_name, item in self.streams.items()} + return {channel_name: tuple(item.held_features) for channel_name, item in self.streams.items()} @cached_property - def playing_generators(self) -> Tuple[GeneratorName, ...]: + def playing_channels(self) -> Tuple[ChannelName, ...]: """The channels whose instruction stream describes a frame. A reconstruction holds a stream for every channel, so this is what says which of them play: the rest stand by, exporting nothing and costing nothing, while describing a frame is what puts one in play. """ - return tuple(generator_name for generator_name, item in self.streams.items() if item.instructions) + return tuple(channel_name for channel_name, item in self.streams.items() if item.instructions) @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: @@ -154,16 +154,16 @@ def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: @classmethod def _exporter_class( cls, - generator_name: GeneratorName, + channel_name: ChannelName, instructions: List[InstructionUnion], ) -> ExporterTypeUnion: """The exporter a channel's stream is read through. The instruction type names the exporter wherever the stream describes a frame; a - channel standing by takes the exporter its generator name pairs with. + channel standing by takes the exporter its channel name pairs with. """ if not instructions: - return GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] + return CHANNEL_TO_EXPORTER_MAP[channel_name] return cls._get_exporter_class(instructions[0]) @@ -180,8 +180,8 @@ def _derive_initial_pitch(cls, instructions: List[InstructionUnion]) -> int: def create( cls, approximation: np.ndarray, - approximations: Mapping[GeneratorName, np.ndarray], - instructions: Mapping[GeneratorName, Sequence[InstructionUnion]], + approximations: Mapping[ChannelName, np.ndarray], + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], config: Config, coefficient: float, audio_filepath: Path, @@ -189,23 +189,23 @@ def create( approximation = np.nan_to_num(approximation, nan=0.0) approximations_data: List[ApproximationsItem] = [ ApproximationsItem( - generator_name=generator_name, - approximation=approximations[generator_name], + channel_name=channel_name, + approximation=approximations[channel_name], ) - for generator_name in GeneratorName.items() - if generator_name in approximations + for channel_name in ChannelName.items() + if channel_name in approximations ] instructions_data: List[InstructionsItem] = [] - for generator_name in GeneratorName.items(): - channel_instructions = list(instructions.get(generator_name, ())) + for channel_name in ChannelName.items(): + channel_instructions = list(instructions.get(channel_name, ())) if not channel_instructions: - instructions_data.append(InstructionsItem.resting(generator_name)) + instructions_data.append(InstructionsItem.resting(channel_name)) continue instructions_data.append( InstructionsItem.create( - generator_name=generator_name, + channel_name=channel_name, instructions=channel_instructions, initial_pitch=cls._derive_initial_pitch(channel_instructions), held_features=(), @@ -246,15 +246,15 @@ def from_state( audio_filepath=path, ) - def update_generator_data( + def update_channel_data( self, - generator_name: GeneratorName, + channel_name: ChannelName, instructions: List[InstructionUnion], partial_approximation: np.ndarray, initial_pitch: int, held_features: Iterable[FeatureKey], ) -> None: - """Replaces one generator's instructions, audio, reference pitch, and held dimensions. + """Replaces one channel's instructions, audio, reference pitch, and held dimensions. The reference pitch travels with the instructions it produced, so a later export measures the arpeggio against the same base the edit was made from. The held @@ -266,9 +266,9 @@ def update_generator_data( long as it carries samples, which keeps silence out of the stored waveforms. """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") - rendered = {name: audio for name, audio in self.approximations.items() if name != generator_name} + rendered = {name: audio for name, audio in self.approximations.items() if name != channel_name} if partial_approximation.size: - rendered[generator_name] = partial_approximation + rendered[channel_name] = partial_approximation max_length = max( (len(np.trim_zeros(audio, trim="b")) for audio in rendered.values()), @@ -278,21 +278,21 @@ def update_generator_data( self.approximations_data = self._build_approximations_data(rendered, max_length) streams = dict(self.streams) - streams[generator_name] = InstructionsItem.create( - generator_name=generator_name, + streams[channel_name] = InstructionsItem.create( + channel_name=channel_name, instructions=instructions, initial_pitch=initial_pitch, held_features=held_features, ) - self.instructions_data = [streams[name] for name in GeneratorName.items()] + self.instructions_data = [streams[name] for name in ChannelName.items()] self._invalidate_derived_caches(self) self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data]) - def get_generator_instructions( + def get_channel_instructions( self, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> List[InstructionUnion]: - return self.instructions[generator_name] + return self.instructions[channel_name] def detach_source(self) -> None: """Drops the local source-audio location so the reconstruction becomes self-contained. @@ -309,7 +309,7 @@ def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: A project runs every embedded sample at one change rate, so a reconstruction joining a project adopts that rate. The frozen ``config`` is rebuilt at the new rate and each - generator's approximation is re-synthesized from its stored instructions at the matching + channel's approximation is re-synthesized from its stored instructions at the matching frame length, re-timing the audio; the instructions and coefficient carry over. The original instance is returned when it already runs at ``nes_frequency``. """ @@ -319,24 +319,24 @@ def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: return self._resynthesized(self.config.with_library(nes_frequency=nes_frequency)) def _resynthesized(self, config: Config) -> Reconstruction: - """Re-renders every generator's approximation from its instructions at ``config``. + """Re-renders every channel's approximation from its instructions at ``config``. Each instruction spans ``config.frame_length`` samples, so re-rendering at a new frame length re-times the audio. The channels describing frames are rendered, padded to a - common length and summed; the mixer weight is baked into each generator's output, so a + common length and summed; the mixer weight is baked into each channel's output, so a plain sum reproduces the stored approximation shape. Drive is left at unity to match the regeneration path. """ - rendered: Dict[GeneratorName, np.ndarray] = {} - for generator_name, instructions in self.instructions.items(): + rendered: Dict[ChannelName, np.ndarray] = {} + for channel_name, instructions in self.instructions.items(): if not instructions: continue - generator = GENERATOR_CLASSES[generator_name]( + generator = CHANNEL_CLASSES[channel_name]( config, - generator_name.value, + channel_name.value, ) - rendered[generator_name] = np.concatenate( + rendered[channel_name] = np.concatenate( [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type] ) @@ -359,9 +359,9 @@ def _resynthesized(self, config: Config) -> Reconstruction: @staticmethod def _sum_approximations(arrays: Sequence[np.ndarray]) -> np.ndarray: - """Mixes equal-length per-generator approximations into one waveform. + """Mixes equal-length per-channel approximations into one waveform. - Returns an empty float array when no generator contributes, so a reconstruction with no + Returns an empty float array when no channel contributes, so a reconstruction with no rendered audio still carries a valid approximation. """ if not arrays: @@ -372,32 +372,32 @@ def _sum_approximations(arrays: Sequence[np.ndarray]) -> np.ndarray: @staticmethod def _build_approximations_data( - rendered: Mapping[GeneratorName, np.ndarray], + rendered: Mapping[ChannelName, np.ndarray], length: int, ) -> List[ApproximationsItem]: """Pads each rendered channel's audio to ``length``, in channel order. - A shared length lets the per-generator arrays stack and sum into the mixed approximation, + A shared length lets the per-channel arrays stack and sum into the mixed approximation, and a fixed order keeps a stored reconstruction reading the same however an edit reached it. """ return [ ApproximationsItem( - generator_name=generator_name, - approximation=pad(rendered[generator_name], 0, length), + channel_name=channel_name, + approximation=pad(rendered[channel_name], 0, length), ) - for generator_name in GeneratorName.items() - if generator_name in rendered + for channel_name in ChannelName.items() + if channel_name in rendered ] @staticmethod def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: - """Drops the memoized per-generator views so they recompute from their backing data.""" + """Drops the memoized per-channel views so they recompute from their backing data.""" reconstruction.__dict__.pop("approximations", None) reconstruction.__dict__.pop("streams", None) reconstruction.__dict__.pop("instructions", None) reconstruction.__dict__.pop("initial_pitches", None) reconstruction.__dict__.pop("held_features", None) - reconstruction.__dict__.pop("playing_generators", None) + reconstruction.__dict__.pop("playing_channels", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -460,17 +460,17 @@ def _validate_instructions( f"with exporter {exporter_class.__name__}" ) - def export(self) -> Dict[GeneratorName, Features]: + def export(self) -> Dict[ChannelName, Features]: """The envelopes each channel exports, one entry per channel the reconstruction holds. A channel standing by describes no frame, so its envelopes come back empty and every reader tells it from a channel that plays by :attr:`Features.has_frames`. Returns: - Dict[GeneratorName, Features]: The envelope representation of each channel. + Dict[ChannelName, Features]: The envelope representation of each channel. """ - features: Dict[GeneratorName, Features] = {} - for name in GeneratorName.items(): + features: Dict[ChannelName, Features] = {} + for name in ChannelName.items(): instructions = self.instructions[name] exporter_class = self._exporter_class(name, instructions) exporter: ExporterUnion = exporter_class() diff --git a/src/sampletones_core/reconstructions/reconstructor/approximation.py b/src/sampletones_core/reconstructions/reconstructor/approximation.py index b19b41358..0efe6dd55 100644 --- a/src/sampletones_core/reconstructions/reconstructor/approximation.py +++ b/src/sampletones_core/reconstructions/reconstructor/approximation.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment from sampletones_core.instructions import InstructionUnion @@ -8,6 +8,6 @@ class ApproximationData(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) - generator_name: GeneratorName + channel_name: ChannelName approximation: Fragment instruction: InstructionUnion diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 7cdad4d23..62e6043b9 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -6,12 +6,12 @@ from sampletones_core.audio import active_frame_level, load_audio from sampletones_core.configs import Config from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import FragmentedAudio, Window from sampletones_core.generators import ( MIXER_LEVELS, GeneratorUnion, - get_generators_by_names, + get_generators_by_channels, ) from sampletones_core.library import InstructionLibrary, InstructionLibraryData from sampletones_shared.exceptions import NoLibraryDataError @@ -29,9 +29,9 @@ def reconstruct( fragmented_audio: FragmentedAudio, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], library_data: InstructionLibraryData, -) -> Dict[int, Dict[GeneratorName, ApproximationData]]: +) -> Dict[int, Dict[ChannelName, ApproximationData]]: """Reconstructs the given fragments in a single worker pass. Args: @@ -39,16 +39,16 @@ def reconstruct( fragmented_audio: The framed target audio. config: The reconstruction configuration. window: The analysis window. - generators: The generators to match against, by channel name. + channels: The channels to match against, each carrying its generator. library_data: The instruction library the candidates are drawn from. Returns: - For each fragment id, the chosen approximation per generator. + For each fragment id, the chosen approximation per channel. """ worker = ReconstructorWorker( config=config, window=window, - generators=generators, + channels=channels, library_data=library_data, signal_length=fragmented_audio.audio.shape[0], ) @@ -77,7 +77,7 @@ def __init__( """Builds a reconstructor for a configuration and loads its library. Args: - config: The reconstruction configuration selecting generators, window, and + config: The reconstruction configuration selecting channels, window, and matching settings. library: The instruction library to match against; a default library rooted at the configured directory is used when omitted. @@ -88,8 +88,8 @@ def __init__( self.config: Config = config self.state: ReconstructionState = ReconstructionState.create([]) - generator_names = self.config.generation.generators - self.generators = get_generators_by_names(config, generator_names) + channel_names = self.config.generation.channels + self.channels = get_generators_by_channels(config, channel_names) self.window: Window = Window.from_config(self.config) self.library_data: InstructionLibraryData = self.load_library(library) @@ -115,7 +115,7 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: path = to_path(path) audio = self.load_audio(path) self.reset_generators() - self.state = ReconstructionState.create(list(self.generators.keys())) + self.state = ReconstructionState.create(list(self.channels.keys())) coefficient = self.get_coefficient(audio) fragmented_audio = self.get_fragments(audio / coefficient) self.reconstruct(fragmented_audio) @@ -156,7 +156,7 @@ def get_coefficient(self, audio: np.ndarray) -> float: Returns: float: The positive scale factor the input is divided by before matching. """ - total = sum(MIXER_LEVELS[generator.class_name()] for generator in self.generators.values()) + total = sum(MIXER_LEVELS[generator.class_name()] for generator in self.channels.values()) level = max( active_frame_level( audio, @@ -192,7 +192,7 @@ def reconstruct(self, fragmented_audio: FragmentedAudio) -> None: worker = ReconstructorWorker( config=self.config, window=self.window, - generators=self.generators, + channels=self.channels, library_data=self.library_data, signal_length=fragmented_audio.audio.shape[0], ) @@ -203,7 +203,7 @@ def reconstruct(self, fragmented_audio: FragmentedAudio) -> None: self.update_state(fragment_approximation) def load_library(self, library: Optional[InstructionLibrary] = None) -> InstructionLibraryData: - """Loads and filters the instruction library for the enabled generators. + """Loads and filters the instruction library for the enabled channels. Args: library: The library to draw from; a default library rooted at the @@ -211,7 +211,7 @@ def load_library(self, library: Optional[InstructionLibrary] = None) -> Instruct Returns: InstructionLibraryData: The library data restricted to the enabled - generators' instruction types. + channels' instruction types. Raises: NoLibraryDataError: If no library exists for the configuration and window. @@ -227,7 +227,7 @@ def load_library(self, library: Optional[InstructionLibrary] = None) -> Instruct return InstructionLibraryData.create( config=self.config, data=library_data.filter( - tuple(generator.class_name() for generator in self.generators.values()), + tuple(generator.class_name() for generator in self.channels.values()), ), ) @@ -240,9 +240,9 @@ def update_state(self, fragment_approximation: ApproximationData) -> None: Args: fragment_approximation: The chosen approximation for one fragment and - generator. + channel. """ - generator: GeneratorUnion = self.generators[fragment_approximation.generator_name] + generator: GeneratorUnion = self.channels[fragment_approximation.channel_name] if self.config.generation.final_regeneration: instruction = fragment_approximation.instruction initials = generator.initials @@ -260,6 +260,6 @@ def update_state(self, fragment_approximation: ApproximationData) -> None: self.state.append(fragment_approximation, approximation) def reset_generators(self) -> None: - """Resets every generator so the next reconstruction starts fresh.""" - for generator in self.generators.values(): + """Resets every channel's generator so the next reconstruction starts fresh.""" + for generator in self.channels.values(): generator.reset() diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/base.py b/src/sampletones_core/reconstructions/reconstructor/selector/base.py index 751365a10..f33cacba4 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/base.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/base.py @@ -3,7 +3,7 @@ from typing import Dict, List from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.fft import Fragment, FragmentedAudio, Window from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import ( @@ -31,7 +31,7 @@ def __init__( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], scorer: Scorer, candidate_provider: CandidateProvider, phase_aligner: PhaseAligner, @@ -39,7 +39,7 @@ def __init__( ) -> None: self.config = config self.window = window - self.generators = generators + self.channels = channels self.scorer = scorer self.candidate_provider = candidate_provider self.phase_aligner = phase_aligner @@ -51,17 +51,23 @@ def select( self, fragmented_audio: FragmentedAudio, fragment_ids: List[int], - ) -> Dict[int, Dict[GeneratorName, ApproximationData]]: ... - - def reconstruct_fragment(self, fragment: Fragment) -> Dict[GeneratorName, ApproximationData]: - approximations: Dict[GeneratorName, ApproximationData] = {} - remaining_generators = dict(self.generators.items()) - while remaining_generators: - remaining_generator_classes = get_remaining_generator_classes(remaining_generators) - approximation_data = self._find_best_approximation(fragment, remaining_generator_classes) - fragment = self.feature_extractor.subtract(fragment, approximation_data.approximation) - approximations[approximation_data.generator_name] = approximation_data - del remaining_generators[approximation_data.generator_name] + ) -> Dict[int, Dict[ChannelName, ApproximationData]]: ... + + def reconstruct_fragment(self, fragment: Fragment) -> Dict[ChannelName, ApproximationData]: + approximations: Dict[ChannelName, ApproximationData] = {} + remaining_channels = dict(self.channels.items()) + while remaining_channels: + remaining_generator_classes = get_remaining_generator_classes(remaining_channels) + approximation_data = self._find_best_approximation( + fragment, + remaining_generator_classes, + ) + fragment = self.feature_extractor.subtract( + fragment, + approximation_data.approximation, + ) + approximations[approximation_data.channel_name] = approximation_data + del remaining_channels[approximation_data.channel_name] return approximations @@ -113,7 +119,7 @@ def _find_best_approximation( generator = get_generator_by_instruction(best.instruction, remaining_generator_classes) return ApproximationData( - generator_name=GeneratorName(generator.name), + channel_name=ChannelName(generator.name), approximation=best.approximation, instruction=best.instruction, ) diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py b/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py index 4db51f24c..e16254a6e 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py @@ -1,6 +1,6 @@ from typing import Dict, List -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import FragmentedAudio from ..approximation import ApproximationData @@ -12,5 +12,5 @@ def select( self, fragmented_audio: FragmentedAudio, fragment_ids: List[int], - ) -> Dict[int, Dict[GeneratorName, ApproximationData]]: + ) -> Dict[int, Dict[ChannelName, ApproximationData]]: return {fragment_id: self.reconstruct_fragment(fragmented_audio[fragment_id]) for fragment_id in fragment_ids} diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py index 8f4d76d94..4cfb1b00e 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py @@ -4,7 +4,7 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment, FragmentedAudio, Window from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion @@ -17,7 +17,7 @@ from .base import ScoredCandidate, Selector ChannelLattice = List[List[ScoredCandidate]] -FrameCandidates = Dict[GeneratorName, List[ScoredCandidate]] +FrameCandidates = Dict[ChannelName, List[ScoredCandidate]] class ViterbiSelector(Selector): @@ -25,7 +25,7 @@ def __init__( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], scorer: Scorer, candidate_provider: CandidateProvider, phase_aligner: PhaseAligner, @@ -34,7 +34,7 @@ def __init__( super().__init__( config, window, - generators, + channels, scorer, candidate_provider, phase_aligner, @@ -50,7 +50,7 @@ def select( self, fragmented_audio: FragmentedAudio, fragment_ids: List[int], - ) -> Dict[int, Dict[GeneratorName, ApproximationData]]: + ) -> Dict[int, Dict[ChannelName, ApproximationData]]: lattices = self._build_lattices(fragmented_audio, fragment_ids) return self._decode_lattices(lattices, fragment_ids) @@ -58,26 +58,26 @@ def _build_lattices( self, fragmented_audio: FragmentedAudio, fragment_ids: List[int], - ) -> Dict[GeneratorName, ChannelLattice]: - lattices: Dict[GeneratorName, ChannelLattice] = {name: [] for name in self.generators} + ) -> Dict[ChannelName, ChannelLattice]: + lattices: Dict[ChannelName, ChannelLattice] = {name: [] for name in self.channels} for fragment_id in fragment_ids: - for generator_name, states in self._frame_candidates(fragmented_audio[fragment_id]).items(): - lattices[generator_name].append(states) + for channel_name, states in self._frame_candidates(fragmented_audio[fragment_id]).items(): + lattices[channel_name].append(states) return lattices def _decode_lattices( self, - lattices: Dict[GeneratorName, ChannelLattice], + lattices: Dict[ChannelName, ChannelLattice], fragment_ids: List[int], - ) -> Dict[int, Dict[GeneratorName, ApproximationData]]: - result: Dict[int, Dict[GeneratorName, ApproximationData]] = {fragment_id: {} for fragment_id in fragment_ids} - for generator_name, frames in lattices.items(): + ) -> Dict[int, Dict[ChannelName, ApproximationData]]: + result: Dict[int, Dict[ChannelName, ApproximationData]] = {fragment_id: {} for fragment_id in fragment_ids} + for channel_name, frames in lattices.items(): path = self._decode(frames) for position, fragment_id in enumerate(fragment_ids): state = frames[position][path[position]] - result[fragment_id][generator_name] = ApproximationData( - generator_name=generator_name, + result[fragment_id][channel_name] = ApproximationData( + channel_name=channel_name, approximation=state.approximation, instruction=state.instruction, ) @@ -87,9 +87,9 @@ def _decode_lattices( def _frame_candidates(self, fragment: Fragment) -> FrameCandidates: candidates: FrameCandidates = {} residual = fragment - for generator_name, generator in self.generators.items(): + for channel_name, generator in self.channels.items(): channel_states = self._channel_candidates(residual, generator) - candidates[generator_name] = channel_states + candidates[channel_name] = channel_states residual = self.feature_extractor.subtract(residual, channel_states[0].approximation) return candidates diff --git a/src/sampletones_core/reconstructions/reconstructor/state.py b/src/sampletones_core/reconstructions/reconstructor/state.py index 43bdbdd3f..69cfaa811 100644 --- a/src/sampletones_core/reconstructions/reconstructor/state.py +++ b/src/sampletones_core/reconstructions/reconstructor/state.py @@ -3,7 +3,7 @@ import numpy as np from pydantic import BaseModel, ConfigDict -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment from sampletones_core.instructions import InstructionUnion @@ -20,16 +20,16 @@ class FragmentReconstructionState(BaseModel): class ReconstructionState(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - generator_names: List[GeneratorName] = [] - instructions: Dict[GeneratorName, List[InstructionUnion]] = {} - approximations: Dict[GeneratorName, List[np.ndarray]] = {} + channel_names: List[ChannelName] = [] + instructions: Dict[ChannelName, List[InstructionUnion]] = {} + approximations: Dict[ChannelName, List[np.ndarray]] = {} @classmethod - def create(cls, generator_names: List[GeneratorName]) -> Self: + def create(cls, channel_names: List[ChannelName]) -> Self: return cls( - generator_names=generator_names, - instructions={name: [] for name in generator_names}, - approximations={name: [] for name in generator_names}, + channel_names=channel_names, + instructions={name: [] for name in channel_names}, + approximations={name: [] for name in channel_names}, ) def append( @@ -37,6 +37,6 @@ def append( fragment_approximation: ApproximationData, approximation: np.ndarray, ) -> None: - name = fragment_approximation.generator_name + name = fragment_approximation.channel_name self.instructions[name].append(fragment_approximation.instruction) self.approximations[name].append(approximation) diff --git a/src/sampletones_core/reconstructions/reconstructor/worker.py b/src/sampletones_core/reconstructions/reconstructor/worker.py index f5ffe44e4..9971c32c9 100644 --- a/src/sampletones_core/reconstructions/reconstructor/worker.py +++ b/src/sampletones_core/reconstructions/reconstructor/worker.py @@ -2,7 +2,7 @@ from typing import Dict, List from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.fft import Fragment, FragmentedAudio, Window from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor from sampletones_core.generators import ( @@ -22,7 +22,7 @@ class ReconstructorWorker: config: Config window: Window - generators: Dict[GeneratorName, GeneratorUnion] + channels: Dict[ChannelName, GeneratorUnion] library_data: InstructionLibraryData signal_length: int @@ -42,7 +42,7 @@ def __post_init__(self) -> None: selector = selector_class( config=self.config, window=self.window, - generators=self.generators, + channels=self.channels, scorer=scorer, candidate_provider=candidate_provider, phase_aligner=phase_aligner, @@ -59,14 +59,14 @@ def __call__( self, fragmented_audio: FragmentedAudio, fragment_ids: List[int], - ) -> Dict[int, Dict[GeneratorName, ApproximationData]]: + ) -> Dict[int, Dict[ChannelName, ApproximationData]]: return self.selector.select(fragmented_audio, fragment_ids) - def reconstruct(self, fragment: Fragment) -> Dict[GeneratorName, ApproximationData]: + def reconstruct(self, fragment: Fragment) -> Dict[ChannelName, ApproximationData]: return self.selector.reconstruct_fragment(fragment) def get_remaining_generator_classes( self, - remaining_generators: Dict[GeneratorName, GeneratorUnion], + remaining_generators: Dict[ChannelName, GeneratorUnion], ) -> Dict[GeneratorClassName, GeneratorUnion]: return get_remaining_generator_classes(remaining_generators) diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/trackers/backend.py index 6f3a6d779..dad68e0d6 100644 --- a/src/sampletones_core/trackers/backend.py +++ b/src/sampletones_core/trackers/backend.py @@ -43,7 +43,7 @@ def write_instrument( destination: Path, request: InstrumentExport, ) -> ExportArtifact: - """Writes one generator slice. + """Writes one channel slice. Args: destination: The file to write. @@ -61,7 +61,7 @@ def write_sample( destination: Path, request: SampleExport, ) -> ExportArtifact: - """Writes every generator slice of one reconstruction. + """Writes every channel slice of one reconstruction. Args: destination: The file this scope is written to. A format that keeps one diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py index 38b3561eb..2d97a9d8c 100644 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -71,7 +71,7 @@ class BitphasePresetBackend: The panel reads one instrument per file into the slot the user has selected, so a whole reconstruction lands as a set of them beside the chosen destination, one file - per generator slice named after the instrument. A preset carries rows alone, so its + per channel slice named after the instrument. A preset carries rows alone, so its pitch contour rides in each row's tone offset. """ diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index dfe9bee04..66cababff 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -29,7 +29,7 @@ class FamiTrackerBackend: """Writes FamiTracker's ``.fti`` instruments and ``.ftm`` modules. FamiTracker reads one instrument per ``.fti`` file, so a whole reconstruction lands - as a set of them beside the chosen destination, one file per generator slice named + as a set of them beside the chosen destination, one file per channel slice named after the instrument. """ diff --git a/src/sampletones_core/trackers/request.py b/src/sampletones_core/trackers/request.py index 6b75932c8..3daab57de 100644 --- a/src/sampletones_core/trackers/request.py +++ b/src/sampletones_core/trackers/request.py @@ -1,25 +1,25 @@ from dataclasses import dataclass from typing import Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.project.project import Project @dataclass(frozen=True) class InstrumentExport: - """One generator slice of a reconstruction, ready for a backend to write. + """One channel slice of a reconstruction, ready for a backend to write. Attributes: name: Name the written instrument carries. - generator: The NES channel the slice was reconstructed for. + channel: The NES channel the slice was reconstructed for. features: The per-dimension envelopes describing the slice. loop: Whether the instrument repeats its envelopes while its note is held. nes_frequency: Rate in Hz the envelopes advance at, one item per tick. """ name: str - generator: GeneratorName + channel: ChannelName features: Features loop: bool nes_frequency: int @@ -27,7 +27,7 @@ class InstrumentExport: @dataclass(frozen=True) class SampleExport: - """Every generator slice of one reconstruction. + """Every channel slice of one reconstruction. Attributes: name: Name of the reconstruction the slices came from. diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py index 78addd2e7..26f3db60f 100644 --- a/src/sampletones_player/nsf/song.py +++ b/src/sampletones_player/nsf/song.py @@ -1,6 +1,6 @@ from typing import Sequence, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.binary import BinaryWriter from sampletones_player.registers.base import ChannelRegisters from sampletones_player.song import Song @@ -51,7 +51,7 @@ def _validate_space(size: int, available_bytes: int) -> None: def _validate_offsets(offsets: Sequence[int]) -> None: - for channel, offset in zip(GeneratorName.items(), offsets): + for channel, offset in zip(ChannelName.items(), offsets): if offset > MAX_STREAM_OFFSET: raise SongTooLargeError( f"the {channel.value} stream starts {offset} bytes into the song " diff --git a/src/sampletones_player/registers/streams.py b/src/sampletones_player/registers/streams.py index 29e1726fa..0bc94989f 100644 --- a/src/sampletones_player/registers/streams.py +++ b/src/sampletones_player/registers/streams.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, model_validator -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.hold import hold from sampletones_player.registers.noise import NoiseRegisters @@ -37,7 +37,7 @@ class ChannelStreams(BaseModel): @model_validator(mode="after") def _validate_every_channel_reaches_a_tick(self) -> ChannelStreams: - empty = tuple(channel.value for channel, stream in zip(GeneratorName.items(), self.ordered) if not stream) + empty = tuple(channel.value for channel, stream in zip(ChannelName.items(), self.ordered) if not stream) if empty: raise ValueError(f"every channel needs at least one tick, and {', '.join(empty)} has none") diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py index f036fabe2..8c7c00cbd 100644 --- a/src/sampletones_player/specification/channels.py +++ b/src/sampletones_player/specification/channels.py @@ -1,6 +1,6 @@ from typing import Dict, Final, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_player.specification.registers import ( NOISE_CONTROL, NOISE_PERIOD, @@ -15,9 +15,9 @@ TRIANGLE_TIMER_LOW, ) -CHANNEL_REGISTER_ADDRESSES: Final[Dict[GeneratorName, Tuple[int, ...]]] = { - GeneratorName.PULSE1: (PULSE1_CONTROL, PULSE1_TIMER_LOW, PULSE1_TIMER_HIGH), - GeneratorName.PULSE2: (PULSE2_CONTROL, PULSE2_TIMER_LOW, PULSE2_TIMER_HIGH), - GeneratorName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH), - GeneratorName.NOISE: (NOISE_CONTROL, NOISE_PERIOD), +CHANNEL_REGISTER_ADDRESSES: Final[Dict[ChannelName, Tuple[int, ...]]] = { + ChannelName.PULSE1: (PULSE1_CONTROL, PULSE1_TIMER_LOW, PULSE1_TIMER_HIGH), + ChannelName.PULSE2: (PULSE2_CONTROL, PULSE2_TIMER_LOW, PULSE2_TIMER_HIGH), + ChannelName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH), + ChannelName.NOISE: (NOISE_CONTROL, NOISE_PERIOD), } diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py index 524dd4644..bcd23f0a5 100644 --- a/src/sampletones_player/specification/song.py +++ b/src/sampletones_player/specification/song.py @@ -1,6 +1,6 @@ from typing import Final -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName WORD_SIZE: Final[int] = 2 @@ -9,7 +9,7 @@ TOTAL_TICKS_OFFSET: Final[int] = STEP_FRACTION_OFFSET + WORD_SIZE LOOP_TICK_OFFSET: Final[int] = TOTAL_TICKS_OFFSET + WORD_SIZE STREAM_OFFSETS_OFFSET: Final[int] = LOOP_TICK_OFFSET + WORD_SIZE -SONG_HEADER_SIZE: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * len(GeneratorName) +SONG_HEADER_SIZE: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * len(ChannelName) NO_LOOP: Final[int] = 0xFFFF MAX_STREAM_OFFSET: Final[int] = 0xFFFF diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py index e81c399f4..5cd9970b4 100644 --- a/src/sampletones_player/trace/trace.py +++ b/src/sampletones_player/trace/trace.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Dict, Final, List, Tuple -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_player.song import Song from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES from sampletones_player.specification.registers import ( @@ -59,7 +59,7 @@ def _tick_writes( shadows: Dict[int, int], ) -> Tuple[RegisterWrite, ...]: writes: List[RegisterWrite] = [] - for channel, registers in zip(GeneratorName.items(), song.streams.at(tick)): + for channel, registers in zip(ChannelName.items(), song.streams.at(tick)): for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], registers.values): if address in REGISTERS_WRITTEN_ON_CHANGE: if shadows.get(address) == value: diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index cf5222e64..a93c24de0 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -7,8 +7,8 @@ SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.0" -SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.1" -SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.0" +SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2" +SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1" SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" SAMPLETONES_AUTHOR: Final[str] = "Jakim" diff --git a/tests/conftest.py b/tests/conftest.py index 49d3471d1..910b7167d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest from sampletones_application.utils.gui.palette.palette import PaletteBindings -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from tests.suite.sequencer import sample_reconstruction @@ -26,6 +26,6 @@ def palette_bindings() -> Iterator[None]: @pytest.fixture def reconstruction_factory() -> ReconstructionFactory: def build() -> Reconstruction: - return sample_reconstruction([GeneratorName.PULSE1]) + return sample_reconstruction([ChannelName.PULSE1]) return build diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 662e9a07e..190032d29 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -7,10 +7,10 @@ from sampletones_core.audio.processing import normalize from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.configs.generation import GenerationConfig -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_core.fft import Window from sampletones_core.fft.features import get_feature_extractor -from sampletones_core.generators import get_generators_by_names +from sampletones_core.generators import get_generators_by_channels from sampletones_core.instructions import InstructionUnion from sampletones_core.library import ( InstructionLibrary, @@ -24,29 +24,29 @@ from tests.integration.assets.synth_config import SynthConfig INSTRUCTIONS_PER_GENERATOR: Final[int] = 48 -LIBRARY_GENERATORS: Final[List[GeneratorName]] = [ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, +CHANNELS: Final[List[ChannelName]] = [ + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, ] def build_mini_library(config: Config, *, per_generator: int = INSTRUCTIONS_PER_GENERATOR) -> InstructionLibrary: """Builds a small in-memory instruction library covering pulse/triangle/noise. - Candidates are sampled with an even stride across each generator's instruction + Candidates are sampled with an even stride across each channel's instruction space so pitch, volume and period are represented, rather than a biased prefix. """ window = Window.from_config(config) extractor = get_feature_extractor(config, window) - generators = get_generators_by_names(config, LIBRARY_GENERATORS) + channels = get_generators_by_channels(config, CHANNELS) data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {} - for generator in generators.values(): - candidates = list(generator.get_possible_instructions()) + for channel in channels.values(): + candidates = list(channel.get_possible_instructions()) stride = max(1, len(candidates) // per_generator) for instruction in candidates[::stride][:per_generator]: - data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor) + data[instruction] = InstructionLibraryFragment.create(channel, instruction, extractor) library = InstructionLibrary() library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) @@ -78,12 +78,12 @@ def make_sample( library: InstructionLibrary, *, tmp_dir: Pathlike, - expected_slices: FrozenSet[GeneratorName], + expected_slices: FrozenSet[ChannelName], loop: bool = False, ) -> Sample: """Reconstructs ``audio`` into a `Sample`, asserting the channels it plays.""" reconstruction = reconstruct_sample(audio, config, library, tmp_dir=tmp_dir, name=name) - played = frozenset(reconstruction.playing_generators) + played = frozenset(reconstruction.playing_channels) if played != expected_slices: raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") @@ -117,8 +117,8 @@ def load_instrument_catalog( catalog: Dict[str, Sample] = {} for entry in spec["instruments"]: - generators = [GeneratorName(name) for name in entry["generators"]] - config = Config(library=library_config, generation=GenerationConfig(generators=generators)) + channels = [ChannelName(name) for name in entry["channels"]] + config = Config(library=library_config, generation=GenerationConfig(channels=channels)) audio = _render_instrument(synth_config, entry["synth"], sample_rate=sample_rate) catalog[entry["name"]] = make_sample( entry["name"], @@ -126,7 +126,7 @@ def load_instrument_catalog( config, library, tmp_dir=tmp_dir, - expected_slices=frozenset(generators), + expected_slices=frozenset(channels), ) return catalog @@ -141,7 +141,7 @@ def _render_instrument( """ Render a named voice at peak level 1.0. - A fresh generator seeded from the synth configuration keeps every instrument + A fresh channel seeded from the synth configuration keeps every instrument reproducible independently of catalog order. """ voice = synth_config.voices[name] diff --git a/tests/integration/assets/song_loader.py b/tests/integration/assets/song_loader.py index 03079cdc5..7d2822d12 100644 --- a/tests/integration/assets/song_loader.py +++ b/tests/integration/assets/song_loader.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Mapping, Optional -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample @@ -16,15 +16,15 @@ def _order( order_specs: List[Dict[str, int]], -) -> List[Dict[GeneratorName, Optional[int]]]: - frames: List[Dict[GeneratorName, Optional[int]]] = [] +) -> List[Dict[ChannelName, Optional[int]]]: + frames: List[Dict[ChannelName, Optional[int]]] = [] for spec in order_specs: - frames.append({generator: spec.get(generator.value) for generator in GeneratorName.items()}) + frames.append({channel: spec.get(channel.value) for channel in ChannelName.items()}) return frames -def _row(spec: RowSpec, generator: GeneratorName, samples_by_name: Mapping[str, Sample]) -> Row: +def _row(spec: RowSpec, channel: ChannelName, samples_by_name: Mapping[str, Sample]) -> Row: transpose = spec.get("transpose") volume = spec.get("volume") @@ -36,22 +36,22 @@ def _row(spec: RowSpec, generator: GeneratorName, samples_by_name: Mapping[str, return Row(transpose=transpose, volume=volume) sample = samples_by_name[sample_name] - if generator not in sample.reconstruction.instructions: - raise ValueError(f"Sample '{sample_name}' has no '{generator.value}' slice for the {generator.value} channel") + if channel not in sample.reconstruction.instructions: + raise ValueError(f"Sample '{sample_name}' has no '{channel.value}' slice for the {channel.value} channel") - command = Instrument(sample_id=sample.id, generator_name=generator) + command = Instrument(sample_id=sample.id, channel_name=channel) return Row(command=command, transpose=transpose, volume=volume) def _pattern( row_specs: List[RowSpec], rows_per_pattern: int, - generator: GeneratorName, + channel: ChannelName, samples_by_name: Mapping[str, Sample], ) -> Pattern: rows = [Row() for _ in range(rows_per_pattern)] for spec in row_specs: - rows[spec["row"]] = _row(spec, generator, samples_by_name) + rows[spec["row"]] = _row(spec, channel, samples_by_name) return Pattern(rows=rows) @@ -60,26 +60,26 @@ def _channels( channels_spec: Dict[str, Dict[str, Any]], rows_per_pattern: int, samples_by_name: Mapping[str, Sample], -) -> Dict[GeneratorName, Channel]: - channels: Dict[GeneratorName, Channel] = {} +) -> Dict[ChannelName, Channel]: + channels: Dict[ChannelName, Channel] = {} for name, spec in channels_spec.items(): - generator = GeneratorName(name) + channel = ChannelName(name) patterns = { int(index): _pattern( row_specs, rows_per_pattern, - generator, + channel, samples_by_name, ) for index, row_specs in spec["patterns"].items() } - channels[generator] = Channel( - generator=generator, + channels[channel] = Channel( + name=channel, patterns=patterns, ) - for generator in GeneratorName.items(): - channels.setdefault(generator, Channel(generator=generator, patterns={})) + for channel in ChannelName.items(): + channels.setdefault(channel, Channel(name=channel, patterns={})) return channels diff --git a/tests/integration/config/reconstruction.yaml b/tests/integration/config/reconstruction.yaml index b87f08954..70b10c633 100644 --- a/tests/integration/config/reconstruction.yaml +++ b/tests/integration/config/reconstruction.yaml @@ -3,6 +3,6 @@ reconstruction: transformation_gamma: 0 instructions_per_generator: 32 instruments: - - { name: kick, synth: kick, generators: [pulse1, triangle] } - - { name: lead, synth: lead, generators: [pulse1, pulse2] } - - { name: hihat, synth: hihat, generators: [noise] } + - { name: kick, synth: kick, channels: [pulse1, triangle] } + - { name: lead, synth: lead, channels: [pulse1, pulse2] } + - { name: hihat, synth: hihat, channels: [noise] } diff --git a/tests/integration/famitracker/test_ftm_pipeline.py b/tests/integration/famitracker/test_ftm_pipeline.py index c9a8e90af..952e39936 100644 --- a/tests/integration/famitracker/test_ftm_pipeline.py +++ b/tests/integration/famitracker/test_ftm_pipeline.py @@ -3,7 +3,7 @@ import pytest from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.export import write_ftm from sampletones_core.formats.famitracker.specification.channels import ChannelId from sampletones_core.formats.famitracker.specification.file import FTM_VERSION @@ -65,8 +65,8 @@ def test_a_soloed_mix_writes_every_played_channel( module_path: Path, ) -> None: channels = SequencerChannelsLogic() - channels.solo(GeneratorName.TRIANGLE) - assert channels.active_channels == frozenset({GeneratorName.TRIANGLE}) + channels.solo(ChannelName.TRIANGLE) + assert channels.active_channels == frozenset({ChannelName.TRIANGLE}) write_ftm(module_path, integration_project) diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index 10db4394d..d1b9768b9 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -6,7 +6,7 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MIN_PITCH from sampletones_core.exporters import Features, PulseExporter from sampletones_core.instructions import PulseInstruction @@ -45,8 +45,8 @@ def minimal_reconstruction(default_config, pulse_instructions) -> Reconstruction length = 256 return Reconstruction.create( approximation=np.zeros(length, dtype=np.float32), - approximations={GeneratorName.PULSE1: np.zeros(length, dtype=np.float32)}, - instructions={GeneratorName.PULSE1: pulse_instructions}, + approximations={ChannelName.PULSE1: np.zeros(length, dtype=np.float32)}, + instructions={ChannelName.PULSE1: pulse_instructions}, config=default_config, coefficient=1.0, audio_filepath=Path("/dev/null"), diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index f0d1939c4..26604f4d8 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -8,7 +8,7 @@ from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess from sampletones_core.audio import read_wave -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport @@ -24,7 +24,7 @@ def backend_fixture() -> FamiTrackerBackend: def instrument_export(name: str, features: Features) -> InstrumentExport: return InstrumentExport( name=name, - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, features=features, loop=False, nes_frequency=NES_FREQUENCY, diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index eaa09995e..90ea39be5 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -9,7 +9,7 @@ from sampletones_application.services.regeneration import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction from tests.suite.scenario import BaseTestScenario, ScenarioStep @@ -29,7 +29,7 @@ class TestRegenerationServicePipeline: """Full synthesis pipeline: real Config, Features (via PulseExporter), real PulseGenerator, - and real Reconstruction.update_generator_data. Nothing is mocked. + and real Reconstruction.update_channel_data. Nothing is mocked. Tests call _run() directly to bypass the executor; the synchronous_executor fixture from the parent conftest covers start() in the final test. @@ -42,7 +42,7 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, pulse_features.volume, @@ -58,7 +58,7 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, pulse_features.volume, @@ -66,8 +66,8 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat emitted = results[0].value assert emitted.reconstruction is not reconstruction_data.reconstruction - assert len(emitted.reconstruction.approximations.get(GeneratorName.PULSE1, np.array([], dtype=np.float32))) > 0 - assert emitted.generator_name is GeneratorName.PULSE1 + assert len(emitted.reconstruction.approximations.get(ChannelName.PULSE1, np.array([], dtype=np.float32))) > 0 + assert emitted.channel_name is ChannelName.PULSE1 assert emitted.feature_key is FeatureKey.VOLUME def test_run_updates_reconstruction_approximation(self, reconstruction_data, pulse_features) -> None: @@ -75,14 +75,14 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, pulse_features.volume, ) approximation = reconstruction_data.reconstruction.approximations.get( - GeneratorName.PULSE1, np.array([], dtype=np.float32) + ChannelName.PULSE1, np.array([], dtype=np.float32) ) assert len(approximation) > 0 @@ -91,13 +91,13 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, pulse_features.volume, ) - instructions = reconstruction_data.reconstruction.get_generator_instructions(GeneratorName.PULSE1) + instructions = reconstruction_data.reconstruction.get_channel_instructions(ChannelName.PULSE1) assert len(instructions) > 0 def test_run_feature_mutation_is_applied_before_synthesis(self, reconstruction_data, pulse_features) -> None: @@ -106,7 +106,7 @@ def test_run_feature_mutation_is_applied_before_synthesis(self, reconstruction_d service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, new_volume, @@ -121,7 +121,7 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, {}, FeatureKey.VOLUME, np.zeros(4, dtype=np.int8), @@ -137,7 +137,7 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ service.start( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, pulse_features.volume, @@ -166,7 +166,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: service._run( context.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, context.features, FeatureKey.ARPEGGIO, arpeggio, @@ -179,7 +179,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: def _pitches(context: ArpeggioEditContext) -> List[int]: - instructions = context.reconstruction.get_generator_instructions(GeneratorName.PULSE1) + instructions = context.reconstruction.get_channel_instructions(ChannelName.PULSE1) return [instruction.pitch for instruction in instructions] @@ -196,7 +196,7 @@ def build() -> ArpeggioEditContext: reconstruction = reconstruction_data.reconstruction return ArpeggioEditContext( reconstruction=reconstruction, - features=FeatureData.load(reconstruction)[GeneratorName.PULSE1], + features=FeatureData.load(reconstruction)[ChannelName.PULSE1], ) def check_the_starting_reference(context: ArpeggioEditContext) -> None: @@ -208,7 +208,7 @@ def raise_the_first_frame_an_octave(context: ArpeggioEditContext) -> None: assert _pitches(context) == [BASE_PITCH + OCTAVE] + [BASE_PITCH] * 3 def reload_the_edited_features(context: ArpeggioEditContext) -> None: - context.features = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1] + context.features = FeatureData.load(context.reconstruction)[ChannelName.PULSE1] assert context.features.initial_pitch == BASE_PITCH assert context.features.arpeggio.tolist() == [OCTAVE, 0] @@ -217,7 +217,7 @@ def clear_the_envelope(context: ArpeggioEditContext) -> None: assert _pitches(context) == [BASE_PITCH] * 4 def check_the_reference_held(context: ArpeggioEditContext) -> None: - reloaded = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1] + reloaded = FeatureData.load(context.reconstruction)[ChannelName.PULSE1] assert reloaded.initial_pitch == BASE_PITCH assert reloaded.arpeggio.tolist() == [0] @@ -287,7 +287,7 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio # The real synthesis emits its ServiceSuccess onto the real queue, due at the current frame. service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, pulse_features.volume, @@ -318,7 +318,7 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, new_volume = np.zeros(len(pulse_features.volume), dtype=np.int8) service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, FeatureKey.VOLUME, new_volume, @@ -333,5 +333,5 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, assert len(delivered) == 1 regenerated = delivered[0].value assert regenerated.reconstruction is not reconstruction_data.reconstruction - assert regenerated.generator_name is GeneratorName.PULSE1 + assert regenerated.channel_name is ChannelName.PULSE1 assert regenerated.feature_key is FeatureKey.VOLUME diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 45eff6f26..a847ed1bf 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -227,7 +227,7 @@ def config_fields( nes_frequency: int, spectrum_method: SpectrumMethod, transformation_gamma: int, - generators: str, + channels: str, config_hash: str, ) -> ConfigDirectoryFields: return ConfigDirectoryFields( @@ -235,7 +235,7 @@ def config_fields( nf=nes_frequency, sm=spectrum_method, tg=transformation_gamma, - gn=generators, + gn=channels, ch=config_hash, ) @@ -245,7 +245,7 @@ def config_fields( nes_frequency=30, spectrum_method=SpectrumMethod.FFT, transformation_gamma=0, - generators="PTN", + channels="PTN", config_hash=HASH_A, ) CONFIG_B: Final[ConfigDirectoryFields] = config_fields( @@ -253,7 +253,7 @@ def config_fields( nes_frequency=30, spectrum_method=SpectrumMethod.FFT, transformation_gamma=0, - generators="PTN", + channels="PTN", config_hash=HASH_B, ) CONFIG_C: Final[ConfigDirectoryFields] = config_fields( @@ -261,7 +261,7 @@ def config_fields( nes_frequency=30, spectrum_method=SpectrumMethod.FFT, transformation_gamma=0, - generators="PT", + channels="PT", config_hash=HASH_C, ) CONFIG_D: Final[ConfigDirectoryFields] = config_fields( @@ -269,7 +269,7 @@ def config_fields( nes_frequency=30, spectrum_method=SpectrumMethod.CQT, transformation_gamma=0, - generators="PTN", + channels="PTN", config_hash=HASH_D, ) CONFIG_E: Final[ConfigDirectoryFields] = config_fields( @@ -277,7 +277,7 @@ def config_fields( nes_frequency=60, spectrum_method=SpectrumMethod.CQT, transformation_gamma=2, - generators="P", + channels="P", config_hash=HASH_E, ) CONFIG_F: Final[ConfigDirectoryFields] = config_fields( @@ -285,7 +285,7 @@ def config_fields( nes_frequency=50, spectrum_method=SpectrumMethod.LOG_SPACED_FFT, transformation_gamma=1, - generators="TN", + channels="TN", config_hash=HASH_F, ) @@ -448,7 +448,7 @@ def _state_detail_labels(panel: GUITreePanel) -> None: panel._lbl_detail_spectrum_method = "spectrum_method" panel._lbl_detail_transformation_gamma = "transformation_gamma" panel._lbl_detail_window_size = "window_size" - panel._lbl_detail_generators = "generators" + panel._lbl_detail_channels = "channels" panel._lbl_detail_configuration = "configuration" diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index b33e270fe..c35c9c131 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -18,7 +18,7 @@ from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -48,19 +48,19 @@ UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds" -def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction: - """A reconstruction carrying one instruction on each of ``generators``. +def sample_reconstruction(channels: Sequence[ChannelName]) -> Reconstruction: + """A reconstruction carrying one instruction on each of ``channels``. The channels a reconstruction covers are what a sample governs in the sequencer, so this is the knob a sequencer test turns: the audio itself is silent, since what is under test is which channels a sample reaches and not how it sounds. - Each channel carries the instruction its own generator sounds, since the instruction type is + Each channel carries the instruction its own channel sounds, since the instruction type is what names the exporter a channel is read through — so a reading taken off this reconstruction is the reading the channel gives. """ - instructions = {generator: [_instruction(generator)] for generator in generators} - approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators} + instructions = {channel: [_instruction(channel)] for channel in channels} + approximations = {channel: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for channel in channels} return Reconstruction.create( approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32), approximations=approximations, @@ -80,8 +80,7 @@ def render_frame(tracker_logic: SequencerTrackerLogic) -> Tuple[str, ...]: """ grid = tracker_logic.build_grid() return tuple( - f" {COLUMN_SEPARATOR} ".join(row.cells[generator].label for generator in GeneratorName.items()) - for row in grid.rows + f" {COLUMN_SEPARATOR} ".join(row.cells[channel].label for channel in ChannelName.items()) for row in grid.rows ) @@ -95,7 +94,7 @@ def render_slots( pattern the channel had not held before. """ frame = controller.project.song.order[frame_index] - return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items()) + return " ".join(display_id(frame.get(channel)) for channel in ChannelName.items()) def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]: @@ -107,8 +106,8 @@ def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]: """ view_model = order_logic.build_order() return tuple( - " ".join(view_model.entry_label(generator, position) for position in range(view_model.position_count)) - for generator in GeneratorName.items() + " ".join(view_model.entry_label(channel, position) for position in range(view_model.position_count)) + for channel in ChannelName.items() ) @@ -150,9 +149,9 @@ def fill_order( for _ in range(reach - order_logic.position_count()): order_logic.append_frame() - for generator, tokens in zip(GeneratorName.items(), lines): + for channel, tokens in zip(ChannelName.items(), lines): for position, token in enumerate(tokens): - order_logic.set_order_entry(generator, position, parse_index(token)) + order_logic.set_order_entry(channel, position, parse_index(token)) def parse_index(token: str) -> Optional[int]: @@ -226,11 +225,11 @@ def fill_frame( exercised it. """ for row_index, line in enumerate(rows): - for generator, cell in zip(GeneratorName.items(), line.split(COLUMN_SEPARATOR)): + for channel, cell in zip(ChannelName.items(), line.split(COLUMN_SEPARATOR)): _fill_cell( tracker_logic, row_index, - generator, + channel, cell.split(), sample_ids, ) @@ -268,19 +267,19 @@ def parse_volume(token: str) -> Optional[int]: return int(token, HEXADECIMAL_BASE) -def _instruction(generator: GeneratorName) -> InstructionUnion: - """The instruction a channel sounds, which is the type its generator and exporter pair with. +def _instruction(channel: ChannelName) -> InstructionUnion: + """The instruction a channel sounds, which is the type its channel and exporter pair with. The two pulse channels share the pulse instruction; the triangle and the noise each take their own. """ - match generator: - case GeneratorName.TRIANGLE: + match channel: + case ChannelName.TRIANGLE: return TriangleInstruction( on=True, pitch=SAMPLE_PITCH, ) - case GeneratorName.NOISE: + case ChannelName.NOISE: return NoiseInstruction( on=True, period=SAMPLE_PERIOD, @@ -299,7 +298,7 @@ def _instruction(generator: GeneratorName) -> InstructionUnion: def _fill_cell( tracker_logic: SequencerTrackerLogic, row_index: int, - generator: GeneratorName, + channel: ChannelName, tokens: Sequence[str], sample_ids: Sequence[str], ) -> None: @@ -315,9 +314,9 @@ def _fill_cell( return tracker_logic.set_row( - generator, + channel, row_index, - command=_command(note, generator), + command=_command(note, channel), transpose=transpose, volume=volume, ) @@ -325,14 +324,14 @@ def _fill_cell( def _command( note: Optional[BlockNote], - generator: GeneratorName, + channel: ChannelName, ) -> Optional[NoteCommand]: """The command a note becomes in the channel it is written to, which is what carries its pitch.""" match note: case NoteOff(): return note case str() as sample_id: - return Instrument(sample_id=sample_id, generator_name=generator) + return Instrument(sample_id=sample_id, channel_name=channel) case None: return None diff --git a/tests/unit/sampletones_application/config/managers/test_config.py b/tests/unit/sampletones_application/config/managers/test_config.py index 685c7b762..7cc1b37d6 100644 --- a/tests/unit/sampletones_application/config/managers/test_config.py +++ b/tests/unit/sampletones_application/config/managers/test_config.py @@ -17,7 +17,7 @@ LibrarySettingsUpdate, ) from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_core.library import InstructionLibraryKey @@ -154,7 +154,7 @@ def test_apply_generation_settings_updates_drive(self, tmp_path: Path) -> None: manager = _manager(tmp_path / "missing.json") update = GenerationSettingsUpdate( drive=0.5, - generators=[GeneratorName.PULSE1], + channels=[ChannelName.PULSE1], ) manager.apply_generation_settings(update) assert manager.config.generation.drive == pytest.approx(0.5) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index b0e7d7783..d34099693 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -61,7 +61,7 @@ HistoryDetailWord, HistoryDetailWordSegment, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.song_position import SongPosition from sampletones_shared.exceptions import InvalidReconstructionValuesError from tests.suite.language import FakeLanguageManager @@ -889,11 +889,11 @@ def test_undo_keeps_the_mute_set( channels = coordinator._sequencer_channels_logic with coordinator._history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) - channels.toggle(GeneratorName.TRIANGLE) + channels.toggle(ChannelName.TRIANGLE) coordinator.undo() - assert channels.active_channels == ALL_CHANNELS - {GeneratorName.TRIANGLE} + assert channels.active_channels == ALL_CHANNELS - {ChannelName.TRIANGLE} def test_redo_keeps_the_mute_set( self, @@ -904,12 +904,12 @@ def test_redo_keeps_the_mute_set( channels = coordinator._sequencer_channels_logic with coordinator._history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) - channels.toggle(GeneratorName.NOISE) + channels.toggle(ChannelName.NOISE) coordinator.undo() coordinator.redo() - assert channels.active_channels == ALL_CHANNELS - {GeneratorName.NOISE} + assert channels.active_channels == ALL_CHANNELS - {ChannelName.NOISE} def test_opening_a_project_restores_every_channel( self, @@ -917,7 +917,7 @@ def test_opening_a_project_restores_every_channel( ) -> None: coordinator = wired_history_coordinator channels = coordinator._sequencer_channels_logic - channels.solo(GeneratorName.TRIANGLE) + channels.solo(ChannelName.TRIANGLE) coordinator._project_controller.new() @@ -929,7 +929,7 @@ def test_closing_the_project_restores_every_channel( ) -> None: coordinator = wired_history_coordinator channels = coordinator._sequencer_channels_logic - channels.toggle(GeneratorName.PULSE1) + channels.toggle(ChannelName.PULSE1) coordinator._project_controller.close() @@ -972,10 +972,10 @@ def test_header_click_silences_that_channel( ) -> None: panel = channels_coordinator._sequencer_tracker_panel - panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) + panel._on_header_clicked(0, True, ChannelName.TRIANGLE) - assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {GeneratorName.TRIANGLE} - assert panel._is_muted(GeneratorName.TRIANGLE) + assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {ChannelName.TRIANGLE} + assert panel._is_muted(ChannelName.TRIANGLE) def test_a_second_click_returns_the_channel_to_the_mix( self, @@ -983,11 +983,11 @@ def test_a_second_click_returns_the_channel_to_the_mix( ) -> None: panel = channels_coordinator._sequencer_tracker_panel - panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) - panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) + panel._on_header_clicked(0, True, ChannelName.TRIANGLE) + panel._on_header_clicked(0, True, ChannelName.TRIANGLE) assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - assert not panel._is_muted(GeneratorName.TRIANGLE) + assert not panel._is_muted(ChannelName.TRIANGLE) def test_ctrl_header_click_solos_that_channel( self, @@ -997,9 +997,9 @@ def test_ctrl_header_click_solos_that_channel( monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL) panel = channels_coordinator._sequencer_tracker_panel - panel._on_header_clicked(0, True, GeneratorName.PULSE2) + panel._on_header_clicked(0, True, ChannelName.PULSE2) - assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({GeneratorName.PULSE2}) + assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({ChannelName.PULSE2}) def test_sample_header_click_silences_every_channel( self, @@ -1010,7 +1010,7 @@ def test_sample_header_click_silences_every_channel( panel._on_header_clicked(0, True, None) assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset() - assert all(panel._is_muted(generator) for generator in GeneratorName.items()) + assert all(panel._is_muted(channel) for channel in ChannelName.items()) def test_sample_header_click_restores_every_channel_from_full_silence( self, @@ -1022,31 +1022,31 @@ def test_sample_header_click_restores_every_channel_from_full_silence( panel._on_header_clicked(0, True, None) assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - assert not any(panel._is_muted(generator) for generator in GeneratorName.items()) + assert not any(panel._is_muted(channel) for channel in ChannelName.items()) def test_the_menu_silences_every_channel_from_a_mixed_set( self, channels_coordinator: SequencerTabCoordinator, ) -> None: panel = channels_coordinator._sequencer_tracker_panel - panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) + panel._on_header_clicked(0, True, ChannelName.TRIANGLE) panel.call(panel.on_channels_muted) assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset() - assert all(panel._is_muted(generator) for generator in GeneratorName.items()) + assert all(panel._is_muted(channel) for channel in ChannelName.items()) def test_the_menu_restores_every_channel_from_a_mixed_set( self, channels_coordinator: SequencerTabCoordinator, ) -> None: panel = channels_coordinator._sequencer_tracker_panel - panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) + panel._on_header_clicked(0, True, ChannelName.TRIANGLE) panel.call(panel.on_channels_unmuted) assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - assert not any(panel._is_muted(generator) for generator in GeneratorName.items()) + assert not any(panel._is_muted(channel) for channel in ChannelName.items()) class TestChannelRowLabelWiring: @@ -1058,10 +1058,10 @@ def test_row_label_click_silences_that_channel( ) -> None: order_panel = channels_coordinator._sequencer_order_panel - order_panel._on_label_clicked(0, True, GeneratorName.NOISE) + order_panel._on_label_clicked(0, True, ChannelName.NOISE) - assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {GeneratorName.NOISE} - assert order_panel._is_muted(GeneratorName.NOISE) + assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {ChannelName.NOISE} + assert order_panel._is_muted(ChannelName.NOISE) def test_ctrl_row_label_click_solos_that_channel( self, @@ -1071,9 +1071,9 @@ def test_ctrl_row_label_click_solos_that_channel( monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL) order_panel = channels_coordinator._sequencer_order_panel - order_panel._on_label_clicked(0, True, GeneratorName.PULSE1) + order_panel._on_label_clicked(0, True, ChannelName.PULSE1) - assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({GeneratorName.PULSE1}) + assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({ChannelName.PULSE1}) def test_master_row_label_click_silences_every_channel( self, @@ -1092,9 +1092,9 @@ def test_a_tracker_click_reaches_the_order_table( tracker_panel = channels_coordinator._sequencer_tracker_panel order_panel = channels_coordinator._sequencer_order_panel - tracker_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) + tracker_panel._on_header_clicked(0, True, ChannelName.TRIANGLE) - assert order_panel._is_muted(GeneratorName.TRIANGLE) + assert order_panel._is_muted(ChannelName.TRIANGLE) def test_an_order_click_reaches_the_tracker( self, @@ -1103,16 +1103,16 @@ def test_an_order_click_reaches_the_tracker( tracker_panel = channels_coordinator._sequencer_tracker_panel order_panel = channels_coordinator._sequencer_order_panel - order_panel._on_label_clicked(0, True, GeneratorName.PULSE2) + order_panel._on_label_clicked(0, True, ChannelName.PULSE2) - assert tracker_panel._is_muted(GeneratorName.PULSE2) + assert tracker_panel._is_muted(ChannelName.PULSE2) def test_the_order_menu_silences_every_channel( self, channels_coordinator: SequencerTabCoordinator, ) -> None: order_panel = channels_coordinator._sequencer_order_panel - order_panel._on_label_clicked(0, True, GeneratorName.TRIANGLE) + order_panel._on_label_clicked(0, True, ChannelName.TRIANGLE) order_panel.call(order_panel.on_channels_muted) @@ -1123,7 +1123,7 @@ def test_the_order_menu_restores_every_channel( channels_coordinator: SequencerTabCoordinator, ) -> None: order_panel = channels_coordinator._sequencer_order_panel - order_panel._on_label_clicked(0, True, GeneratorName.TRIANGLE) + order_panel._on_label_clicked(0, True, ChannelName.TRIANGLE) order_panel.call(order_panel.on_channels_unmuted) @@ -1137,24 +1137,24 @@ def test_the_menu_reads_the_mute_set_back( self, channels_coordinator: SequencerTabCoordinator, ) -> None: - channels_coordinator._sequencer_channels_logic.toggle(GeneratorName.NOISE) + channels_coordinator._sequencer_channels_logic.toggle(ChannelName.NOISE) - assert channels_coordinator.channels.muted == frozenset({GeneratorName.NOISE}) + assert channels_coordinator.channels.muted == frozenset({ChannelName.NOISE}) def test_toggling_a_channel_silences_it( self, channels_coordinator: SequencerTabCoordinator, ) -> None: - channels_coordinator.toggle_channel(GeneratorName.PULSE1) + channels_coordinator.toggle_channel(ChannelName.PULSE1) - assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {GeneratorName.PULSE1} + assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {ChannelName.PULSE1} def test_toggling_a_channel_twice_returns_it_to_the_mix( self, channels_coordinator: SequencerTabCoordinator, ) -> None: - channels_coordinator.toggle_channel(GeneratorName.PULSE1) - channels_coordinator.toggle_channel(GeneratorName.PULSE1) + channels_coordinator.toggle_channel(ChannelName.PULSE1) + channels_coordinator.toggle_channel(ChannelName.PULSE1) assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS @@ -1162,7 +1162,7 @@ def test_the_menu_restores_every_channel_from_a_solo( self, channels_coordinator: SequencerTabCoordinator, ) -> None: - channels_coordinator._sequencer_channels_logic.solo(GeneratorName.TRIANGLE) + channels_coordinator._sequencer_channels_logic.solo(ChannelName.TRIANGLE) channels_coordinator.unmute_all_channels() @@ -1172,16 +1172,16 @@ def test_a_menu_toggle_shows_in_both_tables( self, channels_coordinator: SequencerTabCoordinator, ) -> None: - channels_coordinator.toggle_channel(GeneratorName.TRIANGLE) + channels_coordinator.toggle_channel(ChannelName.TRIANGLE) - assert channels_coordinator._sequencer_tracker_panel._is_muted(GeneratorName.TRIANGLE) - assert channels_coordinator._sequencer_order_panel._is_muted(GeneratorName.TRIANGLE) + assert channels_coordinator._sequencer_tracker_panel._is_muted(ChannelName.TRIANGLE) + assert channels_coordinator._sequencer_order_panel._is_muted(ChannelName.TRIANGLE) def test_a_table_click_tells_the_menu_bar( self, channels_coordinator: SequencerTabCoordinator, ) -> None: - channels_coordinator._sequencer_tracker_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE) + channels_coordinator._sequencer_tracker_panel._on_header_clicked(0, True, ChannelName.TRIANGLE) channels_coordinator._on_channels_changed.assert_called_once_with() @@ -1342,12 +1342,12 @@ def test_player_returns_the_guarded_wrapper( PULSE1_CELL: Final[TrackerRegion] = TrackerRegion( first_row=0, last_row=0, - first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, - last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME).flat_index, ) PULSE1_FRAME: Final[OrderRegion] = OrderRegion( - first_row=CHANNEL_AXIS.index(GeneratorName.PULSE1), - last_row=CHANNEL_AXIS.index(GeneratorName.PULSE1), + first_row=CHANNEL_AXIS.index(ChannelName.PULSE1), + last_row=CHANNEL_AXIS.index(ChannelName.PULSE1), first_position=0, last_position=0, ) @@ -1414,7 +1414,7 @@ def _place_transpose( HistoryAction.EDIT_ROW, coordinator._sequencer_tracker_logic.write_cell, ) - edit(0, GeneratorName.PULSE1, None, transpose, None) + edit(0, ChannelName.PULSE1, None, transpose, None) class TestBlockCopy: @@ -1426,7 +1426,7 @@ def test_a_copy_fills_the_clipboard_with_the_block_it_covers( with coordinator._history.transaction(HistoryAction.EDIT_ROW): coordinator._sequencer_tracker_logic.set_cell_subcolumn( 0, - GeneratorName.PULSE1, + ChannelName.PULSE1, transpose=5, ) @@ -1466,7 +1466,7 @@ def test_a_cut_takes_the_block_and_empties_what_it_covered( block = coordinator._clipboard.tracker_block assert block is not None assert block.transposes[(0, 1)] == 5 - assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).transpose is None def test_a_cut_records_one_entry( self, @@ -1491,7 +1491,7 @@ def test_a_delete_empties_the_region_in_one_entry( coordinator._sequencer_tracker_panel.on_delete_block(PULSE1_CELL) - assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).transpose is None assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK @@ -1504,9 +1504,9 @@ def test_a_paste_writes_the_copied_block_in_one_entry( coordinator._on_tracker_copy_block(PULSE1_CELL) recorded = len(coordinator._history.entries) - coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE2)) - assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE2, 1).transpose == 5 + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE2, 1).transpose == 5 assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK @@ -1537,7 +1537,7 @@ def test_a_cut_takes_the_block_and_silences_what_it_covered( coordinator._sequencer_order_panel.on_cut_block(PULSE1_FRAME) - assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None + assert coordinator._sequencer_order_logic.entry(ChannelName.PULSE1, 0) is None assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK @@ -1550,7 +1550,7 @@ def test_a_delete_silences_the_region_in_one_entry( coordinator._sequencer_order_panel.on_delete_block(PULSE1_FRAME) - assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None + assert coordinator._sequencer_order_logic.entry(ChannelName.PULSE1, 0) is None assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK @@ -1563,10 +1563,10 @@ def test_a_paste_covers_the_frames_it_appends_and_the_entries_it_writes( coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME) recorded = len(coordinator._history.entries) - coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1)) + coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=1)) assert coordinator._sequencer_order_logic.position_count() == 2 - assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0 + assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 1) == 0 assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK @@ -1584,7 +1584,7 @@ def test_a_paste_with_nothing_copied_records_nothing( _place_transpose(coordinator, 5) recorded = len(coordinator._history.entries) - coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE2)) assert len(coordinator._history.entries) == recorded @@ -1638,9 +1638,9 @@ def test_a_block_copied_elsewhere_is_the_one_a_paste_writes( coordinator._on_tracker_copy_block(PULSE1_CELL) coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") - coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) - assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 9 + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 9 def test_unrelated_text_leaves_the_copied_block_in_hand( self, @@ -1651,9 +1651,9 @@ def test_unrelated_text_leaves_the_copied_block_in_hand( coordinator._on_tracker_copy_block(PULSE1_CELL) coordinator._system_clipboard.write("a line from a message") - coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) - assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5 + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 5 def test_a_truncated_block_leaves_the_copied_block_in_hand( self, @@ -1664,9 +1664,9 @@ def test_a_truncated_block_leaves_the_copied_block_in_hand( coordinator._on_tracker_copy_block(PULSE1_CELL) coordinator._system_clipboard.write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .") - coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) - assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5 + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 5 def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( self, @@ -1677,9 +1677,9 @@ def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( coordinator._on_order_copy_block(PULSE1_FRAME) coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") - coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1)) + coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=1)) - assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0 + assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 1) == 0 def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard( self, diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index efe251588..b75d5ec59 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -9,7 +9,7 @@ from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.services.regeneration import RegeneratedInstrument from sampletones_application.services.result import ServiceSuccess -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import ( InvalidMetadataError, @@ -144,7 +144,7 @@ def test_history_hook_sees_prior_reconstruction_identity( regenerated = reconstruction_factory() outcome = RegeneratedInstrument( reconstruction=regenerated, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, feature_key=FeatureKey.VOLUME, ) diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 4fc8ddac3..c2a98a112 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -101,14 +101,14 @@ def test_wait_poll_does_not_emit_a_zero_progress_view( scheduled.assert_called_once() -class TestNoGeneratorsGuard: - """With no generators enabled there is nothing to reconstruct, so the conversion must not start.""" +class TestNoChannelsGuard: + """With no channels enabled there is nothing to reconstruct, so the conversion must not start.""" def test_no_generators_notifies_and_does_not_start( self, converter_logic: ConverterLogic, ) -> None: - converter_logic._config_manager.config.generation.generators = [] + converter_logic._config_manager.config.generation.channels = [] on_no_generators = MagicMock() converter_logic.on_no_generators = on_no_generators diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 54dfae4de..62c4bd02f 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -6,7 +6,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer from sampletones_core.project.instruments.instrument import Instrument @@ -87,19 +87,19 @@ def test_remove_sample_purges_row_references( controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") song = controller.project.song - pattern_id = song.order[0][GeneratorName.PULSE1] + pattern_id = song.order[0][ChannelName.PULSE1] controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_id, 0, - command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1), + command=Instrument(sample_id=sample.id, channel_name=ChannelName.PULSE1), volume=15, ) controller.remove_sample(sample.id) assert controller.project.sample(sample.id) is None - assert song.pattern(GeneratorName.PULSE1, pattern_id).rows[0].command is None + assert song.pattern(ChannelName.PULSE1, pattern_id).rows[0].command is None def test_is_sample_used_reflects_pattern_references( self, @@ -107,17 +107,17 @@ def test_is_sample_used_reflects_pattern_references( ) -> None: controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") - pattern_id = controller.project.song.order[0][GeneratorName.PULSE1] + pattern_id = controller.project.song.order[0][ChannelName.PULSE1] assert controller.is_sample_used(sample.id) is False controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_id, 0, command=Instrument( sample_id=sample.id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), ) @@ -149,20 +149,20 @@ def test_move_sample_preserves_row_references( sample = controller.add_sample(reconstruction_factory(), name="lead") controller.add_sample(reconstruction_factory(), name="pad") song = controller.project.song - pattern_id = song.order[0][GeneratorName.PULSE1] + pattern_id = song.order[0][ChannelName.PULSE1] controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_id, 0, command=Instrument( sample_id=sample.id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), ) controller.move_sample(sample.id, 1) - row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[0] + row = song.pattern(ChannelName.PULSE1, pattern_id).rows[0] assert row.command is not None assert row.command.sample_id == sample.id @@ -245,14 +245,14 @@ def test_replace_sample_reconstruction_preserves_row_references( controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") song = controller.project.song - pattern_id = song.order[0][GeneratorName.PULSE1] + pattern_id = song.order[0][ChannelName.PULSE1] controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_id, 0, command=Instrument( sample_id=sample.id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), ) @@ -261,7 +261,7 @@ def test_replace_sample_reconstruction_preserves_row_references( reconstruction_factory(), ) - row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[0] + row = song.pattern(ChannelName.PULSE1, pattern_id).rows[0] assert row.command is not None assert row.command.sample_id == sample.id @@ -292,21 +292,21 @@ def test_set_row_replaces_row( controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") song = controller.project.song - pattern_id = song.order[0][GeneratorName.PULSE1] + pattern_id = song.order[0][ChannelName.PULSE1] controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_id, 2, command=Instrument( sample_id=sample.id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), transpose=0, volume=10, ) - row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[2] + row = song.pattern(ChannelName.PULSE1, pattern_id).rows[2] assert row.command is not None assert row.command.sample_id == sample.id assert row.transpose == 0 @@ -316,28 +316,28 @@ def test_remove_pattern_drops_from_pool_and_nulls_order_references(self) -> None controller = _controller() song = controller.project.song - pattern_index = controller.add_pattern(GeneratorName.TRIANGLE) + pattern_index = controller.add_pattern(ChannelName.TRIANGLE) controller.append_frame() - controller.set_order_entry(GeneratorName.TRIANGLE, 1, pattern_index) - assert song.order[1][GeneratorName.TRIANGLE] == pattern_index - assert song.pattern(GeneratorName.TRIANGLE, pattern_index) is not None + controller.set_order_entry(ChannelName.TRIANGLE, 1, pattern_index) + assert song.order[1][ChannelName.TRIANGLE] == pattern_index + assert song.pattern(ChannelName.TRIANGLE, pattern_index) is not None - controller.remove_pattern(GeneratorName.TRIANGLE, pattern_index) - assert song.pattern(GeneratorName.TRIANGLE, pattern_index) is None - assert song.order[1][GeneratorName.TRIANGLE] is None + controller.remove_pattern(ChannelName.TRIANGLE, pattern_index) + assert song.pattern(ChannelName.TRIANGLE, pattern_index) is None + assert song.order[1][ChannelName.TRIANGLE] is None def test_move_frame_swaps_order_positions(self) -> None: controller = _controller() song = controller.project.song - first_index = song.order[0][GeneratorName.PULSE2] - second_index = controller.add_pattern(GeneratorName.PULSE2) + first_index = song.order[0][ChannelName.PULSE2] + second_index = controller.add_pattern(ChannelName.PULSE2) controller.append_frame() - controller.set_order_entry(GeneratorName.PULSE2, 1, second_index) + controller.set_order_entry(ChannelName.PULSE2, 1, second_index) controller.move_frame(0, 1) - assert song.order[0][GeneratorName.PULSE2] == second_index - assert song.order[1][GeneratorName.PULSE2] == first_index + assert song.order[0][ChannelName.PULSE2] == second_index + assert song.order[1][ChannelName.PULSE2] == first_index class TestPersistenceRoundTrip: @@ -349,14 +349,14 @@ def test_controller_edits_survive_save_load( controller.set_tempo(96) sample = controller.add_sample(reconstruction_factory(), name="lead") song = controller.project.song - pattern_id = song.order[0][GeneratorName.PULSE1] + pattern_id = song.order[0][ChannelName.PULSE1] controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_id, 0, command=Instrument( sample_id=sample.id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), volume=12, ) @@ -368,7 +368,7 @@ def test_controller_edits_survive_save_load( assert loaded.info.title == "Round" assert loaded.settings.tempo == 96 assert [stored.name for stored in loaded.samples] == ["lead"] - loaded_row = loaded.song.pattern(GeneratorName.PULSE1, pattern_id).rows[0] + loaded_row = loaded.song.pattern(ChannelName.PULSE1, pattern_id).rows[0] assert loaded_row.command is not None assert loaded_row.command.sample_id == sample.id assert loaded_row.volume == 12 @@ -517,18 +517,18 @@ def test_set_sample_loop_toggles_loop_flag( class TestPatternManagement: def test_add_pattern_returns_int_index(self) -> None: controller = _controller() - index = controller.add_pattern(GeneratorName.PULSE1) + index = controller.add_pattern(ChannelName.PULSE1) assert isinstance(index, int) def test_clone_pattern_creates_independent_copy(self) -> None: controller = _controller() - original_index = controller.add_pattern(GeneratorName.TRIANGLE) + original_index = controller.add_pattern(ChannelName.TRIANGLE) clone_index = controller.clone_pattern( - GeneratorName.TRIANGLE, + ChannelName.TRIANGLE, original_index, ) assert clone_index != original_index - assert controller.song.pattern(GeneratorName.TRIANGLE, clone_index) is not None + assert controller.song.pattern(ChannelName.TRIANGLE, clone_index) is not None class TestFrameManagement: @@ -544,7 +544,7 @@ class TestExistingRow: def test_update_row_on_nonexistent_pattern_is_no_op(self) -> None: controller = _controller() controller.update_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, 999, 0, ) @@ -575,8 +575,8 @@ def test_in_place_reconstruction_edit_is_visible_through_project( duty_cycle=1, ) ] - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, new_instructions, np.zeros(64, dtype=np.float32), 72, @@ -584,7 +584,7 @@ def test_in_place_reconstruction_edit_is_visible_through_project( ) stored = controller.project.sample(sample.id).reconstruction - assert stored.get_generator_instructions(GeneratorName.PULSE1) == new_instructions + assert stored.get_channel_instructions(ChannelName.PULSE1) == new_instructions class TestBatch: @@ -592,12 +592,12 @@ def test_a_batch_announces_one_song_change_for_many_rows(self) -> None: controller = _controller() emitted: List[str] = [] controller.on_song_changed = lambda: emitted.append("song") - pattern_index = controller.song.order[0][GeneratorName.PULSE1] + pattern_index = controller.song.order[0][ChannelName.PULSE1] with controller.batch(): for row_index in range(8): controller.set_row( - GeneratorName.PULSE1, + ChannelName.PULSE1, pattern_index, row_index, volume=15, diff --git a/tests/unit/sampletones_application/logic/project/test_manager.py b/tests/unit/sampletones_application/logic/project/test_manager.py index e0af6e437..dcfc14110 100644 --- a/tests/unit/sampletones_application/logic/project/test_manager.py +++ b/tests/unit/sampletones_application/logic/project/test_manager.py @@ -3,7 +3,7 @@ import pytest from sampletones_application.logic.project.manager import ProjectManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.exceptions import NotAValidArchiveError from tests.suite.errors import DIRECTORY_READ_ERRORS @@ -11,7 +11,7 @@ class TestProjectManager: def test_starts_with_a_clean_default_project(self) -> None: manager = ProjectManager() - assert set(manager.current.song.channels) == set(GeneratorName.items()) + assert set(manager.current.song.channels) == set(ChannelName.items()) assert len(manager.current.samples) == 0 assert manager.is_dirty is False @@ -42,7 +42,7 @@ def test_save_load_round_trip(self, tmp_path: Path) -> None: assert loaded.name == "demo" assert loaded.is_dirty is False assert loaded.current.info.title == "Demo" - assert set(loaded.current.song.channels) == set(GeneratorName.items()) + assert set(loaded.current.song.channels) == set(ChannelName.items()) class TestLoadPropagatesErrors: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 8a1cae3e5..64e8fcfa7 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -37,7 +37,7 @@ def config_fields( nes_frequency: int = 30, spectrum_method: SpectrumMethod = SpectrumMethod.FFT, transformation_gamma: int = 0, - generators: str = "PTN", + channels: str = "PTN", config_hash: str = HASH_A, ) -> ConfigDirectoryFields: """Builds configuration fields, so a test states only the field whose effect it examines.""" @@ -46,7 +46,7 @@ def config_fields( nf=nes_frequency, sm=spectrum_method, tg=transformation_gamma, - gn=generators, + gn=channels, ch=config_hash, ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py index 0da43ad99..e3fc2421d 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py @@ -63,7 +63,7 @@ def generator_directories( class TestTopLevelConfigDirectories: def test_config_directory_groups_by_frequencies_then_transformation(self) -> None: - fields = config_fields(generators="PpT") + fields = config_fields(channels="PpT") branch = build_branch(scan_of(config_entry(fields, "song"))) frequencies = group_children(branch) @@ -118,8 +118,8 @@ def test_colliding_generators_get_a_hash_suffix(self) -> None: } def test_distinct_generators_share_a_transformation_group_under_their_own_names(self) -> None: - first = config_fields(generators="PTN") - second = config_fields(generators="TN") + first = config_fields(channels="PTN") + second = config_fields(channels="TN") branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) assert set(generator_directories(branch, first)) == {"PTN", "TN"} diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index a8f734e8d..e684490f0 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -6,7 +6,7 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @@ -241,7 +241,7 @@ def test_unknown_generator_returns_zeros( name="Sample", ) - result = data.get_partials([GeneratorName.TRIANGLE]) + result = data.get_partials([ChannelName.TRIANGLE]) assert np.all(result == 0.0) @@ -255,7 +255,7 @@ def test_known_generator_returns_its_approximation( name="Sample", ) - result = data.get_partials([GeneratorName.PULSE1]) + result = data.get_partials([ChannelName.PULSE1]) - expected = reconstruction.approximations[GeneratorName.PULSE1] + expected = reconstruction.approximations[ChannelName.PULSE1] assert np.array_equal(result, expected) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py index 96bb0d6be..81eba80f7 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py @@ -5,7 +5,7 @@ import pytest from sampletones_application.logic.reconstruction.feature import FeatureData -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction @@ -27,7 +27,7 @@ def test_load_creates_entry_for_each_generator( self, feature_data: FeatureData, ) -> None: - assert set(feature_data.generators.keys()) == set(GeneratorName.items()) + assert set(feature_data.channels.keys()) == set(ChannelName.items()) def test_a_channel_standing_by_carries_empty_envelopes( self, @@ -35,23 +35,23 @@ def test_a_channel_standing_by_carries_empty_envelopes( feature_data: FeatureData, ) -> None: """A channel the reconstruction leaves silent is loaded describing no frame.""" - standing_by = set(GeneratorName.items()) - set(reconstruction.playing_generators) + standing_by = set(ChannelName.items()) - set(reconstruction.playing_channels) assert standing_by - assert all(not feature_data[generator_name].has_frames for generator_name in standing_by) + assert all(not feature_data[channel_name].has_frames for channel_name in standing_by) def test_loaded_features_include_initial_pitch( self, feature_data: FeatureData, ) -> None: - for features in feature_data.generators.values(): + for features in feature_data.channels.values(): assert features.get(FeatureKey.INITIAL_PITCH) is not None class TestFeatureDataQueries: - @pytest.mark.parametrize("generator_name", GeneratorName.items(), ids=lambda name: name.value) + @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda name: name.value) def test_every_channel_answers_with_its_features( self, feature_data: FeatureData, - generator_name: GeneratorName, + channel_name: ChannelName, ) -> None: - assert isinstance(feature_data[generator_name], Features) + assert isinstance(feature_data[channel_name], Features) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 3ab55ba90..4df890955 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -13,7 +13,7 @@ from sampletones_application.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, ) -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.formats.famitracker.footprint import ( features_footprint, @@ -57,7 +57,7 @@ def test_no_features_fires_on_feature_data_changed_with_none( mock_reconstruction_manager: MagicMock, ) -> None: mock_reconstruction_manager.current_features = None - received: List[Optional[Dict[GeneratorName, Features]]] = [] + received: List[Optional[Dict[ChannelName, Features]]] = [] instruments_logic.on_feature_data_changed = received.append instruments_logic.update_display() assert received == [None] @@ -82,10 +82,10 @@ def test_with_features_fires_on_feature_data_changed_with_data( ) -> None: feature_data = FeatureData.load(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data - received: List[Optional[Dict[GeneratorName, Features]]] = [] + received: List[Optional[Dict[ChannelName, Features]]] = [] instruments_logic.on_feature_data_changed = received.append instruments_logic.update_display() - assert received == [feature_data.generators] + assert received == [feature_data.channels] def test_with_features_exposes_the_playing_generators( self, @@ -97,7 +97,7 @@ def test_with_features_exposes_the_playing_generators( received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append instruments_logic.update_display() - assert GeneratorName.PULSE1 in received[0].playing_generators + assert ChannelName.PULSE1 in received[0].playing_channels class TestReconstructionInstrumentsLogicFootprint: @@ -127,8 +127,8 @@ def test_every_playing_channel_is_measured( instruments_logic.update_display() footprint = received[0].footprint assert footprint is not None - assert {instrument.generator for instrument in footprint.instruments} == { - generator_name for generator_name, features in feature_data.generators.items() if features.has_frames + assert {instrument.channel for instrument in footprint.instruments} == { + channel_name for channel_name, features in feature_data.channels.items() if features.has_frames } def test_the_size_is_the_one_a_one_shot_export_writes( @@ -147,7 +147,7 @@ def test_the_size_is_the_one_a_one_shot_export_writes( assert footprint is not None expected = total_footprint( features_footprint(features, loop=False) - for features in feature_data.generators.values() + for features in feature_data.channels.values() if features.has_frames ) assert footprint.total_bytes == expected.total_bytes @@ -166,16 +166,16 @@ def test_an_envelope_edit_is_measured_as_it_arrives( volume = np.array([15, 12, 8, 4, 0], dtype=np.int8) instruments_logic.handle_raw_data_changed( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.VOLUME, volume, ) - edited = feature_data.generators[GeneratorName.PULSE1].model_copy(deep=True) + edited = feature_data.channels[ChannelName.PULSE1].model_copy(deep=True) edited[FeatureKey.VOLUME] = volume footprint = received[0].footprint assert footprint is not None - assert footprint.bytes_for(GeneratorName.PULSE1) == features_footprint(edited, loop=False).total_bytes + assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited, loop=False).total_bytes def test_a_bar_edit_is_measured_as_it_arrives( self, @@ -189,7 +189,7 @@ def test_a_bar_edit_is_measured_as_it_arrives( instruments_logic.on_view_changed = received.append instruments_logic.handle_bar_point_clicked( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.ARPEGGIO, np.zeros(6, dtype=np.int8), ) @@ -205,15 +205,15 @@ def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are( """The regeneration owns the loaded envelopes, so the measurement reads a copy.""" feature_data = FeatureData.load(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data - loaded_volume = feature_data.generators[GeneratorName.PULSE1].volume.copy() + loaded_volume = feature_data.channels[ChannelName.PULSE1].volume.copy() instruments_logic.handle_raw_data_changed( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.VOLUME, np.array([15, 12, 8, 4, 0], dtype=np.int8), ) - assert np.array_equal(feature_data.generators[GeneratorName.PULSE1].volume, loaded_volume) + assert np.array_equal(feature_data.channels[ChannelName.PULSE1].volume, loaded_volume) def test_a_refresh_reports_the_view_alone( self, @@ -224,7 +224,7 @@ def test_a_refresh_reports_the_view_alone( """A regenerated reconstruction refreshes the figures, leaving the edited envelopes displayed.""" mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory()) received: List[ReconstructionInstrumentsViewModel] = [] - feature_updates: List[Optional[Dict[GeneratorName, Features]]] = [] + feature_updates: List[Optional[Dict[ChannelName, Features]]] = [] instruments_logic.on_view_changed = received.append instruments_logic.on_feature_data_changed = feature_updates.append @@ -243,7 +243,7 @@ def test_schedules_reconstruction_update( ) -> None: callback = MagicMock() instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_pitch_value_changed(GeneratorName.PULSE1, 61) + instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, 61) callback.assert_called_once() def test_forwards_generator_pitch_feature_and_value( @@ -253,9 +253,9 @@ def test_forwards_generator_pitch_feature_and_value( ) -> None: callback = MagicMock() instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_pitch_value_changed(GeneratorName.PULSE1, 61) - generator_name, _features, feature_key, value = callback.call_args.args - assert generator_name == GeneratorName.PULSE1 + instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, 61) + channel_name, _features, feature_key, value = callback.call_args.args + assert channel_name == ChannelName.PULSE1 assert feature_key == FeatureKey.INITIAL_PITCH assert value == 61 @@ -269,7 +269,7 @@ def test_handle_bar_point_clicked_schedules_update( callback = MagicMock() instruments_logic.on_reconstruction_instrument_updated = callback instruments_logic.handle_bar_point_clicked( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.VOLUME, np.zeros(4, dtype=np.float32), ) @@ -285,7 +285,7 @@ def test_handle_raw_data_changed_schedules_update( callback = MagicMock() instruments_logic.on_reconstruction_instrument_updated = callback instruments_logic.handle_raw_data_changed( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.ARPEGGIO, np.zeros(4, dtype=np.float32), ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index cb0a74df3..3ed935c0f 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -7,7 +7,7 @@ from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import LoadReconstructionError @@ -378,8 +378,8 @@ def test_locate_audio_raises_file_not_found_when_audio_missing( missing_path = tmp_path / "ghost.wav" reconstruction = Reconstruction.create( approximation=np.zeros(64, dtype=np.float32), - approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)}, - instructions={GeneratorName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, + approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, config=Config(), coefficient=1.0, audio_filepath=missing_path, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 0d426cfbc..b367f2a92 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -17,7 +17,7 @@ ) from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.instructions import TriangleInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.trackers.format import TrackerFormat @@ -303,8 +303,8 @@ def test_display_offers_the_channels_that_play( panel_logic.display_reconstruction() - assert received[0].playing_generators == frozenset({GeneratorName.PULSE1}) - assert received[0].selected_generators == frozenset({GeneratorName.PULSE1}) + assert received[0].playing_channels == frozenset({ChannelName.PULSE1}) + assert received[0].selected_channels == frozenset({ChannelName.PULSE1}) def test_an_edit_reports_the_view_again( self, @@ -318,7 +318,7 @@ def test_an_edit_reports_the_view_again( panel_logic.update_reconstruction() - assert received[0].playing_generators == frozenset({GeneratorName.PULSE1}) + assert received[0].playing_channels == frozenset({ChannelName.PULSE1}) def test_a_channel_switched_off_by_hand_survives_an_edit( self, @@ -328,12 +328,12 @@ def test_a_channel_switched_off_by_hand_survives_an_edit( ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.display_reconstruction() - panel_logic.set_selected_generators([]) + panel_logic.set_selected_channels([]) received = self._received(panel_logic) panel_logic.update_reconstruction() - assert received[0].selected_generators == frozenset() + assert received[0].selected_channels == frozenset() def test_a_channel_gaining_its_first_frame_joins_the_waveform( self, @@ -343,8 +343,8 @@ def test_a_channel_gaining_its_first_frame_joins_the_waveform( ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.display_reconstruction() - loaded_data.reconstruction.update_generator_data( - GeneratorName.TRIANGLE, + loaded_data.reconstruction.update_channel_data( + ChannelName.TRIANGLE, [TriangleInstruction(on=True, pitch=48)], np.ones(64, dtype=np.float32), 48, @@ -354,8 +354,8 @@ def test_a_channel_gaining_its_first_frame_joins_the_waveform( panel_logic.update_reconstruction() - assert received[0].playing_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}) - assert received[0].selected_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}) + assert received[0].playing_channels == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + assert received[0].selected_channels == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) def test_a_channel_taken_out_of_play_leaves_the_waveform( self, @@ -365,8 +365,8 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform( ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.display_reconstruction() - loaded_data.reconstruction.update_generator_data( - GeneratorName.PULSE1, + loaded_data.reconstruction.update_channel_data( + ChannelName.PULSE1, [], np.zeros(0, dtype=np.float32), 60, @@ -376,8 +376,8 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform( panel_logic.update_reconstruction() - assert received[0].playing_generators == frozenset() - assert received[0].selected_generators == frozenset() + assert received[0].playing_channels == frozenset() + assert received[0].selected_channels == frozenset() class TestReconstructionPanelLogicClose: @@ -479,7 +479,7 @@ def test_set_audio_source_emits_audio_data( callback.assert_called_once() -class TestReconstructionPanelLogicSelectedGenerators: +class TestReconstructionPanelLogicSelectedChannels: def test_set_selected_generators_updates_selection( self, panel_logic: ReconstructionPanelLogic, @@ -487,8 +487,8 @@ def test_set_selected_generators_updates_selection( loaded_data: ReconstructionData, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.set_selected_generators([GeneratorName.PULSE1]) - assert panel_logic._selected_generators == [GeneratorName.PULSE1] + panel_logic.set_selected_channels([ChannelName.PULSE1]) + assert panel_logic._selected_channels == [ChannelName.PULSE1] def test_set_selected_generators_fires_waveform_load( self, @@ -499,7 +499,7 @@ def test_set_selected_generators_fires_waveform_load( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_waveform_load_changed = callback - panel_logic.set_selected_generators([GeneratorName.PULSE1]) + panel_logic.set_selected_channels([ChannelName.PULSE1]) callback.assert_called_once() def test_set_selected_generators_with_no_data_skips_waveform( @@ -508,7 +508,7 @@ def test_set_selected_generators_with_no_data_skips_waveform( ) -> None: callback = MagicMock() panel_logic.on_waveform_load_changed = callback - panel_logic.set_selected_generators([GeneratorName.PULSE1]) + panel_logic.set_selected_channels([ChannelName.PULSE1]) callback.assert_not_called() @@ -518,7 +518,7 @@ def test_request_export_instrument_dialog_with_no_data_raises_assertion_error( panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) def test_request_export_instrument_dialog_fires_dialog_callback( self, @@ -529,7 +529,7 @@ def test_request_export_instrument_dialog_fires_dialog_callback( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) callback.assert_called_once() def test_request_export_instrument_dialog_suggests_the_slice_name( @@ -544,7 +544,7 @@ def test_request_export_instrument_dialog_suggests_the_slice_name( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) + panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) assert callback.call_args.args[0] == "Sample (pulse1)" def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( @@ -556,7 +556,7 @@ def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE) + panel_logic.request_export_instrument_dialog(ChannelName.TRIANGLE) callback.assert_not_called() def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog( @@ -565,12 +565,12 @@ def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - """The generator travels with the request, so the confirmation names it back.""" + """The channel travels with the request, so the confirmation names it back.""" mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1) - assert callback.call_args.args[2] == GeneratorName.PULSE1 + panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) + assert callback.call_args.args[2] == ChannelName.PULSE1 def test_handle_export_instrument_confirmed_with_no_data_does_not_export( self, @@ -580,7 +580,7 @@ def test_handle_export_instrument_confirmed_with_no_data_does_not_export( ) -> None: panel_logic.handle_export_instrument_confirmed( tmp_path / "instrument.fti", - GeneratorName.PULSE1, + ChannelName.PULSE1, ) mock_export_service.export_instrument.assert_not_called() @@ -595,7 +595,7 @@ def test_handle_export_instrument_confirmed_calls_export_service( mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instrument_confirmed( tmp_path / "instrument.fti", - GeneratorName.PULSE1, + ChannelName.PULSE1, ) mock_export_service.export_instrument.assert_called_once() @@ -610,7 +610,7 @@ def test_handle_export_instrument_confirmed_names_the_instrument_after_the_desti mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instrument_confirmed( tmp_path / "Clap (pulse1).fti", - GeneratorName.PULSE1, + ChannelName.PULSE1, ) request = mock_export_service.export_instrument.call_args.args[2] assert request.name == "Clap (pulse1)" @@ -629,7 +629,7 @@ def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_na mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instrument_confirmed( tmp_path / f"instrument{case.extension}", - GeneratorName.PULSE1, + ChannelName.PULSE1, ) backend = mock_export_service.export_instrument.call_args.args[1] assert backend is mock_tracker_backends[case.tracker_format] @@ -650,7 +650,7 @@ def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_write with pytest.raises(ValueError): panel_logic.handle_export_instrument_confirmed( tmp_path / f"instrument{extension}", - GeneratorName.PULSE1, + ChannelName.PULSE1, ) @@ -811,7 +811,7 @@ def test_handle_export_wav_confirmed_calls_export_wav_with_sample_rate( tmp_path: Path, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic._selected_generators = [GeneratorName.PULSE1] + panel_logic._selected_channels = [ChannelName.PULSE1] panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") mock_export_service.export_wav.assert_called_once() call_args = mock_export_service.export_wav.call_args diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py index 6e374b736..48c57497a 100644 --- a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py @@ -5,7 +5,7 @@ from sampletones_application.logic.sequencer.clipboard.samples import ( ProjectSampleDirectory, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.sequencer import sample_reconstruction @@ -23,7 +23,7 @@ def directory(controller: ProjectController) -> ProjectSampleDirectory: def _add_sample(controller: ProjectController, name: str) -> str: sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([ChannelName.PULSE1]), name=name, ) return sample.id diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py index 2a20eb0dc..d37a9b775 100644 --- a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py @@ -8,7 +8,7 @@ from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.note_off import NoteOff SAMPLE_IDS: List[str] = ["kick", "snare", "hat"] @@ -38,8 +38,8 @@ def text() -> TrackerBlockText: return TrackerBlockText(samples=FakeSampleDirectory(SAMPLE_IDS)) -def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: - return TrackerSlot(generator, subcolumn).flat_index +def _slot(channel: Optional[ChannelName], subcolumn: SubColumn) -> int: + return TrackerSlot(channel, subcolumn).flat_index def _region( @@ -57,8 +57,8 @@ def _region( PULSE1_CELL = _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), ) @@ -104,8 +104,8 @@ def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: Track class TestTheShapeAStatementCovers: def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: TrackerBlockText) -> None: region = _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE2, SubColumn.VOLUME), rows=4, ) @@ -115,8 +115,8 @@ def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: Tracker def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlockText) -> None: region = _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE2, SubColumn.VOLUME), ) assert _body(text, TrackerBlock(notes={}, transposes={}, volumes={}), region) == ["?? ??? ? | ?? ??? ?"] @@ -124,8 +124,8 @@ def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlock def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: TrackerBlockText) -> None: block = TrackerBlock(notes={}, transposes={(0, 1): 1, (2, 1): 3}, volumes={}) region = _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=3, ) @@ -149,8 +149,8 @@ class RoundTripCase: "a cut and an empty note", TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}), _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=2, ), ), @@ -158,8 +158,8 @@ class RoundTripCase: "the whole transpose range", TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}), _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=3, ), ), @@ -167,8 +167,8 @@ class RoundTripCase: "the whole volume range", TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}), _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=2, ), ), @@ -177,15 +177,15 @@ class RoundTripCase: TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}), _region( first_slot=_slot(None, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), ), ), RoundTripCase( "a block starting and ending mid-cell", TrackerBlock(notes={(0, 3): "snare"}, transposes={(0, 1): 5, (0, 4): None}, volumes={(0, 2): 3}), _region( - first_slot=_slot(GeneratorName.PULSE1, SubColumn.TRANSPOSE), - last_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE), + first_slot=_slot(ChannelName.PULSE1, SubColumn.TRANSPOSE), + last_slot=_slot(ChannelName.PULSE2, SubColumn.TRANSPOSE), ), ), RoundTripCase( @@ -193,7 +193,7 @@ class RoundTripCase: TrackerBlock(notes={(0, 12): "kick"}, transposes={(1, 1): -1}, volumes={(1, 14): 4}), _region( first_slot=_slot(None, SubColumn.INSTRUMENT), - last_slot=_slot(GeneratorName.NOISE, SubColumn.VOLUME), + last_slot=_slot(ChannelName.NOISE, SubColumn.VOLUME), rows=2, ), ), diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_order.py b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py index ec24ec9fc..94eaa7f60 100644 --- a/tests/unit/sampletones_application/logic/sequencer/order/test_order.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py @@ -1,51 +1,51 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.order import SequencerOrderLogic -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName def _logic() -> SequencerOrderLogic: return SequencerOrderLogic(ProjectController(ProjectManager())) -def _order_column(logic: SequencerOrderLogic, generator: GeneratorName) -> list: - """Returns the list of pattern indices for ``generator`` across all order positions.""" +def _order_column(logic: SequencerOrderLogic, channel: ChannelName) -> list: + """Returns the list of pattern indices for ``channel`` across all order positions.""" song = logic._controller.project.song - return [frame[generator] for frame in song.order] + return [frame[channel] for frame in song.order] class TestOrderMutations: def test_set_order_entry_assigns_one_channel(self) -> None: logic = _logic() - logic.set_order_entry(GeneratorName.PULSE1, 0, 3) + logic.set_order_entry(ChannelName.PULSE1, 0, 3) - assert _order_column(logic, GeneratorName.PULSE1) == [3] - assert _order_column(logic, GeneratorName.TRIANGLE) == [0] + assert _order_column(logic, ChannelName.PULSE1) == [3] + assert _order_column(logic, ChannelName.TRIANGLE) == [0] def test_set_master_entry_broadcasts_to_every_channel(self) -> None: logic = _logic() logic.set_master_entry(0, 4) - for generator in GeneratorName.items(): - assert _order_column(logic, generator) == [4] + for channel in ChannelName.items(): + assert _order_column(logic, channel) == [4] def test_set_order_entry_clears_one_channel_with_none(self) -> None: logic = _logic() - logic.set_order_entry(GeneratorName.PULSE1, 0, None) + logic.set_order_entry(ChannelName.PULSE1, 0, None) - assert _order_column(logic, GeneratorName.PULSE1) == [None] - assert _order_column(logic, GeneratorName.TRIANGLE) == [0] + assert _order_column(logic, ChannelName.PULSE1) == [None] + assert _order_column(logic, ChannelName.TRIANGLE) == [0] def test_set_master_entry_clears_every_channel_with_none(self) -> None: logic = _logic() logic.set_master_entry(0, None) - for generator in GeneratorName.items(): - assert _order_column(logic, generator) == [None] + for channel in ChannelName.items(): + assert _order_column(logic, channel) == [None] def test_remove_from_order_drops_the_frame(self) -> None: logic = _logic() @@ -53,8 +53,8 @@ def test_remove_from_order_drops_the_frame(self) -> None: logic.remove_from_order(0) - for generator in GeneratorName.items(): - assert _order_column(logic, generator) == [None] + for channel in ChannelName.items(): + assert _order_column(logic, channel) == [None] class TestEntryAccess: @@ -63,29 +63,29 @@ class TestEntryAccess: def test_write_entry_reaches_one_channel(self) -> None: logic = _logic() - logic.write_entry(GeneratorName.PULSE1, 0, 3) + logic.write_entry(ChannelName.PULSE1, 0, 3) - assert _order_column(logic, GeneratorName.PULSE1) == [3] - assert _order_column(logic, GeneratorName.TRIANGLE) == [0] + assert _order_column(logic, ChannelName.PULSE1) == [3] + assert _order_column(logic, ChannelName.TRIANGLE) == [0] def test_write_entry_through_the_master_row_reaches_every_channel(self) -> None: logic = _logic() logic.write_entry(None, 0, 3) - for generator in GeneratorName.items(): - assert _order_column(logic, generator) == [3] + for channel in ChannelName.items(): + assert _order_column(logic, channel) == [3] def test_entry_reads_the_index_a_channel_plays(self) -> None: logic = _logic() - logic.set_order_entry(GeneratorName.NOISE, 0, 7) + logic.set_order_entry(ChannelName.NOISE, 0, 7) - assert logic.entry(GeneratorName.NOISE, 0) == 7 + assert logic.entry(ChannelName.NOISE, 0) == 7 def test_entry_past_the_last_frame_reads_as_silence(self) -> None: logic = _logic() - assert logic.entry(GeneratorName.NOISE, logic.position_count()) is None + assert logic.entry(ChannelName.NOISE, logic.position_count()) is None def test_append_frame_lengthens_the_order_by_one(self) -> None: logic = _logic() @@ -94,34 +94,34 @@ def test_append_frame_lengthens_the_order_by_one(self) -> None: logic.append_frame() assert logic.position_count() == length + 1 - for generator in GeneratorName.items(): - assert logic.entry(generator, length) is None + for channel in ChannelName.items(): + assert logic.entry(channel, length) is None class TestOrderFrameOps: def test_insert_frame_adds_empty_frame_at_position(self) -> None: logic = _logic() - logic.set_order_entry(GeneratorName.PULSE1, 0, 5) + logic.set_order_entry(ChannelName.PULSE1, 0, 5) logic.insert_frame(0) - assert _order_column(logic, GeneratorName.PULSE1) == [None, 5] + assert _order_column(logic, ChannelName.PULSE1) == [None, 5] def test_duplicate_frame_repeats_the_same_pattern(self) -> None: logic = _logic() - logic.set_order_entry(GeneratorName.PULSE1, 0, 5) + logic.set_order_entry(ChannelName.PULSE1, 0, 5) logic.duplicate_frame(0) - assert _order_column(logic, GeneratorName.PULSE1) == [5, 5] + assert _order_column(logic, ChannelName.PULSE1) == [5, 5] def test_clone_frame_gives_the_copy_its_own_pattern(self) -> None: logic = _logic() - logic.set_order_entry(GeneratorName.PULSE1, 0, 5) + logic.set_order_entry(ChannelName.PULSE1, 0, 5) logic.clone_frame(0) - source_index, clone_index = _order_column(logic, GeneratorName.PULSE1) + source_index, clone_index = _order_column(logic, ChannelName.PULSE1) assert source_index == 5 assert clone_index != 5 @@ -131,18 +131,18 @@ def test_clear_frame_empties_every_channel(self) -> None: logic.clear_frame(0) - for generator in GeneratorName.items(): - assert _order_column(logic, generator) == [None] + for channel in ChannelName.items(): + assert _order_column(logic, channel) == [None] def test_move_frame_reorders(self) -> None: logic = _logic() logic.insert_frame(1) - logic.set_order_entry(GeneratorName.PULSE1, 0, 1) - logic.set_order_entry(GeneratorName.PULSE1, 1, 2) + logic.set_order_entry(ChannelName.PULSE1, 0, 1) + logic.set_order_entry(ChannelName.PULSE1, 1, 2) logic.move_frame(0, 1) - assert _order_column(logic, GeneratorName.PULSE1) == [2, 1] + assert _order_column(logic, ChannelName.PULSE1) == [2, 1] class TestBuildOrder: @@ -153,5 +153,5 @@ def test_position_count_matches_order_length(self) -> None: view_model = logic.build_order() assert view_model.position_count == 2 - assert set(view_model.channels) == set(GeneratorName.items()) - assert view_model.entry_label(GeneratorName.PULSE1, 1) == view_model.master_label(1) + assert set(view_model.channels) == set(ChannelName.items()) + assert view_model.entry_label(ChannelName.PULSE1, 1) == view_model.master_label(1) diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py index 2c49e3cc2..636e33554 100644 --- a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py @@ -10,12 +10,12 @@ SequencerOrderLogic, ) from sampletones_application.view_model.sequencer.region import OrderRegion -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.sequencer import fill_order MASTER_ROW = CHANNEL_AXIS.index(None) -PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) -NOISE_ROW = CHANNEL_AXIS.index(GeneratorName.NOISE) +PULSE1_ROW = CHANNEL_AXIS.index(ChannelName.PULSE1) +NOISE_ROW = CHANNEL_AXIS.index(ChannelName.NOISE) @pytest.fixture @@ -30,12 +30,12 @@ def reader(logic: SequencerOrderLogic) -> OrderBlockReader: def _row( - generator: Optional[GeneratorName], + channel: Optional[ChannelName], *, last_position: int = 0, ) -> OrderRegion: """The region one whole row covers, out to ``last_position``.""" - row = CHANNEL_AXIS.index(generator) + row = CHANNEL_AXIS.index(channel) return OrderRegion( first_row=row, last_row=row, @@ -62,7 +62,7 @@ def test_a_row_carries_the_indices_it_plays( ), ) - block = reader.read(_row(GeneratorName.PULSE1, last_position=2)) + block = reader.read(_row(ChannelName.PULSE1, last_position=2)) assert block.entries == {(0, 0): 0, (0, 1): 1, (0, 2): 2} @@ -82,7 +82,7 @@ def test_a_silent_cell_carries_its_silence( ), ) - block = reader.read(_row(GeneratorName.NOISE)) + block = reader.read(_row(ChannelName.NOISE)) assert block.entries == {(0, 0): None} @@ -170,7 +170,7 @@ def test_offsets_run_from_the_cell_the_region_begins_at( block = reader.read( OrderRegion( first_row=PULSE1_ROW, - last_row=CHANNEL_AXIS.index(GeneratorName.PULSE2), + last_row=CHANNEL_AXIS.index(ChannelName.PULSE2), first_position=2, last_position=2, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py index 9ee95980f..c9b4da58e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py @@ -12,7 +12,7 @@ SequencerOrderLogic, ) from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.sequencer import fill_order, parse_order_block, render_order @@ -50,8 +50,8 @@ def table() -> Table: ) -def _row(generator: Optional[GeneratorName]) -> int: - return CHANNEL_AXIS.index(generator) +def _row(channel: Optional[ChannelName]) -> int: + return CHANNEL_AXIS.index(channel) class TestPaste(BaseTestSuite): @@ -72,7 +72,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a block lands at the cell it is written from", block=("07 08",), - origin=OrderCell(generator=GeneratorName.PULSE2, position=1), + origin=OrderCell(channel=ChannelName.PULSE2, position=1), expected=( SILENT, ".. 07 08", @@ -83,7 +83,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a block through the master row reaches every channel", block=("05",), - origin=OrderCell(generator=None, position=0), + origin=OrderCell(channel=None, position=0), expected=( "05 .. ..", "05 .. ..", @@ -97,7 +97,7 @@ class TestCase(BaseRegularTestCase): "05", "06", ), - origin=OrderCell(generator=None, position=0), + origin=OrderCell(channel=None, position=0), expected=( "06 .. ..", "05 .. ..", @@ -108,7 +108,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a block read from the master row writes one channel when written to one", block=("05",), - origin=OrderCell(generator=GeneratorName.TRIANGLE, position=2), + origin=OrderCell(channel=ChannelName.TRIANGLE, position=2), expected=( SILENT, SILENT, @@ -125,7 +125,7 @@ class TestCase(BaseRegularTestCase): SILENT, ), block=("09 ? 0A",), - origin=OrderCell(generator=GeneratorName.PULSE1, position=0), + origin=OrderCell(channel=ChannelName.PULSE1, position=0), expected=( "09 02 0A", SILENT, @@ -142,7 +142,7 @@ class TestCase(BaseRegularTestCase): SILENT, ), block=(".. ..",), - origin=OrderCell(generator=GeneratorName.PULSE1, position=0), + origin=OrderCell(channel=ChannelName.PULSE1, position=0), expected=( ".. .. 03", SILENT, @@ -157,7 +157,7 @@ class TestCase(BaseRegularTestCase): "02", "03", ), - origin=OrderCell(generator=GeneratorName.TRIANGLE, position=0), + origin=OrderCell(channel=ChannelName.TRIANGLE, position=0), expected=( SILENT, SILENT, @@ -171,7 +171,7 @@ class TestCase(BaseRegularTestCase): "01", "02", ), - origin=OrderCell(generator=GeneratorName.NOISE, position=0), + origin=OrderCell(channel=ChannelName.NOISE, position=0), expected=( SILENT, SILENT, @@ -211,7 +211,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a block reaching past the end appends exactly the positions it writes", block=("01 02 03",), - origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + origin=OrderCell(channel=ChannelName.PULSE1, position=2), expected=( ".. .. 01 02 03", ".. .. .. .. ..", @@ -222,7 +222,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a column the block says nothing about appends no position", block=("01 ? ?",), - origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + origin=OrderCell(channel=ChannelName.PULSE1, position=2), expected=( ".. .. 01", SILENT, @@ -233,7 +233,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a column the block silences appends the position it silences", block=("01 ? ..",), - origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + origin=OrderCell(channel=ChannelName.PULSE1, position=2), expected=( ".. .. 01 .. ..", ".. .. .. .. ..", @@ -247,7 +247,7 @@ class TestCase(BaseRegularTestCase): "01 ?", "? 02", ), - origin=OrderCell(generator=GeneratorName.NOISE, position=2), + origin=OrderCell(channel=ChannelName.NOISE, position=2), expected=( SILENT, SILENT, @@ -258,7 +258,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a wholly mixed block leaves the order the length it was", block=("? ? ?",), - origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + origin=OrderCell(channel=ChannelName.PULSE1, position=2), expected=( SILENT, SILENT, @@ -299,8 +299,8 @@ def test_a_region_silences_the_cells_it_covers(self, table: Table) -> None: table.writer.clear( OrderRegion( - first_row=_row(GeneratorName.PULSE2), - last_row=_row(GeneratorName.TRIANGLE), + first_row=_row(ChannelName.PULSE2), + last_row=_row(ChannelName.TRIANGLE), first_position=0, last_position=1, ) @@ -355,7 +355,7 @@ def test_a_delete_leaves_the_order_the_length_it_was(self, table: Table) -> None table.writer.clear( OrderRegion( first_row=_row(None), - last_row=_row(GeneratorName.NOISE), + last_row=_row(ChannelName.NOISE), first_position=0, last_position=2, ) @@ -379,14 +379,14 @@ def test_a_block_written_back_at_its_origin_restores_the_order(self, table: Tabl ) before = render_order(table.logic) region = OrderRegion( - first_row=_row(GeneratorName.PULSE1), - last_row=_row(GeneratorName.NOISE), + first_row=_row(ChannelName.PULSE1), + last_row=_row(ChannelName.NOISE), first_position=0, last_position=2, ) block = OrderBlockReader(table.logic).read(region) table.writer.clear(region) - table.writer.write(block, OrderCell(generator=GeneratorName.PULSE1, position=0)) + table.writer.write(block, OrderCell(channel=ChannelName.PULSE1, position=0)) assert render_order(table.logic) == before diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 7d65e04f8..b7367ba5b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -10,7 +10,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.instructions import ( NoiseInstruction, PulseInstruction, @@ -26,7 +26,7 @@ def make_controller() -> ProjectController: return ProjectController(ProjectManager()) -def all_channels() -> FrozenSet[GeneratorName]: +def all_channels() -> FrozenSet[ChannelName]: """The fully audible mask a synthesiser renders under unless a test moves it.""" return ALL_CHANNELS @@ -36,7 +36,7 @@ def make_synthesizer( config: Config, *, sample_rate: int = DEFAULT_SAMPLE_RATE, - active_channels: Callable[[], FrozenSet[GeneratorName]] = all_channels, + active_channels: Callable[[], FrozenSet[ChannelName]] = all_channels, ) -> RowSynthesizer: """A synthesiser rendering at ``sample_rate``, standing in for the output a caller supplies.""" return RowSynthesizer( @@ -54,7 +54,7 @@ def make_pulse_reconstruction( count: int = 1, held_features: Iterable[FeatureKey] = (), ) -> Reconstruction: - """Single-generator reconstruction with ``count`` identical PulseInstructions. + """Single-channel reconstruction with ``count`` identical PulseInstructions. ``held_features`` names the dimensions the instrument leaves to the channel, which is what an envelope cleared in the instruments panel produces. @@ -62,18 +62,18 @@ def make_pulse_reconstruction( instructions = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count reconstruction = Reconstruction.create( approximation=np.zeros(64, dtype=np.float32), - approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)}, - instructions={GeneratorName.PULSE1: instructions}, + approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)}, + instructions={ChannelName.PULSE1: instructions}, config=Config(), coefficient=1.0, audio_filepath=Path("/dev/null"), ) if held_features: - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, list(instructions), np.zeros(64, dtype=np.float32), - reconstruction.initial_pitches[GeneratorName.PULSE1], + reconstruction.initial_pitches[ChannelName.PULSE1], held_features, ) @@ -88,8 +88,8 @@ def make_triangle_reconstruction( instructions = [TriangleInstruction(on=True, pitch=pitch)] * count return Reconstruction.create( approximation=np.zeros(64, dtype=np.float32), - approximations={GeneratorName.TRIANGLE: np.zeros(64, dtype=np.float32)}, - instructions={GeneratorName.TRIANGLE: instructions}, + approximations={ChannelName.TRIANGLE: np.zeros(64, dtype=np.float32)}, + instructions={ChannelName.TRIANGLE: instructions}, config=Config(), coefficient=1.0, audio_filepath=Path("/dev/null"), @@ -105,8 +105,8 @@ def make_noise_reconstruction( instructions = [NoiseInstruction(on=True, period=period, volume=volume, short=False)] * count return Reconstruction.create( approximation=np.zeros(64, dtype=np.float32), - approximations={GeneratorName.NOISE: np.zeros(64, dtype=np.float32)}, - instructions={GeneratorName.NOISE: instructions}, + approximations={ChannelName.NOISE: np.zeros(64, dtype=np.float32)}, + instructions={ChannelName.NOISE: instructions}, config=Config(), coefficient=1.0, audio_filepath=Path("/dev/null"), @@ -129,18 +129,18 @@ def add_sample( def place_row( controller: ProjectController, *, - generator: GeneratorName, + channel: ChannelName, row_index: int = 0, sample_id: str, transpose: int | None = None, volume: int | None = None, ) -> None: - pattern_index = controller.project.song.order[0][generator] + pattern_index = controller.project.song.order[0][channel] controller.set_row( - generator, + channel, pattern_index, row_index, - command=Instrument(sample_id=sample_id, generator_name=generator), + command=Instrument(sample_id=sample_id, channel_name=channel), transpose=transpose, volume=volume, ) @@ -149,26 +149,26 @@ def place_row( def place_note_off( controller: ProjectController, *, - generator: GeneratorName, + channel: ChannelName, row_index: int, ) -> None: """Place an explicit note-off command on a channel row.""" - pattern_index = controller.project.song.order[0][generator] - controller.set_row(generator, pattern_index, row_index, command=NoteOff()) + pattern_index = controller.project.song.order[0][channel] + controller.set_row(channel, pattern_index, row_index, command=NoteOff()) def place_modifier_row( controller: ProjectController, *, - generator: GeneratorName, + channel: ChannelName, row_index: int, transpose: int | None = None, volume: int | None = None, ) -> None: """Place a row with only modifiers (no instrument).""" - pattern_index = controller.project.song.order[0][generator] + pattern_index = controller.project.song.order[0][channel] controller.update_row( - generator, + channel, pattern_index, row_index, transpose=transpose, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index dbb76e04e..4985c164b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -13,7 +13,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.reconstructions import Reconstruction @@ -40,12 +40,12 @@ class MaskProvider: __test__ = False def __init__(self) -> None: - self.active: FrozenSet[GeneratorName] = ALL_CHANNELS + self.active: FrozenSet[ChannelName] = ALL_CHANNELS - def mute(self, generator: GeneratorName) -> None: - self.active = ALL_CHANNELS - {generator} + def mute(self, channel: ChannelName) -> None: + self.active = ALL_CHANNELS - {channel} - def __call__(self) -> FrozenSet[GeneratorName]: + def __call__(self) -> FrozenSet[ChannelName]: return self.active @@ -75,9 +75,9 @@ def _controller(context: SynthesizerContext) -> ProjectController: def _state( context: SynthesizerContext, - generator: GeneratorName = GeneratorName.PULSE1, + channel: ChannelName = ChannelName.PULSE1, ): - return context.synthesizer._channels.state(generator) + return context.synthesizer._channels.state(channel) def _render(context: SynthesizerContext) -> np.ndarray: @@ -114,7 +114,7 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) @@ -145,7 +145,7 @@ def place_pulse_sample_with_modifiers(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, transpose=5, @@ -182,7 +182,7 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) @@ -227,14 +227,14 @@ def setup(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, volume=15, ) place_modifier_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=1, volume=0, ) @@ -275,14 +275,14 @@ def setup(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, transpose=0, ) place_modifier_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=1, transpose=7, ) @@ -326,13 +326,13 @@ def place_pulse_sample(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) def mute_pulse1(context: SynthesizerContext) -> None: - context.mask.mute(GeneratorName.PULSE1) + context.mask.mute(ChannelName.PULSE1) def render_and_compare_against_unmasked(context: SynthesizerContext) -> None: audio_masked = _render(context) @@ -368,13 +368,13 @@ def place_looping_pulse_sample(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon, loop=True) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) def mute_pulse1_and_render_row_0(context: SynthesizerContext) -> None: - context.mask.mute(GeneratorName.PULSE1) + context.mask.mute(ChannelName.PULSE1) assert np.allclose(_render(context), 0.0) assert _state(context).sample_id is not None @@ -491,11 +491,11 @@ def place_looped_sample_then_note_off(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon, loop=True) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) - place_note_off(_controller(context), generator=GeneratorName.PULSE1, row_index=1) + place_note_off(_controller(context), channel=ChannelName.PULSE1, row_index=1) def render_row_0_and_assert_audible(context: SynthesizerContext) -> None: assert not np.all(_render(context) == 0.0) @@ -532,7 +532,7 @@ def place_two_instruction_loop_sample(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon, loop=True) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) @@ -576,7 +576,7 @@ def place_one_instruction_non_loop_sample(context: SynthesizerContext) -> None: sample = add_sample(_controller(context), recon, loop=False) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) @@ -612,7 +612,7 @@ def place_loop_then_append_empty_frame(context: SynthesizerContext) -> None: sample = add_sample(controller, recon, loop=True) place_row( controller, - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id, ) @@ -650,8 +650,8 @@ class TestSilenceCases: def test_none_order_slot_with_no_sounding_voice_produces_silence(self) -> None: def clear_all_order_slots(context: SynthesizerContext) -> None: song = _controller(context).project.song - for generator_name in GeneratorName.items(): - song.set_order_entry(0, generator_name, None) + for channel_name in ChannelName.items(): + song.set_order_entry(0, channel_name, None) def render_and_assert_silence(context: SynthesizerContext) -> None: audio = _render(context) @@ -840,12 +840,12 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non controller = make_controller() recon = make_pulse_reconstruction(count=12) sample = add_sample(controller, recon) - place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + place_row(controller, channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) controller.set_nes_frequency(60) synthesizer.render_row() - pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) + pulse_state = synthesizer._channels.state(ChannelName.PULSE1) assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60) controller.set_nes_frequency(30) @@ -874,7 +874,7 @@ def _place( sample = add_sample(_controller(context), reconstruction, name=name) place_row( _controller(context), - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, row_index=row_index, sample_id=sample.id, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index 7d18bc324..3d217f3d5 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -8,7 +8,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.timing import Metre, RowRate, TickClock, calculate_groove from tests.suite.base import BaseTestSuite from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( @@ -225,7 +225,7 @@ def test_a_sounding_channel_fills_every_tick(self) -> None: controller = make_controller() reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) - place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + place_row(controller, channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() @@ -238,7 +238,7 @@ def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> N controller = make_controller() reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) - place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + place_row(controller, channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py index e7b1778eb..b8fc725f5 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py @@ -7,7 +7,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import SampleVoice from sampletones_core.configs import Config -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.instructions import ( @@ -32,35 +32,35 @@ def _reconstruction( - generator_name: GeneratorName, + channel_name: ChannelName, instructions: Sequence[InstructionUnion], held_features: Iterable[FeatureKey], ) -> Reconstruction: """A one-channel reconstruction whose instrument leaves ``held_features`` to the channel.""" reconstruction = Reconstruction.create( approximation=np.zeros(AUDIO_LENGTH, dtype=np.float32), - approximations={generator_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)}, - instructions={generator_name: list(instructions)}, + approximations={channel_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)}, + instructions={channel_name: list(instructions)}, config=Config(), coefficient=1.0, audio_filepath=Path("/dev/null"), ) - reconstruction.update_generator_data( - generator_name, + reconstruction.update_channel_data( + channel_name, list(instructions), np.ones(AUDIO_LENGTH, dtype=np.float32), - reconstruction.initial_pitches[generator_name], + reconstruction.initial_pitches[channel_name], held_features, ) return reconstruction def _voice( - generator_name: GeneratorName, + channel_name: ChannelName, instructions: Sequence[InstructionUnion], held_features: Iterable[FeatureKey], ) -> SampleVoice: - return SampleVoice.read(_reconstruction(generator_name, instructions, held_features), generator_name) + return SampleVoice.read(_reconstruction(channel_name, instructions, held_features), channel_name) def _channel_values() -> Dict[FeatureKey, int]: @@ -72,13 +72,13 @@ class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): - generator_name: GeneratorName + channel_name: ChannelName instructions: List[InstructionUnion] test_cases = ( TestCase( label="pulse", - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, instructions=[ PulseInstruction( on=True, @@ -90,12 +90,12 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="triangle", - generator_name=GeneratorName.TRIANGLE, + channel_name=ChannelName.TRIANGLE, instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], ), TestCase( label="noise", - generator_name=GeneratorName.NOISE, + channel_name=ChannelName.NOISE, instructions=[ NoiseInstruction( on=True, @@ -113,7 +113,7 @@ class TestCase(BaseRegularTestCase): ids=lambda test_case: test_case.label, ) def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None: - voice = _voice(test_case.generator_name, test_case.instructions, ()) + voice = _voice(test_case.channel_name, test_case.instructions, ()) assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0] @@ -123,7 +123,7 @@ def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None: ids=lambda test_case: test_case.label, ) def test_the_channel_takes_up_what_the_instrument_writes(self, test_case: TestCase) -> None: - voice = _voice(test_case.generator_name, test_case.instructions, ()) + voice = _voice(test_case.channel_name, test_case.instructions, ()) values = _channel_values() voice.sound(test_case.instructions[0], values) @@ -150,7 +150,7 @@ class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): - generator_name: GeneratorName + channel_name: ChannelName instruction: InstructionUnion held_feature: FeatureKey channel_value: int @@ -159,7 +159,7 @@ class TestCase(BaseRegularTestCase): test_cases = ( TestCase( label="pulse volume", - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, instruction=_INSTRUCTION, held_feature=FeatureKey.VOLUME, channel_value=CHANNEL_VOLUME, @@ -172,7 +172,7 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="pulse arpeggio", - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, instruction=_INSTRUCTION, held_feature=FeatureKey.ARPEGGIO, channel_value=CHANNEL_ARPEGGIO, @@ -185,7 +185,7 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="pulse duty cycle", - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, instruction=_INSTRUCTION, held_feature=FeatureKey.DUTY_CYCLE, channel_value=CHANNEL_DUTY_CYCLE, @@ -198,7 +198,7 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="triangle arpeggio", - generator_name=GeneratorName.TRIANGLE, + channel_name=ChannelName.TRIANGLE, instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH), held_feature=FeatureKey.ARPEGGIO, channel_value=CHANNEL_ARPEGGIO, @@ -206,7 +206,7 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="noise period", - generator_name=GeneratorName.NOISE, + channel_name=ChannelName.NOISE, instruction=NoiseInstruction( on=True, period=REFERENCE_PERIOD, @@ -224,7 +224,7 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="noise mode", - generator_name=GeneratorName.NOISE, + channel_name=ChannelName.NOISE, instruction=NoiseInstruction( on=True, period=REFERENCE_PERIOD, @@ -248,7 +248,7 @@ class TestCase(BaseRegularTestCase): ids=lambda test_case: test_case.label, ) def test_the_frame_sounds_the_channels_value_and_the_instruments_rest(self, test_case: TestCase) -> None: - voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,)) + voice = _voice(test_case.channel_name, [test_case.instruction], (test_case.held_feature,)) values = _channel_values() values[test_case.held_feature] = test_case.channel_value @@ -260,7 +260,7 @@ def test_the_frame_sounds_the_channels_value_and_the_instruments_rest(self, test ids=lambda test_case: test_case.label, ) def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_case: TestCase) -> None: - voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,)) + voice = _voice(test_case.channel_name, [test_case.instruction], (test_case.held_feature,)) values = _channel_values() values[test_case.held_feature] = test_case.channel_value @@ -270,8 +270,8 @@ def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_c def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None: """The channel carries a value across samples, which is what makes an empty envelope mean this.""" - writes = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], ()) - holds = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + writes = _voice(ChannelName.PULSE1, [self._INSTRUCTION], ()) + holds = _voice(ChannelName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) values = _channel_values() writes.sound(self._INSTRUCTION, values) @@ -281,13 +281,13 @@ def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None: def test_an_instrument_holding_its_level_sounds_a_silent_frame(self) -> None: """Silence is stated by a volume envelope, so an instrument leaving one out plays on.""" rest = PulseInstruction.null_instruction() - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], (FeatureKey.VOLUME,)) + voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], (FeatureKey.VOLUME,)) assert voice.sound(rest, _channel_values()).on is True def test_a_silent_frame_takes_the_channel_to_silence_where_the_instrument_writes_its_level(self) -> None: rest = PulseInstruction.null_instruction() - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ()) + voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], ()) values = _channel_values() assert voice.sound(rest, values).on is False @@ -295,7 +295,7 @@ def test_a_silent_frame_takes_the_channel_to_silence_where_the_instrument_writes def test_a_silent_frame_leaves_the_other_dimensions_where_the_channel_holds_them(self) -> None: rest = PulseInstruction.null_instruction() - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ()) + voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], ()) values = _channel_values() values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE diff --git a/tests/unit/sampletones_application/logic/sequencer/test_channels.py b/tests/unit/sampletones_application/logic/sequencer/test_channels.py index 965284fac..2863754d8 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_channels.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_channels.py @@ -10,23 +10,23 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.case import BaseTestCase Gesture = Callable[[SequencerChannelsLogic], None] -PULSE1 = GeneratorName.PULSE1 -PULSE2 = GeneratorName.PULSE2 -TRIANGLE = GeneratorName.TRIANGLE -NOISE = GeneratorName.NOISE +PULSE1 = ChannelName.PULSE1 +PULSE2 = ChannelName.PULSE2 +TRIANGLE = ChannelName.TRIANGLE +NOISE = ChannelName.NOISE -def toggle(generator: GeneratorName) -> Gesture: - return lambda logic: logic.toggle(generator) +def toggle(channel: ChannelName) -> Gesture: + return lambda logic: logic.toggle(channel) -def solo(generator: GeneratorName) -> Gesture: - return lambda logic: logic.solo(generator) +def solo(channel: ChannelName) -> Gesture: + return lambda logic: logic.solo(channel) def toggle_all() -> Gesture: @@ -49,7 +49,7 @@ def reset() -> Gesture: class GestureCase(BaseTestCase): label: str gestures: Tuple[Gesture, ...] - expected_muted: FrozenSet[GeneratorName] + expected_muted: FrozenSet[ChannelName] GESTURE_CASES = [ diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 1baec8650..7be6c8f36 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -25,7 +25,7 @@ HistoryDetailWord, HistoryDetailWordSegment, ) -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from tests.suite.sequencer import sample_reconstruction Pair = Tuple[str, HistoryDetailRole] @@ -53,11 +53,11 @@ def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]: class TestTrackerDetails: def test_edit_row_single_channel_places_sample(self) -> None: controller = _controller() - controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") - target = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="bass") + controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead") + target = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass") formatter = _formatter(controller) - segments = formatter.edit_row(10, GeneratorName.PULSE1, target.id, None, None) + segments = formatter.edit_row(10, ChannelName.PULSE1, target.id, None, None) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -70,7 +70,7 @@ def test_edit_row_single_channel_places_sample(self) -> None: def test_edit_row_sample_column_lists_the_samples_channels(self) -> None: controller = _controller() sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]), name="chord", ) formatter = _formatter(controller) @@ -89,7 +89,7 @@ def test_edit_row_transpose_shows_subcolumn_and_value(self) -> None: controller = _controller() formatter = _formatter(controller) - segments = formatter.edit_row(0, GeneratorName.TRIANGLE, None, 5, None) + segments = formatter.edit_row(0, ChannelName.TRIANGLE, None, 5, None) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -115,7 +115,7 @@ def test_clear_subcolumn_names_the_column(self) -> None: controller = _controller() formatter = _formatter(controller) - segments = formatter.clear_subcolumn(0, GeneratorName.NOISE, SubColumn.VOLUME) + segments = formatter.clear_subcolumn(0, ChannelName.NOISE, SubColumn.VOLUME) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -131,8 +131,8 @@ def test_a_block_reads_as_the_channels_and_the_rows_it_covers(self) -> None: TrackerRegion( first_row=4, last_row=11, - first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index, - last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.INSTRUMENT).flat_index, ) ) @@ -163,7 +163,7 @@ def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: formatter = _formatter(_controller()) - segments = formatter.tracker_paste(TrackerCell(row=3, generator=GeneratorName.NOISE)) + segments = formatter.tracker_paste(TrackerCell(row=3, channel=ChannelName.NOISE)) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -179,8 +179,8 @@ def test_adjust_transpose_shows_signed_delta(self) -> None: TrackerRegion( first_row=0, last_row=0, - first_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, - last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOLUME).flat_index, ), -3, ) @@ -201,8 +201,8 @@ def test_adjust_volume_reads_the_rows_it_covers(self) -> None: TrackerRegion( first_row=0, last_row=3, - first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, - last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOLUME).flat_index, ), -1, ) @@ -243,7 +243,7 @@ def test_move_frame_shows_source_and_destination(self) -> None: def test_set_order_entry_maps_channel_to_pattern(self) -> None: formatter = _formatter(_controller()) - assert _pairs(formatter.set_order_entry(GeneratorName.PULSE1, 1, 5)) == [ + assert _pairs(formatter.set_order_entry(ChannelName.PULSE1, 1, 5)) == [ ("01", HistoryDetailRole.FRAME), ("P", HistoryDetailRole.CHANNEL), (">", HistoryDetailRole.SEPARATOR), @@ -265,8 +265,8 @@ def test_a_block_reads_as_the_positions_and_the_channels_it_covers(self) -> None segments = formatter.order_block( OrderRegion( - first_row=CHANNEL_AXIS.index(GeneratorName.PULSE2), - last_row=CHANNEL_AXIS.index(GeneratorName.TRIANGLE), + first_row=CHANNEL_AXIS.index(ChannelName.PULSE2), + last_row=CHANNEL_AXIS.index(ChannelName.TRIANGLE), first_position=1, last_position=4, ) @@ -297,7 +297,7 @@ def test_a_block_reaching_the_master_row_reads_as_every_channel(self) -> None: def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: formatter = _formatter(_controller()) - segments = formatter.order_paste(OrderCell(generator=GeneratorName.NOISE, position=3)) + segments = formatter.order_paste(OrderCell(channel=ChannelName.NOISE, position=3)) assert _pairs(segments) == [ ("03", HistoryDetailRole.FRAME), @@ -313,7 +313,7 @@ def test_add_sample_shows_the_name(self) -> None: def test_remove_sample_shows_position_and_name(self) -> None: controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.remove_sample(sample.id)) == [ @@ -323,7 +323,7 @@ def test_remove_sample_shows_position_and_name(self) -> None: def test_replace_sample_shows_position_and_both_names(self) -> None: controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.replace_sample(sample.id, "Kick")) == [ @@ -344,7 +344,7 @@ def test_rename_sample_shows_old_and_new(self) -> None: def test_move_sample_shows_source_position_and_destination(self) -> None: controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.move_sample(sample.id, 5)) == [ @@ -355,7 +355,7 @@ def test_move_sample_shows_source_position_and_destination(self) -> None: def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None: controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) on_segments = formatter.set_sample_loop(sample.id, True) @@ -380,10 +380,10 @@ def test_value_wraps_a_number(self) -> None: class TestReconstructionDetails: def test_edit_reconstruction_names_position_channel_and_feature(self) -> None: controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead") formatter = _formatter(controller) - segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, FeatureKey.VOLUME) + segments = formatter.edit_reconstruction(sample.id, ChannelName.PULSE1, FeatureKey.VOLUME) assert _pairs(segments) == [ ("00:", HistoryDetailRole.SAMPLE), @@ -409,9 +409,9 @@ def test_every_feature_has_a_letter_and_a_colour_role( role: HistoryDetailRole, ) -> None: controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead") formatter = _formatter(controller) - segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, feature_key) + segments = formatter.edit_reconstruction(sample.id, ChannelName.PULSE1, feature_key) assert (segments[-1].text, segments[-1].role) == (letter, role) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index f0a23ecad..4bd89c7e7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -8,7 +8,7 @@ from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import reconstruction_footprints from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.reconstructions import Reconstruction @@ -46,15 +46,15 @@ def _logic_with_mocks() -> Tuple[ def _place_instrument( controller: ProjectController, - generator: GeneratorName, + channel: ChannelName, sample_id: str, ) -> None: - pattern_index = controller.project.song.order[0][generator] + pattern_index = controller.project.song.order[0][channel] controller.set_row( - generator, + channel, pattern_index, 0, - command=Instrument(sample_id=sample_id, generator_name=generator), + command=Instrument(sample_id=sample_id, channel_name=channel), ) @@ -83,7 +83,7 @@ def test_true_after_placing_in_a_pattern( ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - _place_instrument(controller, GeneratorName.PULSE1, sample.id) + _place_instrument(controller, ChannelName.PULSE1, sample.id) assert logic.is_sample_used(sample.id) is True @@ -104,7 +104,7 @@ def test_removing_used_sample_clears_its_references( ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - _place_instrument(controller, GeneratorName.PULSE1, sample.id) + _place_instrument(controller, ChannelName.PULSE1, sample.id) logic.remove_sample(sample.id) @@ -171,13 +171,13 @@ class TestBuildSampleFootprint: def test_it_names_each_playing_channel(self) -> None: controller, logic = _logic() - generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE) - sample = controller.add_sample(sample_reconstruction(generators), name="bell") + channels = (ChannelName.PULSE1, ChannelName.TRIANGLE) + sample = controller.add_sample(sample_reconstruction(channels), name="bell") footprint = logic.build_sample_footprint(sample.id) assert footprint is not None - assert [instrument.generator for instrument in footprint.instruments] == list(generators) + assert [instrument.channel for instrument in footprint.instruments] == list(channels) def test_it_measures_the_sample_under_its_own_loop_flag( self, @@ -215,13 +215,13 @@ def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: the same frame written on each costs the triangle the less. """ controller, logic = _logic() - generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE) - sample = controller.add_sample(sample_reconstruction(generators), name="bell") + channels = (ChannelName.PULSE1, ChannelName.TRIANGLE) + sample = controller.add_sample(sample_reconstruction(channels), name="bell") footprint = logic.build_sample_footprint(sample.id) assert footprint is not None - assert footprint.bytes_for(GeneratorName.TRIANGLE) < footprint.bytes_for(GeneratorName.PULSE1) + assert footprint.bytes_for(ChannelName.TRIANGLE) < footprint.bytes_for(ChannelName.PULSE1) def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: _, logic = _logic() diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py index 20da9fe32..ac0771b12 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py @@ -12,7 +12,7 @@ from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.sequencer import fill_frame, render_frame, sample_reconstruction @@ -43,7 +43,7 @@ def grid() -> Grid: logic = SequencerTrackerLogic(controller) logic.set_rows_per_pattern(FRAME_ROWS) lead = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]), name="lead", ) return Grid( @@ -55,8 +55,8 @@ def grid() -> Grid: def _region( - first: Tuple[Optional[GeneratorName], SubColumn], - last: Tuple[Optional[GeneratorName], SubColumn], + first: Tuple[Optional[ChannelName], SubColumn], + last: Tuple[Optional[ChannelName], SubColumn], *, first_row: int = 0, last_row: int = 0, @@ -88,8 +88,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="a cell alone shifts its own channel", region=_region( - (GeneratorName.PULSE1, SubColumn.INSTRUMENT), - (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.INSTRUMENT), ), delta=1, expected=( @@ -101,8 +101,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="a region standing on another subcolumn still shifts the transpose", region=_region( - (GeneratorName.TRIANGLE, SubColumn.VOLUME), - (GeneratorName.TRIANGLE, SubColumn.VOLUME), + (ChannelName.TRIANGLE, SubColumn.VOLUME), + (ChannelName.TRIANGLE, SubColumn.VOLUME), ), delta=-1, expected=( @@ -115,8 +115,8 @@ class TestCase(BaseRegularTestCase): label="a shift adds to the transpose a cell already holds", frame=(".. +02 . | .. ... . | .. ... . | .. ... .",), region=_region( - (GeneratorName.PULSE1, SubColumn.TRANSPOSE), - (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + (ChannelName.PULSE1, SubColumn.TRANSPOSE), + (ChannelName.PULSE1, SubColumn.TRANSPOSE), ), delta=12, expected=( @@ -128,8 +128,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="a region across columns shifts each of them", region=_region( - (GeneratorName.PULSE2, SubColumn.VOLUME), - (GeneratorName.NOISE, SubColumn.INSTRUMENT), + (ChannelName.PULSE2, SubColumn.VOLUME), + (ChannelName.NOISE, SubColumn.INSTRUMENT), ), delta=1, expected=( @@ -141,8 +141,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="a region across rows shifts each of them", region=_region( - (GeneratorName.PULSE1, SubColumn.INSTRUMENT), - (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.INSTRUMENT), first_row=1, last_row=2, ), @@ -185,7 +185,7 @@ class TestCase(BaseRegularTestCase): frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), region=_region( (None, SubColumn.INSTRUMENT), - (GeneratorName.PULSE1, SubColumn.VOLUME), + (ChannelName.PULSE1, SubColumn.VOLUME), ), delta=1, expected=( @@ -198,8 +198,8 @@ class TestCase(BaseRegularTestCase): label="a shift stops at the transpose range", frame=(".. +20 . | .. ... . | .. ... . | .. ... .",), region=_region( - (GeneratorName.PULSE1, SubColumn.TRANSPOSE), - (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + (ChannelName.PULSE1, SubColumn.TRANSPOSE), + (ChannelName.PULSE1, SubColumn.TRANSPOSE), ), delta=12, expected=( @@ -241,8 +241,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="an unset cell steps down from full", region=_region( - (GeneratorName.PULSE1, SubColumn.INSTRUMENT), - (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.INSTRUMENT), ), delta=-1, expected=( @@ -255,8 +255,8 @@ class TestCase(BaseRegularTestCase): label="a coarse step moves the whole region", frame=(".. ... 8 | .. ... 8 | .. ... . | .. ... .",), region=_region( - (GeneratorName.PULSE1, SubColumn.VOLUME), - (GeneratorName.PULSE2, SubColumn.VOLUME), + (ChannelName.PULSE1, SubColumn.VOLUME), + (ChannelName.PULSE2, SubColumn.VOLUME), ), delta=-4, expected=( @@ -269,8 +269,8 @@ class TestCase(BaseRegularTestCase): label="a shift stops at silence", frame=(".. ... 1 | .. ... . | .. ... . | .. ... .",), region=_region( - (GeneratorName.PULSE1, SubColumn.VOLUME), - (GeneratorName.PULSE1, SubColumn.VOLUME), + (ChannelName.PULSE1, SubColumn.VOLUME), + (ChannelName.PULSE1, SubColumn.VOLUME), ), delta=-4, expected=( @@ -284,7 +284,7 @@ class TestCase(BaseRegularTestCase): frame=(f"{LEAD} ... 8 | {LEAD} ... 8 | .. ... . | .. ... .",), region=_region( (None, SubColumn.INSTRUMENT), - (GeneratorName.PULSE1, SubColumn.VOLUME), + (ChannelName.PULSE1, SubColumn.VOLUME), ), delta=-1, expected=( diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py index f1518560b..f19c044dd 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -11,7 +11,7 @@ from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.note_off import NoteOff from tests.suite.sequencer import sample_reconstruction @@ -42,17 +42,17 @@ def reader(logic: SequencerTrackerLogic) -> TrackerBlockReader: return TrackerBlockReader(logic) -def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: - return TrackerSlot(generator, subcolumn).flat_index +def _slot(channel: Optional[ChannelName], subcolumn: SubColumn) -> int: + return TrackerSlot(channel, subcolumn).flat_index def _cell( row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], subcolumn: SubColumn, ) -> TrackerRegion: """The region one subcolumn of one cell covers.""" - slot = _slot(generator, subcolumn) + slot = _slot(channel, subcolumn) return TrackerRegion( first_row=row_index, last_row=row_index, @@ -62,7 +62,7 @@ def _cell( def _column( - generator: Optional[GeneratorName], + channel: Optional[ChannelName], *, last_row: int = 0, ) -> TrackerRegion: @@ -70,8 +70,8 @@ def _column( return TrackerRegion( first_row=0, last_row=last_row, - first_slot=_slot(generator, SubColumn.INSTRUMENT), - last_slot=_slot(generator, SubColumn.VOLUME), + first_slot=_slot(channel, SubColumn.INSTRUMENT), + last_slot=_slot(channel, SubColumn.VOLUME), ) @@ -85,13 +85,13 @@ def test_a_cell_carries_the_values_it_holds( reader: TrackerBlockReader, ) -> None: sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([ChannelName.PULSE1]), name="lead", ) - logic.place_note(0, GeneratorName.PULSE1, sample.id) - logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5, volume=3) + logic.place_note(0, ChannelName.PULSE1, sample.id) + logic.set_cell_subcolumn(0, ChannelName.PULSE1, transpose=5, volume=3) - block = reader.read(_column(GeneratorName.PULSE1)) + block = reader.read(_column(ChannelName.PULSE1)) assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id assert block.transposes[_key(SubColumn.TRANSPOSE)] == 5 @@ -102,7 +102,7 @@ def test_an_empty_cell_carries_its_emptiness( reader: TrackerBlockReader, ) -> None: """An untouched channel holds no pattern at all, which reads as the empty cell it shows.""" - block = reader.read(_column(GeneratorName.NOISE)) + block = reader.read(_column(ChannelName.NOISE)) assert block.notes[_key(SubColumn.INSTRUMENT)] is None assert block.transposes[_key(SubColumn.TRANSPOSE)] is None @@ -113,9 +113,9 @@ def test_a_cut_cell_carries_the_cut( logic: SequencerTrackerLogic, reader: TrackerBlockReader, ) -> None: - logic.cut_note(0, GeneratorName.PULSE1) + logic.cut_note(0, ChannelName.PULSE1) - block = reader.read(_cell(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) + block = reader.read(_cell(0, ChannelName.PULSE1, SubColumn.INSTRUMENT)) assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() @@ -125,9 +125,9 @@ def test_a_zero_transpose_carries_as_the_value_it_is( reader: TrackerBlockReader, ) -> None: """An explicit zero resets the channel's transpose, so it is a value and not an absence.""" - logic.set_cell_subcolumn(0, GeneratorName.PULSE2, transpose=0) + logic.set_cell_subcolumn(0, ChannelName.PULSE2, transpose=0) - block = reader.read(_cell(0, GeneratorName.PULSE2, SubColumn.TRANSPOSE)) + block = reader.read(_cell(0, ChannelName.PULSE2, SubColumn.TRANSPOSE)) assert block.transposes[_key(SubColumn.TRANSPOSE)] == 0 @@ -138,9 +138,9 @@ def test_rows_past_the_pattern_read_empty( ) -> None: """A region reaching past the rows a pattern holds takes emptiness from beyond its end.""" logic.set_rows_per_pattern(2) - logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=4) + logic.set_cell_subcolumn(0, ChannelName.PULSE1, volume=4) - block = reader.read(_column(GeneratorName.PULSE1, last_row=3)) + block = reader.read(_column(ChannelName.PULSE1, last_row=3)) assert block.volumes[_key(SubColumn.VOLUME)] == 4 assert block.volumes[_key(SubColumn.VOLUME, 2)] is None @@ -157,7 +157,7 @@ def test_a_value_every_governed_channel_shares_carries_over( reader: TrackerBlockReader, ) -> None: sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.place_note(0, None, sample.id) @@ -176,7 +176,7 @@ def test_a_note_carries_as_the_sample_it_names( ) -> None: """The channels hold instruments of their own, and the block keeps the sample they share.""" sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]), name="chord", ) logic.place_note(0, None, sample.id) @@ -191,7 +191,7 @@ def test_a_column_its_channels_disagree_over_leaves_its_key_out( reader: TrackerBlockReader, ) -> None: """No sample governs the row, so the column spans every channel and only one holds a value.""" - logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5) + logic.set_cell_subcolumn(0, ChannelName.PULSE1, transpose=5) block = reader.read(_cell(0, None, SubColumn.TRANSPOSE)) @@ -202,7 +202,7 @@ def test_a_half_cut_row_leaves_its_note_out( logic: SequencerTrackerLogic, reader: TrackerBlockReader, ) -> None: - logic.cut_note(0, GeneratorName.PULSE1) + logic.cut_note(0, ChannelName.PULSE1) block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) @@ -240,7 +240,7 @@ def test_a_mixed_edge_column_leaves_only_itself_out( reader: TrackerBlockReader, ) -> None: """The last slot reads as nothing, and the cells beside it keep the offsets they stand at.""" - logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2) + logic.set_cell_subcolumn(0, ChannelName.PULSE1, volume=2) block = reader.read( TrackerRegion( @@ -268,8 +268,8 @@ def test_the_offsets_are_measured_from_the_column_the_block_begins_in( TrackerRegion( first_row=0, last_row=0, - first_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE), - last_slot=_slot(GeneratorName.TRIANGLE, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE2, SubColumn.TRANSPOSE), + last_slot=_slot(ChannelName.TRIANGLE, SubColumn.INSTRUMENT), ) ) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index bee5646f1..d853d3260 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -2,7 +2,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff @@ -17,25 +17,25 @@ def _controller() -> ProjectController: def _row( controller: ProjectController, - generator: GeneratorName, + channel: ChannelName, row_index: int = 0, ) -> Row: song = controller.project.song - pattern_index = song.order[0][generator] - return song[generator].get_row(pattern_index, row_index) + pattern_index = song.order[0][channel] + return song[channel].get_row(pattern_index, row_index) def _place_instrument( controller: ProjectController, - generator: GeneratorName, + channel: ChannelName, sample_id: str, ) -> None: - pattern_index = controller.project.song.order[0][generator] + pattern_index = controller.project.song.order[0][channel] controller.set_row( - generator, + channel, pattern_index, 0, - command=Instrument(sample_id=sample_id, generator_name=generator), + command=Instrument(sample_id=sample_id, channel_name=channel), ) @@ -43,13 +43,13 @@ class TestClearCell: def test_a_channel_cell_clears_only_that_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_row(GeneratorName.PULSE1, 0, transpose=5) - logic.set_row(GeneratorName.PULSE2, 0, transpose=7) + logic.set_row(ChannelName.PULSE1, 0, transpose=5) + logic.set_row(ChannelName.PULSE2, 0, transpose=7) - logic.clear_cell(0, GeneratorName.PULSE1) + logic.clear_cell(0, ChannelName.PULSE1) - assert _row(controller, GeneratorName.PULSE1).transpose is None - assert _row(controller, GeneratorName.PULSE2).transpose == 7 + assert _row(controller, ChannelName.PULSE1).transpose is None + assert _row(controller, ChannelName.PULSE2).transpose == 7 def test_the_sample_column_clears_every_channel(self) -> None: controller = _controller() @@ -58,19 +58,19 @@ def test_the_sample_column_clears_every_channel(self) -> None: logic.clear_cell(0, None) - for generator in GeneratorName.items(): - assert _row(controller, generator).transpose is None + for channel in ChannelName.items(): + assert _row(controller, channel).transpose is None class TestClearCellSubcolumn: def test_a_channel_cell_clears_one_subcolumn_of_its_own(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_row(GeneratorName.PULSE1, 0, transpose=5, volume=10) + logic.set_row(ChannelName.PULSE1, 0, transpose=5, volume=10) - logic.clear_cell_subcolumn(0, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + logic.clear_cell_subcolumn(0, ChannelName.PULSE1, SubColumn.TRANSPOSE) - row = _row(controller, GeneratorName.PULSE1) + row = _row(controller, ChannelName.PULSE1) assert row.transpose is None assert row.volume == 10 @@ -78,35 +78,35 @@ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) - logic.set_note_off(GeneratorName.NOISE, 0) + logic.set_note_off(ChannelName.NOISE, 0) logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT) - for generator in GeneratorName.items(): - assert _row(controller, generator).command is None + for channel in ChannelName.items(): + assert _row(controller, channel).command is None def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) - for generator in GeneratorName.items(): - logic.set_row(generator, 0, transpose=5) + for channel in ChannelName.items(): + logic.set_row(channel, 0, transpose=5) logic.clear_cell_subcolumn(0, None, SubColumn.TRANSPOSE) - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).transpose is None + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + assert _row(controller, channel).transpose is None - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).transpose == 5 + for channel in (ChannelName.PULSE2, ChannelName.NOISE): + assert _row(controller, channel).transpose == 5 class TestWriteCell: @@ -114,34 +114,34 @@ def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.write_cell(0, None, sample.id, None, None) - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert isinstance(_row(controller, generator).command, Instrument) + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + assert isinstance(_row(controller, channel).command, Instrument) - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).command is None + for channel in (ChannelName.PULSE2, ChannelName.NOISE): + assert _row(controller, channel).command is None def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: """A cell re-targets the sample onto its own channel, whichever channels the sample covers.""" controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([ChannelName.PULSE1]), name="lead", ) - logic.write_cell(0, GeneratorName.NOISE, sample.id, None, None) + logic.write_cell(0, ChannelName.NOISE, sample.id, None, None) - command = _row(controller, GeneratorName.NOISE).command + command = _row(controller, ChannelName.NOISE).command assert isinstance(command, Instrument) assert command.sample_id == sample.id - assert command.generator_name == GeneratorName.NOISE - assert _row(controller, GeneratorName.PULSE1).command is None + assert command.channel_name == ChannelName.NOISE + assert _row(controller, ChannelName.PULSE1).command is None def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None: controller = _controller() @@ -149,17 +149,17 @@ def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None: logic.write_cell(0, None, None, 5, None) - for generator in GeneratorName.items(): - assert _row(controller, generator).transpose == 5 + for channel in ChannelName.items(): + assert _row(controller, channel).transpose == 5 def test_a_volume_in_a_channel_cell_leaves_the_rest_of_the_cell_standing(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + logic.set_row(ChannelName.PULSE1, 0, transpose=5) - logic.write_cell(0, GeneratorName.PULSE1, None, None, 10) + logic.write_cell(0, ChannelName.PULSE1, None, None, 10) - row = _row(controller, GeneratorName.PULSE1) + row = _row(controller, ChannelName.PULSE1) assert row.transpose == 5 assert row.volume == 10 @@ -170,9 +170,9 @@ def test_an_edit_carrying_no_value_leaves_the_frame_alone(self) -> None: controller.append_frame() logic.select_frame(1) - logic.write_cell(0, GeneratorName.PULSE1, None, None, None) + logic.write_cell(0, ChannelName.PULSE1, None, None, None) - assert controller.project.song.order[1][GeneratorName.PULSE1] is None + assert controller.project.song.order[1][ChannelName.PULSE1] is None class TestCutNote: @@ -180,10 +180,10 @@ def test_a_channel_cell_cuts_that_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.cut_note(0, GeneratorName.PULSE1) + logic.cut_note(0, ChannelName.PULSE1) - assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff) - assert _row(controller, GeneratorName.PULSE2).command is None + assert isinstance(_row(controller, ChannelName.PULSE1).command, NoteOff) + assert _row(controller, ChannelName.PULSE2).command is None def test_the_sample_column_cuts_every_channel(self) -> None: controller = _controller() @@ -191,8 +191,8 @@ def test_the_sample_column_cuts_every_channel(self) -> None: logic.cut_note(0, None) - for generator in GeneratorName.items(): - assert isinstance(_row(controller, generator).command, NoteOff) + for channel in ChannelName.items(): + assert isinstance(_row(controller, channel).command, NoteOff) class TestFrameRowCount: @@ -222,9 +222,9 @@ class TestRowAccess: def test_reads_the_stored_row(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + logic.set_row(ChannelName.PULSE1, 0, transpose=5) - row = logic.row(GeneratorName.PULSE1, 0) + row = logic.row(ChannelName.PULSE1, 0) assert row is not None assert row.transpose == 5 @@ -235,33 +235,33 @@ def test_a_channel_without_a_pattern_has_no_row(self) -> None: controller.append_frame() logic.select_frame(1) - assert logic.row(GeneratorName.PULSE1, 0) is None + assert logic.row(ChannelName.PULSE1, 0) is None -class TestReferencedGenerators: +class TestReferencedChannels: def test_one_placement_reports_the_samples_whole_span(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - _place_instrument(controller, GeneratorName.PULSE1, sample.id) + _place_instrument(controller, ChannelName.PULSE1, sample.id) - assert logic.referenced_generators(0) == frozenset( + assert logic.referenced_channels(0) == frozenset( { - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, } ) def test_a_row_naming_no_sample_references_no_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_note_off(GeneratorName.PULSE1, 0) + logic.set_note_off(ChannelName.PULSE1, 0) - assert logic.referenced_generators(0) == frozenset() - assert logic.relevant_generators(0) == GeneratorName.items() + assert logic.referenced_channels(0) == frozenset() + assert logic.relevant_channels(0) == ChannelName.items() class TestSetNoteOff: @@ -269,10 +269,10 @@ def test_set_note_off_writes_note_off_command(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_note_off(GeneratorName.PULSE1, 0) + logic.set_note_off(ChannelName.PULSE1, 0) assert isinstance( - _row(controller, GeneratorName.PULSE1).command, + _row(controller, ChannelName.PULSE1).command, NoteOff, ) @@ -282,8 +282,8 @@ def test_set_note_off_all_generators_cuts_every_channel(self) -> None: logic.set_note_off_all_generators(0) - for generator in GeneratorName.items(): - assert isinstance(_row(controller, generator).command, NoteOff) + for channel in ChannelName.items(): + assert isinstance(_row(controller, channel).command, NoteOff) class TestSetSampleInstrument: @@ -291,48 +291,48 @@ def test_fills_only_used_generators(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - command = _row(controller, generator).command + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + command = _row(controller, channel).command assert isinstance(command, Instrument) assert command.sample_id == sample.id - assert command.generator_name == generator + assert command.channel_name == channel - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).command is None + for channel in (ChannelName.PULSE2, ChannelName.NOISE): + assert _row(controller, channel).command is None def test_clears_channels_the_new_sample_does_not_use(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) stale = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE2]), + sample_reconstruction([ChannelName.PULSE2]), name="bass", ) - pattern_index = controller.project.song.order[0][GeneratorName.PULSE2] + pattern_index = controller.project.song.order[0][ChannelName.PULSE2] controller.set_row( - GeneratorName.PULSE2, + ChannelName.PULSE2, pattern_index, 0, command=Instrument( sample_id=stale.id, - generator_name=GeneratorName.PULSE2, + channel_name=ChannelName.PULSE2, ), volume=15, ) lead = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([ChannelName.PULSE1]), name="lead", ) logic.set_sample_instrument(0, lead.id) - assert _row(controller, GeneratorName.PULSE1).command is not None - cleared = _row(controller, GeneratorName.PULSE2) + assert _row(controller, ChannelName.PULSE1).command is not None + cleared = _row(controller, ChannelName.PULSE2) assert cleared.command is None assert cleared.volume is None @@ -340,15 +340,15 @@ def test_none_sample_clears_the_whole_row(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([ChannelName.PULSE1]), name="lead", ) logic.set_sample_instrument(0, sample.id) logic.set_sample_instrument(0, None) - for generator in GeneratorName.items(): - assert _row(controller, generator).command is None + for channel in ChannelName.items(): + assert _row(controller, channel).command is None class TestSampleSubcolumn: @@ -358,26 +358,26 @@ def test_synchronises_across_relevant_channels_even_without_instrument( controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - _place_instrument(controller, GeneratorName.PULSE1, sample.id) + _place_instrument(controller, ChannelName.PULSE1, sample.id) logic.set_sample_subcolumn(0, transpose=5) logic.set_sample_subcolumn(0, volume=10) - carrier = _row(controller, GeneratorName.PULSE1) + carrier = _row(controller, ChannelName.PULSE1) assert carrier.command is not None assert carrier.transpose == 5 assert carrier.volume == 10 - synced = _row(controller, GeneratorName.TRIANGLE) + synced = _row(controller, ChannelName.TRIANGLE) assert synced.command is None assert synced.transpose == 5 assert synced.volume == 10 - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - row = _row(controller, generator) + for channel in (ChannelName.PULSE2, ChannelName.NOISE): + row = _row(controller, channel) assert row.transpose is None assert row.volume is None @@ -389,8 +389,8 @@ def test_synchronises_across_all_channels_when_no_sample_is_referenced( logic.set_sample_subcolumn(0, transpose=5, volume=10) - for generator in GeneratorName.items(): - row = _row(controller, generator) + for channel in ChannelName.items(): + row = _row(controller, channel) assert row.command is None assert row.transpose == 5 assert row.volume == 10 @@ -399,7 +399,7 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -408,8 +408,8 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: logic.clear_sample_subcolumn(0, transpose=True) - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - row = _row(controller, generator) + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + row = _row(controller, channel) assert row.transpose is None assert row.volume == 10 assert row.command is not None @@ -420,38 +420,38 @@ def test_first_nudge_writes_the_delta_from_zero(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.adjust_transpose(GeneratorName.PULSE1, 0, 1) + logic.adjust_transpose(ChannelName.PULSE1, 0, 1) - assert _row(controller, GeneratorName.PULSE1).transpose == 1 + assert _row(controller, ChannelName.PULSE1).transpose == 1 def test_repeated_nudges_accumulate(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.adjust_transpose(GeneratorName.PULSE1, 0, 1) - logic.adjust_transpose(GeneratorName.PULSE1, 0, 12) + logic.adjust_transpose(ChannelName.PULSE1, 0, 1) + logic.adjust_transpose(ChannelName.PULSE1, 0, 12) - assert _row(controller, GeneratorName.PULSE1).transpose == 13 + assert _row(controller, ChannelName.PULSE1).transpose == 13 def test_clamps_to_max_transpose(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_row(GeneratorName.PULSE1, 0, transpose=MAX_TRANSPOSE) + logic.set_row(ChannelName.PULSE1, 0, transpose=MAX_TRANSPOSE) - logic.adjust_transpose(GeneratorName.PULSE1, 0, 12) + logic.adjust_transpose(ChannelName.PULSE1, 0, 12) - assert _row(controller, GeneratorName.PULSE1).transpose == MAX_TRANSPOSE + assert _row(controller, ChannelName.PULSE1).transpose == MAX_TRANSPOSE def test_preserves_instrument_and_volume(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") - _place_instrument(controller, GeneratorName.PULSE1, sample.id) - logic.adjust_volume(GeneratorName.PULSE1, 0, -1) + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead") + _place_instrument(controller, ChannelName.PULSE1, sample.id) + logic.adjust_volume(ChannelName.PULSE1, 0, -1) - logic.adjust_transpose(GeneratorName.PULSE1, 0, 2) + logic.adjust_transpose(ChannelName.PULSE1, 0, 2) - row = _row(controller, GeneratorName.PULSE1) + row = _row(controller, ChannelName.PULSE1) assert row.command is not None assert row.transpose == 2 assert row.volume == MAX_VOLUME - 1 @@ -462,26 +462,26 @@ def test_unset_volume_steps_down_from_full(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.adjust_volume(GeneratorName.PULSE1, 0, -1) + logic.adjust_volume(ChannelName.PULSE1, 0, -1) - assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1 + assert _row(controller, ChannelName.PULSE1).volume == MAX_VOLUME - 1 def test_unset_volume_up_stays_full(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.adjust_volume(GeneratorName.PULSE1, 0, 1) + logic.adjust_volume(ChannelName.PULSE1, 0, 1) - assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME + assert _row(controller, ChannelName.PULSE1).volume == MAX_VOLUME def test_clamps_to_zero(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_row(GeneratorName.PULSE1, 0, volume=1) + logic.set_row(ChannelName.PULSE1, 0, volume=1) - logic.adjust_volume(GeneratorName.PULSE1, 0, -4) + logic.adjust_volume(ChannelName.PULSE1, 0, -4) - assert _row(controller, GeneratorName.PULSE1).volume == 0 + assert _row(controller, ChannelName.PULSE1).volume == 0 class TestBuildTrackerAggregation: @@ -489,10 +489,10 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - _place_instrument(controller, GeneratorName.PULSE1, sample.id) + _place_instrument(controller, ChannelName.PULSE1, sample.id) row = logic.build_grid().rows[0] @@ -502,25 +502,25 @@ def test_full_placement_reads_as_the_sample(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) row = logic.build_grid().rows[0] - assert row.sample_instrument == row.cells[GeneratorName.PULSE1].instrument + assert row.sample_instrument == row.cells[ChannelName.PULSE1].instrument assert row.sample_instrument != MIXED def test_diverging_transpose_renders_as_mixed(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) - logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + logic.set_row(ChannelName.PULSE1, 0, transpose=5) row = logic.build_grid().rows[0] @@ -530,7 +530,7 @@ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -538,7 +538,7 @@ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: row = logic.build_grid().rows[0] - assert row.sample_transpose == row.cells[GeneratorName.PULSE1].transpose + assert row.sample_transpose == row.cells[ChannelName.PULSE1].transpose assert row.sample_transpose != MIXED @@ -552,13 +552,13 @@ def test_editing_an_empty_slot_creates_and_assigns_a_pattern(self) -> None: self._append_empty_frame(controller) logic.select_frame(1) - logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + logic.set_row(ChannelName.PULSE1, 0, transpose=5) song = controller.project.song - new_index = song.order[1][GeneratorName.PULSE1] + new_index = song.order[1][ChannelName.PULSE1] assert new_index is not None - assert song[GeneratorName.PULSE1].get_row(new_index, 0).transpose == 5 - assert song.order[1][GeneratorName.PULSE2] is None + assert song[ChannelName.PULSE1].get_row(new_index, 0).transpose == 5 + assert song.order[1][ChannelName.PULSE2] is None def test_empty_frame_still_shows_editable_rows(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py index 349dc4600..d1a02d5a5 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -13,7 +13,7 @@ from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.sequencer import ( @@ -53,11 +53,11 @@ def grid() -> Grid: logic = SequencerTrackerLogic(controller) logic.set_rows_per_pattern(FRAME_ROWS) lead = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]), name="lead", ) bass = controller.add_sample( - sample_reconstruction([GeneratorName.TRIANGLE]), + sample_reconstruction([ChannelName.TRIANGLE]), name="bass", ) return Grid( @@ -89,7 +89,7 @@ class TestCase(BaseRegularTestCase): label="a block keeps its own kinds wherever the cursor stands", block=("+02 8",), first_subcolumn=SubColumn.TRANSPOSE, - origin=TrackerCell(row=1, generator=GeneratorName.PULSE2), + origin=TrackerCell(row=1, channel=ChannelName.PULSE2), expected=( EMPTY, ".. ... . | .. +02 8 | .. ... . | .. ... .", @@ -102,7 +102,7 @@ class TestCase(BaseRegularTestCase): frame=(".. ... . | .. ... . | .. ... . | .. ... 5",), block=(LEAD,), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=( "00 ... . | 00 ... . | .. ... . | .. ... .", EMPTY, @@ -114,7 +114,7 @@ class TestCase(BaseRegularTestCase): label="a channel beside the sample column overwrites what it settled", block=(f"{LEAD} ... . | {BASS}",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=( "01 ... . | 00 ... . | .. ... . | .. ... .", EMPTY, @@ -126,7 +126,7 @@ class TestCase(BaseRegularTestCase): label="a block read from the sample column writes one channel when written to one", block=(LEAD,), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=GeneratorName.TRIANGLE), + origin=TrackerCell(row=0, channel=ChannelName.TRIANGLE), expected=( ".. ... . | .. ... . | 00 ... . | .. ... .", EMPTY, @@ -139,7 +139,7 @@ class TestCase(BaseRegularTestCase): frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",), block=(".. ? .",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( ".. +03 . | .. ... . | .. ... . | .. ... .", EMPTY, @@ -152,7 +152,7 @@ class TestCase(BaseRegularTestCase): frame=(".. +03 . | .. +05 . | .. ... . | .. ... .",), block=("+00 ? | ? ...",), first_subcolumn=SubColumn.TRANSPOSE, - origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( ".. +00 . | .. ... . | .. ... . | .. ... .", EMPTY, @@ -165,7 +165,7 @@ class TestCase(BaseRegularTestCase): frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), block=("~~",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=( "~~ ... . | ~~ ... . | ~~ ... . | ~~ ... .", EMPTY, @@ -178,7 +178,7 @@ class TestCase(BaseRegularTestCase): frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), block=("!! ? ?",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( "00 +02 5 | .. ... . | .. ... . | .. ... .", EMPTY, @@ -191,7 +191,7 @@ class TestCase(BaseRegularTestCase): frame=("00 ... . | 00 ... . | .. ... . | .. ... 5",), block=("!!",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=( "00 ... . | 00 ... . | .. ... . | .. ... 5", EMPTY, @@ -204,7 +204,7 @@ class TestCase(BaseRegularTestCase): frame=("00 ... . | 00 ... . | .. ... . | ~~ ... .",), block=("..",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=(EMPTY, EMPTY, EMPTY, EMPTY), ), TestCase( @@ -212,7 +212,7 @@ class TestCase(BaseRegularTestCase): frame=(".. +02 . | .. +02 . | .. +02 . | .. +02 .",), block=("...",), first_subcolumn=SubColumn.TRANSPOSE, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=(EMPTY, EMPTY, EMPTY, EMPTY), ), TestCase( @@ -220,7 +220,7 @@ class TestCase(BaseRegularTestCase): frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), block=("+02",), first_subcolumn=SubColumn.TRANSPOSE, - origin=TrackerCell(row=0, generator=None), + origin=TrackerCell(row=0, channel=None), expected=( "00 +02 . | 00 +02 . | .. ... . | .. ... .", EMPTY, @@ -232,7 +232,7 @@ class TestCase(BaseRegularTestCase): label="a silent volume writes zero rather than emptiness", block=("0",), first_subcolumn=SubColumn.VOLUME, - origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( ".. ... 0 | .. ... . | .. ... . | .. ... .", EMPTY, @@ -244,7 +244,7 @@ class TestCase(BaseRegularTestCase): label="rows past the frame's last are dropped rather than wrapped", block=("+01", "+02", "+03"), first_subcolumn=SubColumn.TRANSPOSE, - origin=TrackerCell(row=2, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=2, channel=ChannelName.PULSE1), expected=( EMPTY, EMPTY, @@ -256,7 +256,7 @@ class TestCase(BaseRegularTestCase): label="slots past the last column are dropped rather than wrapped", block=(f"{LEAD} ... . | {BASS}",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=GeneratorName.NOISE), + origin=TrackerCell(row=0, channel=ChannelName.NOISE), expected=( ".. ... . | .. ... . | .. ... . | 00 ... .", EMPTY, @@ -269,7 +269,7 @@ class TestCase(BaseRegularTestCase): frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), block=("? ? ?",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( "00 +02 5 | .. ... . | .. ... . | .. ... .", EMPTY, @@ -282,7 +282,7 @@ class TestCase(BaseRegularTestCase): frame=("00 +02 5 | 00 +02 5 | .. ... . | .. ... .",), block=(".. ... .",), first_subcolumn=SubColumn.INSTRUMENT, - origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( ".. ... . | 00 +02 5 | .. ... . | .. ... .", EMPTY, @@ -324,11 +324,11 @@ def test_a_single_cell_block_matches_the_edit_it_stands_for(self, grid: Grid) -> first_subcolumn=SubColumn.TRANSPOSE, sample_ids=grid.sample_ids, ) - grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1)) + grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE1)) pasted = render_frame(grid.logic) typed = _typed_grid() - typed.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=2) + typed.set_cell_subcolumn(0, ChannelName.PULSE1, transpose=2) assert pasted == render_frame(typed) @@ -347,8 +347,8 @@ def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None: TrackerRegion( first_row=0, last_row=0, - first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index, - last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.INSTRUMENT).flat_index, ) ) @@ -389,13 +389,13 @@ def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) region = TrackerRegion( first_row=0, last_row=1, - first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, - last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index, ) block = TrackerBlockReader(grid.logic).read(region) grid.writer.clear(region) - grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1)) + grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE1)) assert render_frame(grid.logic) == before @@ -414,7 +414,7 @@ def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Gr first_subcolumn=SubColumn.TRANSPOSE, sample_ids=grid.sample_ids, ) - grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2)) + grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2)) assert render_slots(grid.controller, position) == ".. 01 .. .." assert render_frame(grid.logic)[0] == ".. ... . | .. +02 . | .. ... . | .. ... ." @@ -429,7 +429,7 @@ def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid: first_subcolumn=SubColumn.INSTRUMENT, sample_ids=grid.sample_ids, ) - grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2)) + grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2)) assert render_slots(grid.controller, position) == ".. .. .. .." diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 664aaba72..aed169b37 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -9,7 +9,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.project.project import Project @@ -89,7 +89,7 @@ def _write( def build_instrument(name: str = "Lead") -> InstrumentExport: return InstrumentExport( name=name, - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, features=Features( initial_pitch=60, volume=np.full(8, 15, dtype=int), diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index ede8ea039..c0a8ac868 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -12,7 +12,7 @@ ServiceError, ServiceSuccess, ) -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction from tests.conftest import ReconstructionFactory @@ -63,18 +63,18 @@ def synthesis_mocks() -> Iterator[SynthesisMocks]: mock_exporter.get_generator_type.return_value = mock_generator_class mock_exporter.from_features.return_value = [mock_instruction] - generator_name = GeneratorName.PULSE1 + channel_name = ChannelName.PULSE1 with patch( - "sampletones_application.services.regeneration.GENERATOR_NAME_TO_EXPORTER_MAP", - {generator_name: mock_exporter}, + "sampletones_application.services.regeneration.CHANNEL_TO_EXPORTER_MAP", + {channel_name: mock_exporter}, ): yield SimpleNamespace( exporter=mock_exporter, generator_class=mock_generator_class, generator=mock_generator, instruction=mock_instruction, - generator_name=generator_name, + channel_name=channel_name, ) @@ -92,7 +92,7 @@ def test_start_when_not_cancelled_returns_true( service = RegenerationService() result = service.start( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, {}), FeatureKey.VOLUME, 1, @@ -192,7 +192,7 @@ def test_run_success_emits_service_success( service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), FeatureKey.VOLUME, 1, @@ -203,7 +203,7 @@ def test_run_success_emits_service_success( outcome = results[0].value assert outcome.reconstruction is reconstruction.model_copy.return_value assert outcome.reconstruction is not reconstruction - assert outcome.generator_name is synthesis_mocks.generator_name + assert outcome.channel_name is synthesis_mocks.channel_name assert outcome.feature_key is FeatureKey.VOLUME def test_run_updates_feature_before_synthesis( @@ -218,7 +218,7 @@ def test_run_updates_feature_before_synthesis( service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), feature_key, new_value, @@ -236,17 +236,17 @@ def test_run_updates_reconstruction_copy( service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), FeatureKey.VOLUME, 1, ) updated = reconstruction.model_copy.return_value - updated.update_generator_data.assert_called_once() - reconstruction.update_generator_data.assert_not_called() - call_args = updated.update_generator_data.call_args - assert call_args.args[0] == synthesis_mocks.generator_name + updated.update_channel_data.assert_called_once() + reconstruction.update_channel_data.assert_not_called() + call_args = updated.update_channel_data.call_args + assert call_args.args[0] == synthesis_mocks.channel_name def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( self, @@ -263,13 +263,13 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), FeatureKey.ARPEGGIO, np.array([12, 0], dtype=np.int8), ) - call_args = reconstruction.model_copy.return_value.update_generator_data.call_args + call_args = reconstruction.model_copy.return_value.update_channel_data.call_args assert call_args.args[3] == REFERENCE_PITCH def test_run_carries_a_moved_reference_pitch( @@ -284,13 +284,13 @@ def test_run_carries_a_moved_reference_pitch( service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), FeatureKey.INITIAL_PITCH, moved_pitch, ) - call_args = reconstruction.model_copy.return_value.update_generator_data.call_args + call_args = reconstruction.model_copy.return_value.update_channel_data.call_args assert call_args.args[3] == moved_pitch def test_run_calls_generator_for_each_instruction( @@ -308,7 +308,7 @@ def test_run_calls_generator_for_each_instruction( service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), FeatureKey.VOLUME, 1, @@ -329,12 +329,12 @@ def test_run_exception_emits_service_error( mock_exporter.get_generator_type.side_effect = exception with patch( - "sampletones_application.services.regeneration.GENERATOR_NAME_TO_EXPORTER_MAP", - {GeneratorName.PULSE1: mock_exporter}, + "sampletones_application.services.regeneration.CHANNEL_TO_EXPORTER_MAP", + {ChannelName.PULSE1: mock_exporter}, ): service._run( reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, cast(Features, {}), FeatureKey.VOLUME, 1, @@ -354,18 +354,18 @@ def test_run_exception_does_not_update_reconstruction( mock_exporter.get_generator_type.side_effect = RuntimeError("fail") with patch( - "sampletones_application.services.regeneration.GENERATOR_NAME_TO_EXPORTER_MAP", - {GeneratorName.PULSE1: mock_exporter}, + "sampletones_application.services.regeneration.CHANNEL_TO_EXPORTER_MAP", + {ChannelName.PULSE1: mock_exporter}, ): service._run( reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, cast(Features, {}), FeatureKey.VOLUME, 1, ) - reconstruction.update_generator_data.assert_not_called() + reconstruction.update_channel_data.assert_not_called() class TestClearingEveryEnvelope: @@ -379,7 +379,7 @@ class TestClearingEveryEnvelope: @staticmethod def _regenerated(reconstruction: Reconstruction) -> Reconstruction: """The reconstruction the service returns once every dimension is left to the channel.""" - features = reconstruction.export()[GeneratorName.PULSE1] + features = reconstruction.export()[ChannelName.PULSE1] features.leave_to_channel([FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE]) service = RegenerationService() results: List[Any] = [] @@ -387,7 +387,7 @@ def _regenerated(reconstruction: Reconstruction) -> Reconstruction: service._run( reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, features, FeatureKey.VOLUME, np.array([], dtype=np.int8), @@ -405,8 +405,8 @@ def test_a_cleared_instrument_takes_its_channel_out_of_play( regenerated = self._regenerated(reconstruction) - assert regenerated.instructions[GeneratorName.PULSE1] == [] - assert regenerated.playing_generators == () + assert regenerated.instructions[ChannelName.PULSE1] == [] + assert regenerated.playing_channels == () def test_a_cleared_instrument_sounds_as_an_empty_waveform( self, @@ -427,12 +427,12 @@ def test_the_cleared_channel_records_every_dimension_as_the_channels( regenerated = self._regenerated(reconstruction) - assert regenerated.held_features[GeneratorName.PULSE1] == ( + assert regenerated.held_features[ChannelName.PULSE1] == ( FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE, ) - assert not regenerated.export()[GeneratorName.PULSE1].has_frames + assert not regenerated.export()[ChannelName.PULSE1].has_frames def test_the_reconstruction_the_edit_was_made_from_keeps_playing( self, @@ -442,7 +442,7 @@ def test_the_reconstruction_the_edit_was_made_from_keeps_playing( self._regenerated(reconstruction) - assert reconstruction.playing_generators == (GeneratorName.PULSE1,) + assert reconstruction.playing_channels == (ChannelName.PULSE1,) class TestRegenerationServiceCancellationConstraints: @@ -482,7 +482,7 @@ def blocking_from_features(edited_features: Any) -> List[MagicMock]: thread = threading.Thread( target=lambda: service._run( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, features), FeatureKey.VOLUME, 1, @@ -511,7 +511,7 @@ def test_cancel_after_completion_prevents_new_tasks( service.start( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, {}), FeatureKey.VOLUME, 1, @@ -520,7 +520,7 @@ def test_cancel_after_completion_prevents_new_tasks( service.cancel() second_result = service.start( reconstruction, - synthesis_mocks.generator_name, + synthesis_mocks.channel_name, cast(Features, {}), FeatureKey.VOLUME, 2, diff --git a/tests/unit/sampletones_application/test_application_channels.py b/tests/unit/sampletones_application/test_application_channels.py index 39a46fc0f..3f8111785 100644 --- a/tests/unit/sampletones_application/test_application_channels.py +++ b/tests/unit/sampletones_application/test_application_channels.py @@ -8,7 +8,7 @@ from sampletones_application.application import Application from sampletones_application.categories.hierarchy import Tab -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -25,20 +25,20 @@ class Harness: """An application standing in one tab, recording which surface a channel key reaches.""" def __init__(self, tab: Tab) -> None: - self.switched: List[Tuple[Surface, GeneratorName]] = [] + self.switched: List[Tuple[Surface, ChannelName]] = [] self.application = Application.__new__(Application) self.application._shell = MagicMock() self.application._shell.get_current_tab.return_value = tab self.application._main_tab = MagicMock() - self.application._main_tab.toggle_generator = partial(self._record, Surface.MAIN) + self.application._main_tab.toggle_channel = partial(self._record, Surface.MAIN) self.application._reconstructions_tab = MagicMock() - self.application._reconstructions_tab.toggle_generator = partial(self._record, Surface.RECONSTRUCTIONS) + self.application._reconstructions_tab.toggle_channel = partial(self._record, Surface.RECONSTRUCTIONS) self.application._sequencer_tab = MagicMock() self.application._sequencer_tab.toggle_channel = partial(self._record, Surface.SEQUENCER) - def _record(self, surface: Surface, generator: GeneratorName) -> None: - self.switched.append((surface, generator)) + def _record(self, surface: Surface, channel: ChannelName) -> None: + self.switched.append((surface, channel)) class TestToggleChannel(BaseTestSuite): @@ -51,7 +51,7 @@ class TestCase(BaseRegularTestCase): test_cases = ( TestCase( - label="the main tab switches a generator of the reconstructor", + label="the main tab switches a channel of the reconstructor", tab=Tab.MAIN, expected=Surface.MAIN, ), @@ -80,9 +80,9 @@ class TestCase(BaseRegularTestCase): def test_the_surface_a_channel_key_reaches(self, test_case: TestCase) -> None: harness = Harness(test_case.tab) - harness.application._toggle_channel(GeneratorName.TRIANGLE) + harness.application._toggle_channel(ChannelName.TRIANGLE) - assert harness.switched == [(test_case.expected, GeneratorName.TRIANGLE)] + assert harness.switched == [(test_case.expected, ChannelName.TRIANGLE)] @pytest.mark.parametrize( "test_case", @@ -93,10 +93,10 @@ def test_every_channel_reaches_the_same_surface(self, test_case: TestCase) -> No """The four keys stand together, so a tab answers all of them or none.""" harness = Harness(test_case.tab) - for generator in GeneratorName: - harness.application._toggle_channel(generator) + for channel in ChannelName: + harness.application._toggle_channel(channel) - assert harness.switched == [(test_case.expected, generator) for generator in GeneratorName] + assert harness.switched == [(test_case.expected, channel) for channel in ChannelName] class TestMuteChannel: @@ -104,6 +104,6 @@ def test_the_menu_gesture_switches_the_sequencer_mix_from_any_tab(self) -> None: """The Channels submenu shows the sequencer's mix, so choosing an item switches that mix.""" harness = Harness(Tab.MAIN) - harness.application._mute_channel(GeneratorName.NOISE) + harness.application._mute_channel(ChannelName.NOISE) - assert harness.switched == [(Surface.SEQUENCER, GeneratorName.NOISE)] + assert harness.switched == [(Surface.SEQUENCER, ChannelName.NOISE)] diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index dac387964..f3d53a4b4 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -22,7 +22,7 @@ stop_background_workers, ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} @@ -347,36 +347,36 @@ class TestChannelKeys: """ @staticmethod - def _press(app: Application, generator: GeneratorName, tab: Tab) -> None: + def _press(app: Application, channel: ChannelName, tab: Tab) -> None: with patch.object(app._shell, "get_current_tab", return_value=tab): - _press_shortcut(app, CHANNEL_SHORTCUT_IDS[generator]) + _press_shortcut(app, CHANNEL_SHORTCUT_IDS[channel]) def test_the_main_tab_switches_the_generator_a_reconstruction_is_built_from(self, app: Application) -> None: - selected = frozenset(app.config_manager.config.generation.generators) + selected = frozenset(app.config_manager.config.generation.channels) - self._press(app, GeneratorName.TRIANGLE, Tab.MAIN) + self._press(app, ChannelName.TRIANGLE, Tab.MAIN) - assert frozenset(app.config_manager.config.generation.generators) == selected ^ {GeneratorName.TRIANGLE} + assert frozenset(app.config_manager.config.generation.channels) == selected ^ {ChannelName.TRIANGLE} def test_the_sequencer_switches_its_mix(self, app: Application) -> None: - self._press(app, GeneratorName.NOISE, Tab.SEQUENCER) + self._press(app, ChannelName.NOISE, Tab.SEQUENCER) - assert app._sequencer_tab.channels.is_muted(GeneratorName.NOISE) + assert app._sequencer_tab.channels.is_muted(ChannelName.NOISE) def test_a_second_press_returns_the_mix_it_started_from(self, app: Application) -> None: - self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER) - self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER) + self._press(app, ChannelName.PULSE1, Tab.SEQUENCER) + self._press(app, ChannelName.PULSE1, Tab.SEQUENCER) assert not app._sequencer_tab.channels.any_muted def test_the_reconstructions_tab_holding_nothing_leaves_the_mix_alone(self, app: Application) -> None: """With no reconstruction loaded every slice reads as unavailable, so the key rests there.""" - self._press(app, GeneratorName.PULSE2, Tab.RECONSTRUCTIONS) + self._press(app, ChannelName.PULSE2, Tab.RECONSTRUCTIONS) assert not app._sequencer_tab.channels.any_muted def test_the_main_tab_leaves_the_sequencer_mix_alone(self, app: Application) -> None: - self._press(app, GeneratorName.PULSE1, Tab.MAIN) + self._press(app, ChannelName.PULSE1, Tab.MAIN) assert not app._sequencer_tab.channels.any_muted diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py index 3cae480ae..e92e356ee 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -28,7 +28,7 @@ "spectrum_method", "transformation_gamma", "window_size", - "generators", + "channels", "configuration", ] diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py index ae07e0514..9bb777781 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py @@ -7,13 +7,13 @@ from sampletones_application.ui.panels.main import reconstructor as reconstructor_module from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel from sampletones_application.view_model.main.updates import GenerationSettingsUpdate -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase DRIVE = 1.5 -ALL_GENERATORS = frozenset(GeneratorName) +ALL_CHANNELS = frozenset(ChannelName) class Harness: @@ -21,12 +21,11 @@ class Harness: def __init__( self, - checked: FrozenSet[GeneratorName], + checked: FrozenSet[ChannelName], monkeypatch: pytest.MonkeyPatch, ) -> None: self.values: Dict[str, bool] = { - GUIReconstructorPanel._get_generator_checkbox_tag(generator): generator in checked - for generator in GeneratorName + GUIReconstructorPanel._get_generator_checkbox_tag(channel): channel in checked for channel in ChannelName } self.reported: List[GenerationSettingsUpdate] = [] @@ -42,40 +41,40 @@ def _drive(tag: str) -> float: assert tag == TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE return DRIVE - def checked(self) -> FrozenSet[GeneratorName]: + def checked(self) -> FrozenSet[ChannelName]: return frozenset( - generator - for generator in GeneratorName - if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(generator)] + channel + for channel in ChannelName + if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(channel)] ) -class TestToggleGenerator(BaseTestSuite): +class TestToggleChannel(BaseTestSuite): """The key a channel answers to switches its checkbox, the gesture a click on it makes.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): - checked: FrozenSet[GeneratorName] - generator: GeneratorName - expected: FrozenSet[GeneratorName] + checked: FrozenSet[ChannelName] + channel: ChannelName + expected: FrozenSet[ChannelName] test_cases = ( TestCase( label="switching one off leaves the rest", - checked=ALL_GENERATORS, - generator=GeneratorName.TRIANGLE, - expected=ALL_GENERATORS - {GeneratorName.TRIANGLE}, + checked=ALL_CHANNELS, + channel=ChannelName.TRIANGLE, + expected=ALL_CHANNELS - {ChannelName.TRIANGLE}, ), TestCase( label="switching one on adds it alone", checked=frozenset(), - generator=GeneratorName.PULSE1, - expected=frozenset({GeneratorName.PULSE1}), + channel=ChannelName.PULSE1, + expected=frozenset({ChannelName.PULSE1}), ), TestCase( label="the last one switched off leaves nothing selected", - checked=frozenset({GeneratorName.NOISE}), - generator=GeneratorName.NOISE, + checked=frozenset({ChannelName.NOISE}), + channel=ChannelName.NOISE, expected=frozenset(), ), ) @@ -92,7 +91,7 @@ def test_the_set_the_checkboxes_show( ) -> None: harness = Harness(test_case.checked, monkeypatch) - harness.panel.toggle_generator(test_case.generator) + harness.panel.toggle_channel(test_case.channel) assert harness.checked() == test_case.expected @@ -109,12 +108,12 @@ def test_the_settings_the_panel_reports( """A switch reaches the configuration the same way a click does, drive carried along.""" harness = Harness(test_case.checked, monkeypatch) - harness.panel.toggle_generator(test_case.generator) + harness.panel.toggle_channel(test_case.channel) assert harness.reported == [ GenerationSettingsUpdate( drive=DRIVE, - generators=[generator for generator in GeneratorName if generator in test_case.expected], + channels=[channel for channel in ChannelName if channel in test_case.expected], ) ] @@ -122,36 +121,36 @@ def test_switching_a_generator_twice_returns_the_set_it_started_from( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - harness = Harness(ALL_GENERATORS, monkeypatch) + harness = Harness(ALL_CHANNELS, monkeypatch) - harness.panel.toggle_generator(GeneratorName.PULSE2) - harness.panel.toggle_generator(GeneratorName.PULSE2) + harness.panel.toggle_channel(ChannelName.PULSE2) + harness.panel.toggle_channel(ChannelName.PULSE2) - assert harness.checked() == ALL_GENERATORS + assert harness.checked() == ALL_CHANNELS def test_the_generators_are_reported_in_the_order_the_tracker_shows_them( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - harness = Harness(frozenset({GeneratorName.NOISE, GeneratorName.PULSE1}), monkeypatch) + harness = Harness(frozenset({ChannelName.NOISE, ChannelName.PULSE1}), monkeypatch) - harness.panel.toggle_generator(GeneratorName.TRIANGLE) + harness.panel.toggle_channel(ChannelName.TRIANGLE) assert self._generators(harness.reported) == [ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, ] @staticmethod - def _generators(reported: List[GenerationSettingsUpdate]) -> List[GeneratorName]: - return list(reported[-1].generators) + def _generators(reported: List[GenerationSettingsUpdate]) -> List[ChannelName]: + return list(reported[-1].channels) class TestCheckboxTags: def test_each_generator_carries_a_tag_of_its_own(self) -> None: tags: Tuple[str, ...] = tuple( - GUIReconstructorPanel._get_generator_checkbox_tag(generator) for generator in GeneratorName + GUIReconstructorPanel._get_generator_checkbox_tag(channel) for channel in ChannelName ) assert len(set(tags)) == len(tags) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index 26881292e..04b83ec9a 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -35,7 +35,7 @@ ReconstructionInstrumentsViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, @@ -52,18 +52,18 @@ NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - playing_generators=frozenset(), + playing_channels=frozenset(), footprint=None, ) def build_view_model( - channel_footprints: Dict[GeneratorName, InstrumentFootprint], + channel_footprints: Dict[ChannelName, InstrumentFootprint], ) -> ReconstructionInstrumentsViewModel: """A loaded reconstruction playing the given channels, each measured as given.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - playing_generators=frozenset(channel_footprints), + playing_channels=frozenset(channel_footprints), footprint=SampleFootprintViewModel.from_footprints(channel_footprints), ) @@ -141,7 +141,7 @@ def test_a_sequence_within_the_limit_keeps_the_default_theme( bound_themes: List[str], item_count: int, ) -> None: - panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, item_count) + panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, item_count) assert bound_themes == [TAG_GLOBAL_THEME_DEFAULT] def test_a_sequence_beyond_the_limit_takes_the_warning_theme( @@ -149,7 +149,7 @@ def test_a_sequence_beyond_the_limit_takes_the_warning_theme( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1) + panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1) assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING] def test_a_shortened_sequence_returns_to_the_default_theme( @@ -157,8 +157,8 @@ def test_a_shortened_sequence_returns_to_the_default_theme( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(GeneratorName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 40) - panel._apply_input_theme(GeneratorName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS) + panel._apply_input_theme(ChannelName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 40) + panel._apply_input_theme(ChannelName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS) assert bound_themes == [ TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT, @@ -169,8 +169,8 @@ def test_each_dimension_carries_its_own_length( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1) - panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.ARPEGGIO, 8) + panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1) + panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.ARPEGGIO, 8) assert bound_themes == [ TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT, @@ -178,31 +178,31 @@ def test_each_dimension_carries_its_own_length( class TestInstrumentExport: - """The export button carries the generator whose slice it writes; the destination the + """The export button carries the channel whose slice it writes; the destination the dialog answers with names the tracker, so no format travels from here.""" def test_the_generator_reaches_the_export_callback( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - calls: List[GeneratorName] = [] + calls: List[ChannelName] = [] panel.on_instrument_export = calls.append - panel._export_callback(GeneratorName.NOISE)() + panel._export_callback(ChannelName.NOISE)() - assert calls == [GeneratorName.NOISE] + assert calls == [ChannelName.NOISE] def test_each_generator_gets_its_own_handler( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - calls: List[GeneratorName] = [] + calls: List[ChannelName] = [] panel.on_instrument_export = calls.append - for generator_name in GeneratorName.items(): - panel._export_callback(generator_name)() + for channel_name in ChannelName.items(): + panel._export_callback(channel_name)() - assert calls == list(GeneratorName.items()) + assert calls == list(ChannelName.items()) def test_the_handler_is_one_the_framework_can_dispatch( self, @@ -211,7 +211,7 @@ def test_the_handler_is_one_the_framework_can_dispatch( """DearPyGui reads a callback's ``__code__`` to decide how many arguments to pass it, so a press handler carries one and takes the arguments the framework offers a button. """ - callback = panel._export_callback(GeneratorName.NOISE) + callback = panel._export_callback(ChannelName.NOISE) assert callback.__code__.co_argcount == 0 @@ -223,12 +223,12 @@ def test_a_sequence_within_the_limit_describes_editing( bound_themes: List[str], ) -> None: panel._apply_input_theme( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.VOLUME, HEXADECIMAL_BASE, ) message = panel._sequence_status_message( - GeneratorName.PULSE1, + ChannelName.PULSE1, FeatureKey.VOLUME, ) assert message == panel._language_manager[SEQUENCE_STATUS_KEY].format( @@ -240,8 +240,8 @@ def test_a_sequence_beyond_the_limit_names_the_limit( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, 300) - message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME) + panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, 300) + message = panel._sequence_status_message(ChannelName.PULSE1, FeatureKey.VOLUME) assert "300" in message assert str(MAX_SEQUENCE_ITEMS) in message @@ -251,27 +251,27 @@ class TestSizeFields(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): - channel_footprints: Dict[GeneratorName, InstrumentFootprint] + channel_footprints: Dict[ChannelName, InstrumentFootprint] expected: str test_cases = ( TestCase( label="a single channel spends what its instrument does", - channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE}, + channel_footprints={ChannelName.PULSE1: LARGEST_PULSE}, expected="777 B", ), TestCase( label="three channels spend their instruments together", channel_footprints={ - GeneratorName.PULSE1: LARGEST_PULSE, - GeneratorName.TRIANGLE: LARGEST_TRIANGLE, - GeneratorName.NOISE: LARGEST_PULSE, + ChannelName.PULSE1: LARGEST_PULSE, + ChannelName.TRIANGLE: LARGEST_TRIANGLE, + ChannelName.NOISE: LARGEST_PULSE, }, expected="2073 B", ), TestCase( label="a silent channel spends the instrument definition alone", - channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT}, + channel_footprints={ChannelName.TRIANGLE: SILENT_INSTRUMENT}, expected="3 B", ), ) @@ -295,11 +295,11 @@ def test_each_channel_states_its_own_size( ) -> None: panel.update_view(build_view_model(test_case.channel_footprints)) assert { - generator_name: written[panel._get_instrument_size_tag(generator_name)] - for generator_name in test_case.channel_footprints + channel_name: written[panel._get_instrument_size_tag(channel_name)] + for channel_name in test_case.channel_footprints } == { - generator_name: f"{footprint.total_bytes} B" - for generator_name, footprint in test_case.channel_footprints.items() + channel_name: f"{footprint.total_bytes} B" + for channel_name, footprint in test_case.channel_footprints.items() } @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) @@ -312,13 +312,13 @@ def test_a_channel_standing_by_costs_nothing( """A channel that describes no frame is written by no export, so its tab states what that costs.""" panel.update_view(build_view_model(test_case.channel_footprints)) assert { - generator_name: written[panel._get_instrument_size_tag(generator_name)] - for generator_name in GeneratorName.items() - if generator_name not in test_case.channel_footprints + channel_name: written[panel._get_instrument_size_tag(channel_name)] + for channel_name in ChannelName.items() + if channel_name not in test_case.channel_footprints } == { - generator_name: "0 B" - for generator_name in GeneratorName.items() - if generator_name not in test_case.channel_footprints + channel_name: "0 B" + for channel_name in ChannelName.items() + if channel_name not in test_case.channel_footprints } @@ -334,36 +334,35 @@ def test_every_channel_keeps_its_tab( panel: GUIReconstructionInstrumentsPanel, shown: Dict[str, bool], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) assert { - generator_name: shown[panel._get_generator_tab_tag(generator_name)] - for generator_name in GeneratorName.items() - } == {generator_name: True for generator_name in GeneratorName.items()} + channel_name: shown[panel._get_generator_tab_tag(channel_name)] for channel_name in ChannelName.items() + } == {channel_name: True for channel_name in ChannelName.items()} def test_a_channel_standing_by_reads_muted( self, panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) - assert dict(zip(GeneratorName.items(), bound_themes)) == { - GeneratorName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS, - GeneratorName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, - GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, - GeneratorName.NOISE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) + assert dict(zip(ChannelName.items(), bound_themes)) == { + ChannelName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS, + ChannelName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, + ChannelName.TRIANGLE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, + ChannelName.NOISE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, } def test_only_a_playing_channel_offers_its_export( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} - panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) + buttons = {channel_name: MagicMock() for channel_name in ChannelName.items()} + panel._export_buttons.update(cast(Dict[ChannelName, GUIButton], buttons)) - panel.update_view(build_view_model({GeneratorName.TRIANGLE: LARGEST_TRIANGLE})) + panel.update_view(build_view_model({ChannelName.TRIANGLE: LARGEST_TRIANGLE})) - assert {generator_name: button.set_enabled.call_args.args[0] for generator_name, button in buttons.items()} == { - generator_name: generator_name is GeneratorName.TRIANGLE for generator_name in GeneratorName.items() + assert {channel_name: button.set_enabled.call_args.args[0] for channel_name, button in buttons.items()} == { + channel_name: channel_name is ChannelName.TRIANGLE for channel_name in ChannelName.items() } @@ -373,7 +372,7 @@ def test_a_loaded_reconstruction_shows_the_sample_size( panel: GUIReconstructionInstrumentsPanel, shown: Dict[str, bool], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) assert shown[panel.sample_size_group_tag] is True def test_no_reconstruction_hides_the_sample_size( diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py index 76f4d1900..d6fa821c7 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -12,11 +12,11 @@ ReconstructionPathViewModel, ReconstructionViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase -ALL_GENERATORS = frozenset(GeneratorName) +ALL_CHANNELS = frozenset(ChannelName) class StubTheme: @@ -31,19 +31,19 @@ def bind_to_item(self, item: str) -> None: class Harness: - """The panel over its generator checkboxes, each shown or disabled as a reconstruction leaves + """The panel over its channel checkboxes, each shown or disabled as a reconstruction leaves it.""" def __init__( self, *, - selected: FrozenSet[GeneratorName], - available: FrozenSet[GeneratorName], + selected: FrozenSet[ChannelName], + available: FrozenSet[ChannelName], monkeypatch: pytest.MonkeyPatch, ) -> None: - self.values: Dict[str, bool] = {self._tag(generator): generator in selected for generator in GeneratorName} - self.enabled: Dict[str, bool] = {self._tag(generator): generator in available for generator in GeneratorName} - self.reported: List[List[GeneratorName]] = [] + self.values: Dict[str, bool] = {self._tag(channel): channel in selected for channel in ChannelName} + self.enabled: Dict[str, bool] = {self._tag(channel): channel in available for channel in ChannelName} + self.reported: List[List[ChannelName]] = [] self.bound_themes: Dict[str, str] = {} monkeypatch.setattr(plot_module.dpg, "get_value", self.values.__getitem__) @@ -58,38 +58,38 @@ def __init__( ) self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel) - self.panel.on_generators_changed = self.reported.append + self.panel.on_channels_changed = self.reported.append def _configure(self, tag: str, *, enabled: bool, default_value: bool) -> None: self.enabled[tag] = enabled self.values[tag] = default_value @staticmethod - def _tag(generator: GeneratorName) -> str: - return GUIReconstructionPlotPanel._get_generator_checkbox_tag(generator) + def _tag(channel: ChannelName) -> str: + return GUIReconstructionPlotPanel._get_generator_checkbox_tag(channel) - def offered(self) -> FrozenSet[GeneratorName]: - return frozenset(generator for generator in GeneratorName if self.enabled[self._tag(generator)]) + def offered(self) -> FrozenSet[ChannelName]: + return frozenset(channel for channel in ChannelName if self.enabled[self._tag(channel)]) - def selected(self) -> FrozenSet[GeneratorName]: - return frozenset(generator for generator in GeneratorName if self.values[self._tag(generator)]) + def selected(self) -> FrozenSet[ChannelName]: + return frozenset(channel for channel in ChannelName if self.values[self._tag(channel)]) def _view_model( - playing: FrozenSet[GeneratorName], - selected: FrozenSet[GeneratorName], + playing: FrozenSet[ChannelName], + selected: FrozenSet[ChannelName], ) -> ReconstructionViewModel: empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="") return ReconstructionViewModel( reconstruction_loaded=True, - playing_generators=playing, - selected_generators=selected, + playing_channels=playing, + selected_channels=selected, reconstruction_file=empty_path, original_audio=empty_path, ) -class TestGeneratorCheckboxes: +class TestChannelCheckboxes: """The checkboxes offer the channels that play and tick the ones the reader keeps on.""" def test_a_channel_that_plays_is_offered( @@ -97,7 +97,7 @@ def test_a_channel_that_plays_is_offered( monkeypatch: pytest.MonkeyPatch, ) -> None: harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch) - playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE}) + playing = frozenset({ChannelName.PULSE1, ChannelName.NOISE}) harness.panel.update_view(_view_model(playing, playing)) @@ -109,69 +109,69 @@ def test_a_channel_switched_off_by_hand_stays_off( monkeypatch: pytest.MonkeyPatch, ) -> None: """An edit reports the view again, and the report carries the reader's choice.""" - harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch) - playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE}) + harness = Harness(selected=ALL_CHANNELS, available=ALL_CHANNELS, monkeypatch=monkeypatch) + playing = frozenset({ChannelName.PULSE1, ChannelName.NOISE}) - harness.panel.update_view(_view_model(playing, frozenset({GeneratorName.NOISE}))) + harness.panel.update_view(_view_model(playing, frozenset({ChannelName.NOISE}))) assert harness.offered() == playing - assert harness.selected() == frozenset({GeneratorName.NOISE}) + assert harness.selected() == frozenset({ChannelName.NOISE}) def test_a_channel_standing_by_is_left_unticked( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch) - playing = frozenset({GeneratorName.PULSE1}) + harness = Harness(selected=ALL_CHANNELS, available=ALL_CHANNELS, monkeypatch=monkeypatch) + playing = frozenset({ChannelName.PULSE1}) harness.panel.update_view(_view_model(playing, playing)) assert harness.selected() == playing - assert GeneratorName.PULSE2 not in harness.offered() + assert ChannelName.PULSE2 not in harness.offered() def test_a_channel_that_plays_carries_its_own_tint( self, monkeypatch: pytest.MonkeyPatch, ) -> None: harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch) - playing = frozenset({GeneratorName.TRIANGLE}) + playing = frozenset({ChannelName.TRIANGLE}) harness.panel.update_view(_view_model(playing, playing)) - assert set(harness.bound_themes) == {Harness._tag(GeneratorName.TRIANGLE)} + assert set(harness.bound_themes) == {Harness._tag(ChannelName.TRIANGLE)} -class TestToggleGenerator(BaseTestSuite): +class TestToggleChannel(BaseTestSuite): """The key a channel answers to switches its slice in and out of the waveform.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): - selected: FrozenSet[GeneratorName] - available: FrozenSet[GeneratorName] - generator: GeneratorName - expected: FrozenSet[GeneratorName] + selected: FrozenSet[ChannelName] + available: FrozenSet[ChannelName] + channel: ChannelName + expected: FrozenSet[ChannelName] test_cases = ( TestCase( label="switching a shown slice out", - selected=ALL_GENERATORS, - available=ALL_GENERATORS, - generator=GeneratorName.PULSE1, - expected=ALL_GENERATORS - {GeneratorName.PULSE1}, + selected=ALL_CHANNELS, + available=ALL_CHANNELS, + channel=ChannelName.PULSE1, + expected=ALL_CHANNELS - {ChannelName.PULSE1}, ), TestCase( label="switching a hidden slice back in", - selected=frozenset({GeneratorName.NOISE}), - available=ALL_GENERATORS, - generator=GeneratorName.TRIANGLE, - expected=frozenset({GeneratorName.TRIANGLE, GeneratorName.NOISE}), + selected=frozenset({ChannelName.NOISE}), + available=ALL_CHANNELS, + channel=ChannelName.TRIANGLE, + expected=frozenset({ChannelName.TRIANGLE, ChannelName.NOISE}), ), TestCase( - label="a generator the reconstruction holds none of stays out", - selected=frozenset({GeneratorName.PULSE1}), - available=frozenset({GeneratorName.PULSE1}), - generator=GeneratorName.NOISE, - expected=frozenset({GeneratorName.PULSE1}), + label="a channel the reconstruction holds none of stays out", + selected=frozenset({ChannelName.PULSE1}), + available=frozenset({ChannelName.PULSE1}), + channel=ChannelName.NOISE, + expected=frozenset({ChannelName.PULSE1}), ), ) @@ -191,13 +191,13 @@ def test_the_slices_the_checkboxes_show( monkeypatch=monkeypatch, ) - harness.panel.toggle_generator(test_case.generator) + harness.panel.toggle_channel(test_case.channel) assert harness.selected() == test_case.expected @pytest.mark.parametrize( "test_case", - [test_case for test_case in test_cases if test_case.generator in test_case.available], + [test_case for test_case in test_cases if test_case.channel in test_case.available], ids=lambda test_case: test_case.label, ) def test_the_selection_the_panel_reports( @@ -212,10 +212,10 @@ def test_the_selection_the_panel_reports( monkeypatch=monkeypatch, ) - harness.panel.toggle_generator(test_case.generator) + harness.panel.toggle_channel(test_case.channel) assert harness.reported == [ - [generator for generator in GeneratorName if generator in test_case.expected], + [channel for channel in ChannelName if channel in test_case.expected], ] def test_a_generator_the_reconstruction_holds_none_of_reports_nothing( @@ -224,12 +224,12 @@ def test_a_generator_the_reconstruction_holds_none_of_reports_nothing( ) -> None: """Its checkbox already reads as unavailable, so the key leaves the waveform as it stands.""" harness = Harness( - selected=frozenset({GeneratorName.PULSE1}), - available=frozenset({GeneratorName.PULSE1}), + selected=frozenset({ChannelName.PULSE1}), + available=frozenset({ChannelName.PULSE1}), monkeypatch=monkeypatch, ) - harness.panel.toggle_generator(GeneratorName.NOISE) + harness.panel.toggle_channel(ChannelName.NOISE) assert harness.reported == [] @@ -238,12 +238,12 @@ def test_switching_a_slice_twice_returns_the_waveform_it_started_from( monkeypatch: pytest.MonkeyPatch, ) -> None: harness = Harness( - selected=ALL_GENERATORS, - available=ALL_GENERATORS, + selected=ALL_CHANNELS, + available=ALL_CHANNELS, monkeypatch=monkeypatch, ) - harness.panel.toggle_generator(GeneratorName.PULSE2) - harness.panel.toggle_generator(GeneratorName.PULSE2) + harness.panel.toggle_channel(ChannelName.PULSE2) + harness.panel.toggle_channel(ChannelName.PULSE2) - assert harness.selected() == ALL_GENERATORS + assert harness.selected() == ALL_CHANNELS diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 00e132c8f..9be209e76 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -5,17 +5,17 @@ OrderCursor, OrderInputState, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName POSITION_COUNT = 8 def _state( - generator: Optional[GeneratorName] = GeneratorName.PULSE1, + channel: Optional[ChannelName] = ChannelName.PULSE1, position: int = 0, pending: str = "", ) -> OrderInputState: - return OrderInputState(cursor=OrderCursor(generator, position), pending=pending) + return OrderInputState(cursor=OrderCursor(channel, position), pending=pending) class TestNavigation: @@ -35,11 +35,11 @@ def test_channel_cycles_master_then_channels_and_wraps(self) -> None: visited = [] state = OrderInputState(cursor=OrderCursor(CHANNEL_AXIS[0], 0)) for _ in range(len(CHANNEL_AXIS)): - visited.append(state.cursor.generator) + visited.append(state.cursor.channel) state = state.navigate_channel(1) assert visited == list(CHANNEL_AXIS) - assert state.cursor.generator == CHANNEL_AXIS[0] + assert state.cursor.channel == CHANNEL_AXIS[0] class TestSelection: @@ -62,16 +62,16 @@ def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None: assert leftwards == rightwards def test_extending_channels_reaches_from_master_down(self) -> None: - extended = _state(generator=None).extend_channel(2) + extended = _state(channel=None).extend_channel(2) region = extended.region assert region is not None - assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2) + assert region.channels == (None, ChannelName.PULSE1, ChannelName.PULSE2) def test_extending_channels_stops_at_either_end_of_the_axis(self) -> None: """A selection covers a run of the table, so its reach stops where plain navigation wraps.""" - first = _state(generator=CHANNEL_AXIS[0]).extend_channel(-1) - last = _state(generator=CHANNEL_AXIS[-1]).extend_channel(1) + first = _state(channel=CHANNEL_AXIS[0]).extend_channel(-1) + last = _state(channel=CHANNEL_AXIS[-1]).extend_channel(1) assert first.cursor == OrderCursor(CHANNEL_AXIS[0], 0) assert last.cursor == OrderCursor(CHANNEL_AXIS[-1], 0) @@ -113,16 +113,16 @@ class TestTarget: """The region a block gesture acts on, which is the selection wherever one has been made.""" def test_a_cell_of_a_table_with_nothing_selected_is_raised_on_itself(self) -> None: - cell = OrderCursor(GeneratorName.PULSE2, 4) + cell = OrderCursor(ChannelName.PULSE2, 4) - region = _state(GeneratorName.PULSE2, position=4).region_at(cell) + region = _state(ChannelName.PULSE2, position=4).region_at(cell) assert (region.first_position, region.last_position) == (4, 4) - assert region.generators == (GeneratorName.PULSE2,) + assert region.channels == (ChannelName.PULSE2,) def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(position=4).extend_position(2, POSITION_COUNT) - cell = OrderCursor(GeneratorName.PULSE1, 5) + cell = OrderCursor(ChannelName.PULSE1, 5) assert selected.region_at(cell) == selected.region @@ -135,17 +135,17 @@ def test_selecting_all_reaches_every_row_and_every_position(self) -> None: region = selected.region assert region is not None - assert region.generators == CHANNEL_AXIS + assert region.channels == CHANNEL_AXIS assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) def test_selecting_a_row_reaches_the_cursor_s_channel_across_the_order(self) -> None: - cell = OrderCursor(GeneratorName.TRIANGLE, 2) + cell = OrderCursor(ChannelName.TRIANGLE, 2) - selected = _state(GeneratorName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT) + selected = _state(ChannelName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT) region = selected.region assert region is not None - assert region.generators == (GeneratorName.TRIANGLE,) + assert region.channels == (ChannelName.TRIANGLE,) assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) def test_the_master_row_is_a_row_like_any_other(self) -> None: @@ -155,7 +155,7 @@ def test_the_master_row_is_a_row_like_any_other(self) -> None: region = selected.region assert region is not None - assert region.generators == (None,) + assert region.channels == (None,) def test_a_shape_stands_the_cursor_on_the_last_position_it_reaches(self) -> None: """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index b4731cb43..7c5c37c36 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -3,7 +3,7 @@ from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName ROW_COUNT = 64 @@ -12,10 +12,10 @@ def _state( subcolumn: SubColumn, *, row: int = 0, - generator: Optional[GeneratorName] = GeneratorName.PULSE1, + channel: Optional[ChannelName] = ChannelName.PULSE1, pending: str = "", ) -> TrackerInputState: - return TrackerInputState(cursor=TrackerCursor(row, generator, subcolumn), pending=pending) + return TrackerInputState(cursor=TrackerCursor(row, channel, subcolumn), pending=pending) class TestNoteOffEntry: @@ -24,7 +24,7 @@ def test_minus_in_instrument_emits_note_off(self) -> None: assert action is not None assert action.note_off is True assert action.row == 0 - assert action.generator == GeneratorName.PULSE1 + assert action.channel == ChannelName.PULSE1 assert state.pending == "" def test_plus_in_instrument_is_ignored(self) -> None: @@ -67,22 +67,22 @@ def test_a_further_extend_keeps_the_original_anchor(self) -> None: assert (region.first_row, region.last_row) == (4, 8) def test_extending_slots_reaches_across_the_column_boundary(self) -> None: - extended = _state(SubColumn.VOLUME, generator=None).extend_slot(1) + extended = _state(SubColumn.VOLUME, channel=None).extend_slot(1) region = extended.region assert region is not None assert (region.first_slot, region.last_slot) == (2, 3) assert extended.cursor is not None - assert extended.cursor.generator is GeneratorName.PULSE1 + assert extended.cursor.channel is ChannelName.PULSE1 assert extended.cursor.subcolumn is SubColumn.INSTRUMENT def test_extending_slots_stops_at_either_end_of_the_axis(self) -> None: """A selection covers a run of the grid, so its reach stops where plain navigation wraps.""" - first = _state(SubColumn.INSTRUMENT, generator=None).extend_slot(-1) - last = _state(SubColumn.VOLUME, generator=GeneratorName.NOISE).extend_slot(1) + first = _state(SubColumn.INSTRUMENT, channel=None).extend_slot(-1) + last = _state(SubColumn.VOLUME, channel=ChannelName.NOISE).extend_slot(1) assert first.cursor == TrackerCursor(0, None, SubColumn.INSTRUMENT) - assert last.cursor == TrackerCursor(0, GeneratorName.NOISE, SubColumn.VOLUME) + assert last.cursor == TrackerCursor(0, ChannelName.NOISE, SubColumn.VOLUME) def test_a_plain_move_collapses_the_selection(self) -> None: moved = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT) @@ -137,16 +137,16 @@ class TestTargetRegion: """The region a block gesture acts on, which is the selection wherever one has been made.""" def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None: - cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + cell = TrackerCursor(4, ChannelName.PULSE1, SubColumn.TRANSPOSE) region = _state(SubColumn.TRANSPOSE, row=4).region_at(cell) assert (region.first_row, region.last_row) == (4, 4) - assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) + assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE),) def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) - cell = TrackerCursor(5, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + cell = TrackerCursor(5, ChannelName.PULSE1, SubColumn.INSTRUMENT) assert selected.region_at(cell) == selected.region @@ -163,42 +163,42 @@ def test_selecting_all_reaches_every_row_and_every_slot(self) -> None: assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1) def test_selecting_a_column_reaches_the_cursor_s_channel_and_its_subcolumns(self) -> None: - cell = TrackerCursor(4, GeneratorName.TRIANGLE, SubColumn.TRANSPOSE) + cell = TrackerCursor(4, ChannelName.TRIANGLE, SubColumn.TRANSPOSE) selected = _state(SubColumn.TRANSPOSE, row=4).select_column(cell, ROW_COUNT) region = selected.region assert region is not None assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) - assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn) + assert region.slots == tuple(TrackerSlot(ChannelName.TRIANGLE, subcolumn) for subcolumn in SubColumn) def test_the_sample_column_is_a_column_like_any_other(self) -> None: cell = TrackerCursor(4, None, SubColumn.VOLUME) - selected = _state(SubColumn.VOLUME, row=4, generator=None).select_column(cell, ROW_COUNT) + selected = _state(SubColumn.VOLUME, row=4, channel=None).select_column(cell, ROW_COUNT) region = selected.region assert region is not None assert region.columns == (None,) def test_selecting_a_subcolumn_reaches_the_one_slot_the_cursor_stands_on(self) -> None: - cell = TrackerCursor(4, GeneratorName.NOISE, SubColumn.VOLUME) + cell = TrackerCursor(4, ChannelName.NOISE, SubColumn.VOLUME) - selected = _state(SubColumn.VOLUME, row=4, generator=GeneratorName.NOISE).select_subcolumn(cell, ROW_COUNT) + selected = _state(SubColumn.VOLUME, row=4, channel=ChannelName.NOISE).select_subcolumn(cell, ROW_COUNT) region = selected.region assert region is not None assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) - assert region.slots == (TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME),) + assert region.slots == (TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME),) def test_a_shape_stands_the_cursor_on_the_last_row_it_reaches(self) -> None: """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" - cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + cell = TrackerCursor(4, ChannelName.PULSE1, SubColumn.INSTRUMENT) selected = _state(SubColumn.INSTRUMENT, row=4).select_column(cell, ROW_COUNT) - assert selected.cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.PULSE1, SubColumn.VOLUME) - assert selected.anchor == TrackerCursor(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + assert selected.cursor == TrackerCursor(ROW_COUNT - 1, ChannelName.PULSE1, SubColumn.VOLUME) + assert selected.anchor == TrackerCursor(0, ChannelName.PULSE1, SubColumn.INSTRUMENT) def test_a_frame_holding_no_rows_selects_nothing(self) -> None: state = _state(SubColumn.INSTRUMENT) @@ -208,20 +208,20 @@ def test_a_frame_holding_no_rows_selects_nothing(self) -> None: class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: - state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) + state = _state(SubColumn.VOLUME, channel=ChannelName.PULSE1) moved = state.navigate_column_by(1) assert moved.cursor is not None - assert moved.cursor.generator != GeneratorName.PULSE1 + assert moved.cursor.channel != ChannelName.PULSE1 assert moved.cursor.subcolumn is SubColumn.VOLUME def test_shift_tab_preserves_subcolumn(self) -> None: - state = _state(SubColumn.TRANSPOSE, generator=GeneratorName.PULSE1) + state = _state(SubColumn.TRANSPOSE, channel=ChannelName.PULSE1) moved = state.navigate_column_by(-1) assert moved.cursor is not None assert moved.cursor.subcolumn is SubColumn.TRANSPOSE def test_tab_preserves_row(self) -> None: - state = _state(SubColumn.VOLUME, row=5, generator=GeneratorName.PULSE1) + state = _state(SubColumn.VOLUME, row=5, channel=ChannelName.PULSE1) moved = state.navigate_column_by(1) assert moved.cursor is not None assert moved.cursor.row == 5 diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index b82dcdae0..6dd257221 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -25,7 +25,7 @@ ) from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from tests.suite.grid import ( ORDER_BLOCK_SHORTCUTS, @@ -39,7 +39,7 @@ POSITION_COUNT = 8 CURSOR_POSITION = 2 MASTER_ROW = CHANNEL_AXIS.index(None) -PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) +PULSE1_ROW = CHANNEL_AXIS.index(ChannelName.PULSE1) @dataclass @@ -51,7 +51,7 @@ class Gestures: cut: List[TrackerRegion] = field(default_factory=list) deleted: List[TrackerRegion] = field(default_factory=list) pasted: List[TrackerCell] = field(default_factory=list) - cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) + cleared: List[Tuple[int, Optional[ChannelName]]] = field(default_factory=list) transposed: List[Tuple[TrackerRegion, int]] = field(default_factory=list) volume_shifted: List[Tuple[TrackerRegion, int]] = field(default_factory=list) @@ -64,7 +64,7 @@ class OrderGestures: cut: List[OrderRegion] = field(default_factory=list) deleted: List[OrderRegion] = field(default_factory=list) pasted: List[OrderCell] = field(default_factory=list) - cleared: List[Tuple[GeneratorName, int, Optional[int]]] = field(default_factory=list) + cleared: List[Tuple[ChannelName, int, Optional[int]]] = field(default_factory=list) def _press(text: str) -> KeyEvent: @@ -77,7 +77,7 @@ def _panel( monkeypatch: pytest.MonkeyPatch, gestures: Gestures, *, - generator: Optional[GeneratorName] = GeneratorName.PULSE1, + channel: Optional[ChannelName] = ChannelName.PULSE1, subcolumn: SubColumn = SubColumn.INSTRUMENT, ) -> GUISequencerTrackerPanel: """A tracker panel reporting the gestures it fires, with its grid left unbuilt. @@ -87,14 +87,14 @@ def _panel( """ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() - panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn)) panel._current_row_count = ROW_COUNT panel._editable_cells = EditableCells() panel.on_copy_block = gestures.copied.append panel.on_cut_block = gestures.cut.append panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append - panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) + panel.on_clear_row = lambda row, channel_name: gestures.cleared.append((row, channel_name)) panel.on_adjust_transpose = lambda region, delta: gestures.transposed.append((region, delta)) panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta)) panel.can_paste_block = lambda: True @@ -108,12 +108,12 @@ def _order_panel( monkeypatch: pytest.MonkeyPatch, gestures: OrderGestures, *, - generator: Optional[GeneratorName] = GeneratorName.PULSE1, + channel: Optional[ChannelName] = ChannelName.PULSE1, ) -> GUISequencerOrderPanel: """An order panel reporting the gestures it fires, with its table left unbuilt.""" panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) panel._shortcuts = shipped_source() - panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION)) + panel._input_state = OrderInputState(cursor=OrderCursor(channel, CURSOR_POSITION)) panel._position_count = POSITION_COUNT panel.on_copy_block = gestures.copied.append panel.on_cut_block = gestures.cut.append @@ -152,7 +152,7 @@ def test_a_cursor_alone_copies_the_cell_it_stands_on( assert panel._on_key_pressed(_press("Ctrl+C")) is True assert gestures.copied[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) - assert gestures.copied[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert gestures.copied[-1].slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),) def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = Gestures() @@ -192,17 +192,17 @@ def test_a_paste_names_the_cell_the_cursor_stands_on( panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) assert panel._on_key_pressed(_press("Ctrl+V")) is True - assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=GeneratorName.PULSE1)] + assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, channel=ChannelName.PULSE1)] def test_the_sample_column_is_a_cell_a_block_lands_on( self, monkeypatch: pytest.MonkeyPatch, ) -> None: gestures = Gestures() - panel = _panel(monkeypatch, gestures, generator=None) + panel = _panel(monkeypatch, gestures, channel=None) assert panel._on_key_pressed(_press("Ctrl+V")) is True - assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=None)] + assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, channel=None)] class TestTrackerDeleteKey: @@ -232,7 +232,7 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( assert panel._on_key_pressed(_press("Del")) is True assert gestures.deleted == [] - assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] + assert gestures.cleared == [(CURSOR_ROW, ChannelName.PULSE1)] class TestTrackerAdjustKeys: @@ -263,7 +263,7 @@ def test_a_cursor_alone_shifts_the_cell_it_stands_on(self, monkeypatch: pytest.M assert panel._on_key_pressed(_press("Alt+Down")) is True region, delta = gestures.volume_shifted[-1] assert region.rows == range(CURSOR_ROW, CURSOR_ROW + 1) - assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),) assert delta == -tracker_module.VOLUME_FINE_STEP def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -305,7 +305,7 @@ def test_a_cursor_alone_copies_the_cell_it_stands_on( monkeypatch: pytest.MonkeyPatch, ) -> None: gestures = OrderGestures() - panel = _order_panel(monkeypatch, gestures, generator=None) + panel = _order_panel(monkeypatch, gestures, channel=None) assert panel._on_key_pressed(_press("Ctrl+C")) is True assert gestures.copied == [ @@ -353,17 +353,17 @@ def test_a_paste_names_the_cell_the_cursor_stands_on( panel = _order_panel(monkeypatch, gestures) assert panel._on_key_pressed(_press("Ctrl+V")) is True - assert gestures.pasted == [OrderCell(generator=GeneratorName.PULSE1, position=CURSOR_POSITION)] + assert gestures.pasted == [OrderCell(channel=ChannelName.PULSE1, position=CURSOR_POSITION)] def test_the_master_row_is_a_cell_a_block_lands_on( self, monkeypatch: pytest.MonkeyPatch, ) -> None: gestures = OrderGestures() - panel = _order_panel(monkeypatch, gestures, generator=None) + panel = _order_panel(monkeypatch, gestures, channel=None) assert panel._on_key_pressed(_press("Ctrl+V")) is True - assert gestures.pasted == [OrderCell(generator=None, position=CURSOR_POSITION)] + assert gestures.pasted == [OrderCell(channel=None, position=CURSOR_POSITION)] class TestOrderDeleteKey: @@ -393,4 +393,4 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( assert panel._on_key_pressed(_press("Del")) is True assert gestures.deleted == [] - assert gestures.cleared == [(GeneratorName.PULSE1, CURSOR_POSITION, None)] + assert gestures.cleared == [(ChannelName.PULSE1, CURSOR_POSITION, None)] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 53e5b2142..201dec768 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -24,7 +24,7 @@ ) from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot, slot_from_flat from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.grid import ( ORDER_BLOCK_SHORTCUTS, TRACKER_BLOCK_SHORTCUTS, @@ -47,7 +47,7 @@ SELECT_SUBCOLUMN_ITEM = 2 SELECT_ROW_ITEM = 1 -PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) +PULSE1_ROW = CHANNEL_AXIS.index(ChannelName.PULSE1) @dataclass @@ -204,14 +204,14 @@ def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: return _record_into(monkeypatch, order_module) -def _tracker_cell(generator: Optional[GeneratorName]) -> TrackerCursor: +def _tracker_cell(channel: Optional[ChannelName]) -> TrackerCursor: """The clicked cell the tracker item tests raise their menu on.""" - return TrackerCursor(CLICKED_ROW, generator, SubColumn.INSTRUMENT) + return TrackerCursor(CLICKED_ROW, channel, SubColumn.INSTRUMENT) -def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor: +def _order_cell(channel: Optional[ChannelName]) -> OrderCursor: """The clicked cell the order item tests raise their menu on.""" - return OrderCursor(generator, CLICKED_POSITION) + return OrderCursor(channel, CLICKED_POSITION) def _tracker_selections( @@ -219,7 +219,7 @@ def _tracker_selections( panel: tracker_module.GUISequencerTrackerPanel, ) -> List[TrackerInputState]: """The states a select item applies, on a grid holding a cursor and the rows to reach.""" - panel._input_state = TrackerInputState(cursor=_tracker_cell(GeneratorName.PULSE1)) + panel._input_state = TrackerInputState(cursor=_tracker_cell(ChannelName.PULSE1)) panel._current_row_count = ROW_COUNT states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -232,7 +232,7 @@ def _order_selections( panel: order_module.GUISequencerOrderPanel, ) -> List[OrderInputState]: """The states a select item applies, on a table holding a cursor and the positions to reach.""" - panel._input_state = OrderInputState(cursor=_order_cell(GeneratorName.PULSE1)) + panel._input_state = OrderInputState(cursor=_order_cell(ChannelName.PULSE1)) states: List[OrderInputState] = [] monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: states.append(state)) return states @@ -240,13 +240,13 @@ def _order_selections( def _selected_tracker_state() -> TrackerInputState: """A selection running from the clicked row down two rows, over Pulse 1's whole cell.""" - state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) + state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, ChannelName.PULSE1, SubColumn.INSTRUMENT)) return state.extend_row(2, ROW_COUNT).extend_slot(2) def _selected_order_state() -> OrderInputState: """A selection running from the clicked position across two positions of Pulse 1's row.""" - state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION)) + state = OrderInputState(cursor=OrderCursor(ChannelName.PULSE1, CLICKED_POSITION)) return state.extend_position(2, POSITION_COUNT) @@ -258,7 +258,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: target = panel._surface.target_at( TrackerCursor( CLICKED_ROW + 1, - GeneratorName.PULSE1, + ChannelName.PULSE1, SubColumn.TRANSPOSE, ) ) @@ -272,7 +272,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non target = panel._surface.target_at( TrackerCursor( CLICKED_ROW, - GeneratorName.TRIANGLE, + ChannelName.TRIANGLE, SubColumn.VOLUME, ) ) @@ -280,8 +280,8 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non assert target.region == TrackerRegion( first_row=CLICKED_ROW, last_row=CLICKED_ROW, - first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, - last_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, + first_slot=TrackerSlot(ChannelName.TRIANGLE, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(ChannelName.TRIANGLE, SubColumn.VOLUME).flat_index, ) def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: @@ -307,7 +307,7 @@ def test_a_grid_holding_no_cursor_names_no_target(self) -> None: def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _tracker_panel(Gestures()) - cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + cursor = TrackerCursor(CLICKED_ROW, ChannelName.NOISE, SubColumn.VOLUME) panel._input_state = TrackerInputState(cursor=cursor) target = panel._surface.cursor_target() @@ -316,10 +316,10 @@ def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None assert target.region == TrackerRegion( first_row=CLICKED_ROW, last_row=CLICKED_ROW, - first_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, - last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + first_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index, ) - assert target.anchor == TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE) + assert target.anchor == TrackerCell(row=CLICKED_ROW, channel=ChannelName.NOISE) class TestTrackerMenuItems: @@ -332,7 +332,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -349,19 +349,19 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord panel._surface.target_at( TrackerCursor( CLICKED_ROW, - GeneratorName.NOISE, + ChannelName.NOISE, SubColumn.VOLUME, ) ) ) tracker_recorder.items[PASTE_ITEM].callback() - assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)] + assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, channel=ChannelName.NOISE)] def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -372,7 +372,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -382,7 +382,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._surface.target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) + target = panel._surface.target_at(OrderCursor(ChannelName.PULSE1, CLICKED_POSITION + 1)) assert target.region == panel._input_state.region @@ -402,7 +402,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - target = panel._surface.target_at(_order_cell(GeneratorName.PULSE1)) + target = panel._surface.target_at(_order_cell(ChannelName.PULSE1)) assert target.region == OrderRegion( first_row=PULSE1_ROW, @@ -426,7 +426,7 @@ def test_a_table_holding_no_cursor_names_no_target(self) -> None: def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _order_panel(Gestures()) - cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) + cursor = OrderCursor(ChannelName.PULSE1, CLICKED_POSITION) panel._input_state = OrderInputState(cursor=cursor) target = panel._surface.cursor_target() @@ -438,7 +438,7 @@ def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None first_position=CLICKED_POSITION, last_position=CLICKED_POSITION, ) - assert target.anchor == OrderCell(generator=GeneratorName.PULSE1, position=CLICKED_POSITION) + assert target.anchor == OrderCell(channel=ChannelName.PULSE1, position=CLICKED_POSITION) class TestOrderMenuItems: @@ -451,7 +451,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1))) for item in order_recorder.items: item.callback() @@ -466,12 +466,12 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder panel._surface.add_block_items(panel._surface.target_at(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() - assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] + assert gestures.pasted == [OrderCell(channel=None, position=CLICKED_POSITION)] def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -482,7 +482,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -496,7 +496,7 @@ def test_the_tracker_action_set_leads_with_the_shapes_a_selection_takes( ) -> None: panel = _tracker_panel(Gestures()) - panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) + panel.add_action_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[:3] == ["select_all", "select_column", "select_subcolumn"] @@ -509,7 +509,7 @@ def test_the_order_action_set_leads_with_the_shapes_a_selection_takes( ) -> None: panel = _order_panel(Gestures()) - panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) + panel.add_action_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1))) labels = [item.label for item in order_recorder.items] assert labels[:2] == ["select_all", "select_row"] @@ -523,7 +523,7 @@ class TestMenuItemOrder: def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" @@ -538,7 +538,7 @@ class TestSelectItems: def test_the_tracker_items_print_the_keys_they_answer(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_select_items(_tracker_cell(GeneratorName.PULSE1)) + panel._add_select_items(_tracker_cell(ChannelName.PULSE1)) assert [item.shortcut for item in tracker_recorder.items] == [ "Ctrl+A", @@ -555,12 +555,12 @@ def test_a_tracker_item_selects_the_column_the_menu_was_raised_on( panel = _tracker_panel(Gestures()) states = _tracker_selections(monkeypatch, panel) - panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE)) + panel._add_select_items(_tracker_cell(ChannelName.TRIANGLE)) tracker_recorder.items[SELECT_COLUMN_ITEM].callback() region = states[-1].region assert region is not None - assert region.columns == (GeneratorName.TRIANGLE,) + assert region.columns == (ChannelName.TRIANGLE,) assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) def test_a_tracker_item_selects_the_whole_frame( @@ -571,7 +571,7 @@ def test_a_tracker_item_selects_the_whole_frame( panel = _tracker_panel(Gestures()) states = _tracker_selections(monkeypatch, panel) - panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE)) + panel._add_select_items(_tracker_cell(ChannelName.TRIANGLE)) tracker_recorder.items[SELECT_ALL_ITEM].callback() region = states[-1].region @@ -581,7 +581,7 @@ def test_a_tracker_item_selects_the_whole_frame( def test_the_order_items_print_the_keys_they_answer(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures()) - panel._add_select_items(_order_cell(GeneratorName.PULSE1)) + panel._add_select_items(_order_cell(ChannelName.PULSE1)) assert [item.shortcut for item in order_recorder.items] == ["Ctrl+A", "Ctrl+Shift+A"] @@ -598,5 +598,5 @@ def test_an_order_item_selects_the_row_the_menu_was_raised_on( region = states[-1].region assert region is not None - assert region.generators == (None,) + assert region.channels == (None,) assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py index 15503553a..288e5de42 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py @@ -8,7 +8,7 @@ ChannelSwitch, channel_tooltip, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName LABELS = ChannelMenuLabels( mute="Mute", @@ -43,8 +43,8 @@ def _switch() -> ChannelSwitch: """A switch whose hooks are inert, for reading the items it builds.""" return ChannelSwitch( labels=LABELS, - on_mute_toggled=lambda generator: None, - on_soloed=lambda generator: None, + on_mute_toggled=lambda channel: None, + on_soloed=lambda channel: None, on_toggled=lambda: None, on_muted=lambda: None, on_unmuted=lambda: None, @@ -63,7 +63,7 @@ class TestMenuBeforeTheFirstModel: """A table whose menu opens before the first mute set arrives reads every channel as audible.""" def test_a_channel_offers_to_mute_and_to_solo(self, menu: _MenuRecorder) -> None: - _switch().add_menu_items(GeneratorName.TRIANGLE, None) + _switch().add_menu_items(ChannelName.TRIANGLE, None) assert menu.labels == [ LABELS.mute, @@ -91,19 +91,19 @@ class TestClickRouting: def test_a_channel_click_carries_its_channel(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(channels_module.dpg, "set_value", lambda item, value: None) monkeypatch.setattr(channels_module, "capture_modifiers", frozenset) - toggled: List[GeneratorName] = [] + toggled: List[ChannelName] = [] switch = ChannelSwitch( labels=LABELS, on_mute_toggled=toggled.append, - on_soloed=lambda generator: pytest.fail("a plain click must not solo"), + on_soloed=lambda channel: pytest.fail("a plain click must not solo"), on_toggled=lambda: pytest.fail("a channel click addresses one channel"), on_muted=lambda: None, on_unmuted=lambda: None, ) - switch.click(0, GeneratorName.PULSE1) + switch.click(0, ChannelName.PULSE1) - assert toggled == [GeneratorName.PULSE1] + assert toggled == [ChannelName.PULSE1] def test_the_click_releases_the_selectable(self, monkeypatch: pytest.MonkeyPatch) -> None: released: Dict[int, bool] = {} diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py index 64ab59507..91629fd8a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py @@ -9,13 +9,13 @@ tracker_table_column, tracker_table_row, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName _CHANNEL_COLUMNS = [ - (GeneratorName.PULSE1, 4), - (GeneratorName.PULSE2, 5), - (GeneratorName.TRIANGLE, 6), - (GeneratorName.NOISE, 7), + (ChannelName.PULSE1, 4), + (ChannelName.PULSE2, 5), + (ChannelName.TRIANGLE, 6), + (ChannelName.NOISE, 7), ] _PATTERN_ROWS = [(0, 1), (1, 2), (5, 6), (63, 64)] @@ -26,20 +26,20 @@ def test_sample_column_directly_precedes_the_divider() -> None: assert DIVIDER_TABLE_COLUMN == SAMPLE_TABLE_COLUMN + 1 -@pytest.mark.parametrize("generator, expected_column", _CHANNEL_COLUMNS) -def test_channels_sit_one_slot_past_the_divider(generator: GeneratorName, expected_column: int) -> None: - assert tracker_table_column(generator) == expected_column +@pytest.mark.parametrize("channel, expected_column", _CHANNEL_COLUMNS) +def test_channels_sit_one_slot_past_the_divider(channel: ChannelName, expected_column: int) -> None: + assert tracker_table_column(channel) == expected_column def test_no_logical_column_lands_on_the_divider() -> None: - mapped = {tracker_table_column(None)} | {tracker_table_column(generator) for generator in GeneratorName.items()} + mapped = {tracker_table_column(None)} | {tracker_table_column(channel) for channel in ChannelName.items()} assert DIVIDER_TABLE_COLUMN not in mapped - assert len(mapped) == len(GeneratorName.items()) + 1 + assert len(mapped) == len(ChannelName.items()) + 1 def test_every_mapped_column_lies_within_the_table() -> None: - mapped = {tracker_table_column(None)} | {tracker_table_column(generator) for generator in GeneratorName.items()} + mapped = {tracker_table_column(None)} | {tracker_table_column(channel) for channel in ChannelName.items()} assert DIVIDER_TABLE_COLUMN < TRACKER_TABLE_COLUMNS assert max(mapped) < TRACKER_TABLE_COLUMNS diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py index 47dbb8ee1..73456851c 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py @@ -20,7 +20,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback @@ -54,27 +54,27 @@ LABEL_MUTE_ALL = "Mute all channels" LABEL_UNMUTE_ALL = "Unmute all channels" -ROW_LABELS: Dict[Optional[GeneratorName], str] = { +ROW_LABELS: Dict[Optional[ChannelName], str] = { None: "Master", - GeneratorName.PULSE1: "Pulse 1", - GeneratorName.PULSE2: "Pulse 2", - GeneratorName.TRIANGLE: "Triangle", - GeneratorName.NOISE: "Noise", + ChannelName.PULSE1: "Pulse 1", + ChannelName.PULSE2: "Pulse 2", + ChannelName.TRIANGLE: "Triangle", + ChannelName.NOISE: "Noise", } -CHANNEL_TABLE_ROWS: Dict[GeneratorName, int] = { - GeneratorName.PULSE1: 2, - GeneratorName.PULSE2: 3, - GeneratorName.TRIANGLE: 4, - GeneratorName.NOISE: 5, +CHANNEL_TABLE_ROWS: Dict[ChannelName, int] = { + ChannelName.PULSE1: 2, + ChannelName.PULSE2: 3, + ChannelName.TRIANGLE: 4, + ChannelName.NOISE: 5, } """Each channel's table row: the master row, the divider beneath it, then the four channels.""" -LABEL_WIDGETS: Dict[Optional[GeneratorName], Sender] = {row: 300 + index for index, row in enumerate(ROW_LABELS)} +LABEL_WIDGETS: Dict[Optional[ChannelName], Sender] = {row: 300 + index for index, row in enumerate(ROW_LABELS)} -def _entry_widget(generator: GeneratorName, position: int) -> int: - return 2000 + 100 * GeneratorName.items().index(generator) + position +def _entry_widget(channel: ChannelName, position: int) -> int: + return 2000 + 100 * ChannelName.items().index(channel) + position class _DearPyGuiRecorder: @@ -131,7 +131,7 @@ def click(self, label: str) -> None: self.callbacks[label]() -def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel: +def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerOrderPanel: """Builds a panel around the state the channel cues read, with no DearPyGui context. The cues touch the layout colours, the theme ids, the row labels, and the entry registry, so @@ -159,9 +159,9 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel: panel._entry_theme = ENTRY_THEME panel._muted_entry_theme = MUTED_ENTRY_THEME panel._order = EditableCells() - for generator in GeneratorName.items(): + for channel in ChannelName.items(): for position in range(POSITION_COUNT): - panel._order.register((generator, position), _entry_widget(generator, position)) + panel._order.register((channel, position), _entry_widget(channel, position)) panel._create_channel_switch(LanguageManager(LANG_EN)) return panel @@ -197,7 +197,7 @@ def _hold(monkeypatch: pytest.MonkeyPatch, modifiers: ModifierSet) -> None: monkeypatch.setattr(channels_module, "capture_modifiers", lambda: modifiers) -def _right_click(panel: GUISequencerOrderPanel, row: Optional[GeneratorName]) -> None: +def _right_click(panel: GUISequencerOrderPanel, row: Optional[ChannelName]) -> None: panel._on_label_right_clicked( LABEL_WIDGET_ID, (order_module.dpg.mvMouseButton_Right, LABEL_WIDGETS[row]), @@ -212,12 +212,12 @@ def test_plain_click_toggles_the_clicked_channel( ) -> None: panel = _panel(frozenset()) _hold(monkeypatch, NO_MODIFIERS) - toggled: List[GeneratorName] = [] + toggled: List[ChannelName] = [] panel.on_channel_mute_toggled = toggled.append - panel._on_label_clicked(LABEL_WIDGET_ID, True, GeneratorName.TRIANGLE) + panel._on_label_clicked(LABEL_WIDGET_ID, True, ChannelName.TRIANGLE) - assert toggled == [GeneratorName.TRIANGLE] + assert toggled == [ChannelName.TRIANGLE] def test_ctrl_click_solos_the_clicked_channel( self, @@ -226,13 +226,13 @@ def test_ctrl_click_solos_the_clicked_channel( ) -> None: panel = _panel(frozenset()) _hold(monkeypatch, CTRL) - soloed: List[GeneratorName] = [] + soloed: List[ChannelName] = [] panel.on_channel_soloed = soloed.append panel.on_channel_mute_toggled = lambda _: pytest.fail("Ctrl+click must not toggle") - panel._on_label_clicked(LABEL_WIDGET_ID, True, GeneratorName.NOISE) + panel._on_label_clicked(LABEL_WIDGET_ID, True, ChannelName.NOISE) - assert soloed == [GeneratorName.NOISE] + assert soloed == [ChannelName.NOISE] def test_master_label_switches_every_channel( self, @@ -258,7 +258,7 @@ def test_every_label_click_releases_the_selectable( self, recorder: _DearPyGuiRecorder, monkeypatch: pytest.MonkeyPatch, - row: Optional[GeneratorName], + row: Optional[ChannelName], ) -> None: panel = _panel(frozenset()) _hold(monkeypatch, NO_MODIFIERS) @@ -276,7 +276,7 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor panel._apply_channel_cues() - assert recorder.row_tints[CHANNEL_TABLE_ROWS[GeneratorName.PULSE1]] == ( + assert recorder.row_tints[CHANNEL_TABLE_ROWS[ChannelName.PULSE1]] == ( 240, 146, 86, @@ -284,25 +284,23 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor ) def test_muted_channel_takes_the_neutral_wash(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE1})) + panel = _panel(frozenset({ChannelName.PULSE1})) panel._apply_channel_cues() - assert recorder.row_tints[CHANNEL_TABLE_ROWS[GeneratorName.PULSE1]] == MUTED_BACKGROUND + assert recorder.row_tints[CHANNEL_TABLE_ROWS[ChannelName.PULSE1]] == MUTED_BACKGROUND def test_the_other_channels_keep_their_tint_while_one_is_muted(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) + panel = _panel(frozenset({ChannelName.TRIANGLE})) panel._apply_channel_cues() - washed = { - generator for generator, row in CHANNEL_TABLE_ROWS.items() if recorder.row_tints[row] == MUTED_BACKGROUND - } - assert washed == {GeneratorName.TRIANGLE} + washed = {channel for channel, row in CHANNEL_TABLE_ROWS.items() if recorder.row_tints[row] == MUTED_BACKGROUND} + assert washed == {ChannelName.TRIANGLE} def test_the_master_row_carries_no_channel_wash(self, recorder: _DearPyGuiRecorder) -> None: """The master row stands for every channel, so it takes its own shade instead of a tint.""" - panel = _panel(frozenset(GeneratorName.items())) + panel = _panel(frozenset(ChannelName.items())) panel._apply_channel_cues() @@ -310,31 +308,31 @@ def test_the_master_row_carries_no_channel_wash(self, recorder: _DearPyGuiRecord def test_the_wash_matches_the_shade_the_tracker_column_takes(self, recorder: _DearPyGuiRecorder) -> None: """Both tables read the same colour, so a silenced channel looks the same in each.""" - panel = _panel(frozenset({GeneratorName.NOISE})) + panel = _panel(frozenset({ChannelName.NOISE})) - assert panel._channel_row_tint(GeneratorName.NOISE) == MUTED_BACKGROUND + assert panel._channel_row_tint(ChannelName.NOISE) == MUTED_BACKGROUND class TestRowLabelShade: def test_muted_channel_label_takes_the_muted_shade(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE2})) + panel = _panel(frozenset({ChannelName.PULSE2})) panel._apply_channel_cues() - assert recorder.bound_themes[LABEL_WIDGETS[GeneratorName.PULSE2]] == MUTED_LABEL_THEME + assert recorder.bound_themes[LABEL_WIDGETS[ChannelName.PULSE2]] == MUTED_LABEL_THEME def test_audible_channel_label_keeps_the_plain_shade(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE2})) + panel = _panel(frozenset({ChannelName.PULSE2})) panel._apply_channel_cues() - assert recorder.bound_themes[LABEL_WIDGETS[GeneratorName.NOISE]] == LABEL_THEME + assert recorder.bound_themes[LABEL_WIDGETS[ChannelName.NOISE]] == LABEL_THEME def test_master_label_keeps_the_plain_shade_with_every_channel_muted( self, recorder: _DearPyGuiRecorder, ) -> None: - panel = _panel(frozenset(GeneratorName.items())) + panel = _panel(frozenset(ChannelName.items())) panel._apply_channel_cues() @@ -343,33 +341,33 @@ def test_master_label_keeps_the_plain_shade_with_every_channel_muted( class TestEntryTextShade: def test_muted_channel_entries_take_the_dimmed_theme(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) + panel = _panel(frozenset({ChannelName.TRIANGLE})) panel._apply_channel_cues() bound = { - recorder.bound_themes[_entry_widget(GeneratorName.TRIANGLE, position)] for position in range(POSITION_COUNT) + recorder.bound_themes[_entry_widget(ChannelName.TRIANGLE, position)] for position in range(POSITION_COUNT) } assert bound == {MUTED_ENTRY_THEME} def test_audible_channel_entries_keep_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) + panel = _panel(frozenset({ChannelName.TRIANGLE})) panel._apply_channel_cues() bound = { - recorder.bound_themes[_entry_widget(GeneratorName.PULSE1, position)] for position in range(POSITION_COUNT) + recorder.bound_themes[_entry_widget(ChannelName.PULSE1, position)] for position in range(POSITION_COUNT) } assert bound == {ENTRY_THEME} def test_unmuting_restores_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.NOISE})) + panel = _panel(frozenset({ChannelName.NOISE})) panel._apply_channel_cues() panel.update_channels(SequencerChannelsViewModel(muted=frozenset())) bound = { - recorder.bound_themes[_entry_widget(GeneratorName.NOISE, position)] for position in range(POSITION_COUNT) + recorder.bound_themes[_entry_widget(ChannelName.NOISE, position)] for position in range(POSITION_COUNT) } assert bound == {ENTRY_THEME} @@ -378,9 +376,9 @@ class TestPushedModel: def test_the_pushed_model_is_kept_for_the_next_rebuild(self, recorder: _DearPyGuiRecorder) -> None: panel = _panel(frozenset()) - panel.update_channels(SequencerChannelsViewModel(muted=frozenset({GeneratorName.PULSE1}))) + panel.update_channels(SequencerChannelsViewModel(muted=frozenset({ChannelName.PULSE1}))) - assert panel._is_muted(GeneratorName.PULSE1) + assert panel._is_muted(ChannelName.PULSE1) def test_a_panel_awaiting_its_first_model_reports_every_channel_audible( self, @@ -389,16 +387,16 @@ def test_a_panel_awaiting_its_first_model_reports_every_channel_audible( panel = _panel(frozenset()) panel._current_channels = None - assert not any(panel._is_muted(generator) for generator in GeneratorName.items()) + assert not any(panel._is_muted(channel) for channel in ChannelName.items()) class TestRowLabelRightClickRouting: def test_a_right_click_opens_the_menu_for_the_clicked_row(self, menu: _MenuRecorder) -> None: panel = _panel(frozenset()) - _right_click(panel, GeneratorName.TRIANGLE) + _right_click(panel, ChannelName.TRIANGLE) - assert menu.titles == [ROW_LABELS[GeneratorName.TRIANGLE]] + assert menu.titles == [ROW_LABELS[ChannelName.TRIANGLE]] def test_the_master_label_opens_the_whole_mix_menu(self, menu: _MenuRecorder) -> None: panel = _panel(frozenset()) @@ -413,7 +411,7 @@ def test_a_left_click_opens_no_menu(self, menu: _MenuRecorder) -> None: panel._on_label_right_clicked( LABEL_WIDGET_ID, - (order_module.dpg.mvMouseButton_Left, LABEL_WIDGETS[GeneratorName.NOISE]), + (order_module.dpg.mvMouseButton_Left, LABEL_WIDGETS[ChannelName.NOISE]), ) assert menu.titles == [] @@ -441,14 +439,14 @@ class TestRowLabelMenuItems: def test_a_channel_menu_carries_its_own_gestures_and_the_whole_mix(self, menu: _MenuRecorder) -> None: panel = _panel(frozenset()) - _right_click(panel, GeneratorName.PULSE1) + _right_click(panel, ChannelName.PULSE1) assert menu.labels == [LABEL_MUTE, LABEL_SOLO, LABEL_MUTE_ALL, LABEL_UNMUTE_ALL] def test_the_items_name_the_change_they_make(self, menu: _MenuRecorder) -> None: - panel = _panel(frozenset(GeneratorName.items()) - {GeneratorName.PULSE1}) + panel = _panel(frozenset(ChannelName.items()) - {ChannelName.PULSE1}) - _right_click(panel, GeneratorName.PULSE1) + _right_click(panel, ChannelName.PULSE1) assert menu.labels == [ LABEL_MUTE, @@ -458,9 +456,9 @@ def test_the_items_name_the_change_they_make(self, menu: _MenuRecorder) -> None: ] def test_muting_everything_is_withheld_in_full_silence(self, menu: _MenuRecorder) -> None: - panel = _panel(frozenset(GeneratorName.items())) + panel = _panel(frozenset(ChannelName.items())) - _right_click(panel, GeneratorName.NOISE) + _right_click(panel, ChannelName.NOISE) assert not menu.is_enabled(LABEL_MUTE_ALL) assert menu.is_enabled(LABEL_UNMUTE_ALL) @@ -468,33 +466,33 @@ def test_muting_everything_is_withheld_in_full_silence(self, menu: _MenuRecorder def test_restoring_everything_is_withheld_in_the_full_mix(self, menu: _MenuRecorder) -> None: panel = _panel(frozenset()) - _right_click(panel, GeneratorName.NOISE) + _right_click(panel, ChannelName.NOISE) assert menu.is_enabled(LABEL_MUTE_ALL) assert not menu.is_enabled(LABEL_UNMUTE_ALL) def test_the_mute_item_switches_the_clicked_channel(self, menu: _MenuRecorder) -> None: panel = _panel(frozenset()) - toggled: List[GeneratorName] = [] + toggled: List[ChannelName] = [] panel.on_channel_mute_toggled = toggled.append - _right_click(panel, GeneratorName.PULSE2) + _right_click(panel, ChannelName.PULSE2) menu.click(LABEL_MUTE) - assert toggled == [GeneratorName.PULSE2] + assert toggled == [ChannelName.PULSE2] def test_the_solo_item_solos_the_clicked_channel(self, menu: _MenuRecorder) -> None: panel = _panel(frozenset()) - soloed: List[GeneratorName] = [] + soloed: List[ChannelName] = [] panel.on_channel_soloed = soloed.append - _right_click(panel, GeneratorName.TRIANGLE) + _right_click(panel, ChannelName.TRIANGLE) menu.click(LABEL_SOLO) - assert soloed == [GeneratorName.TRIANGLE] + assert soloed == [ChannelName.TRIANGLE] def test_the_whole_mix_items_reach_their_own_hooks(self, menu: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.NOISE})) + panel = _panel(frozenset({ChannelName.NOISE})) calls: List[str] = [] panel.on_channels_muted = lambda: calls.append("muted") panel.on_channels_unmuted = lambda: calls.append("unmuted") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 204bfd083..47e3244f7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -10,7 +10,7 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.shortcuts import shipped_source POSITION_COUNT = 4 @@ -38,7 +38,7 @@ class OrderPanelFixture: def order(monkeypatch: pytest.MonkeyPatch) -> OrderPanelFixture: panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) panel._shortcuts = shipped_source() - panel._input_state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION)) + panel._input_state = OrderInputState(cursor=OrderCursor(ChannelName.PULSE1, CURSOR_POSITION)) panel._position_count = POSITION_COUNT panel._current_position = CURSOR_POSITION panel._buttons = None @@ -110,7 +110,7 @@ def test_the_move_to_end_key_moves_the_frame_last(self, order: OrderPanelFixture def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, order: OrderPanelFixture) -> None: """A boundary keeps the press, so a repeated move stays out of the global shortcuts.""" - order.panel._input_state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, 0)) + order.panel._input_state = OrderInputState(cursor=OrderCursor(ChannelName.PULSE1, 0)) assert order.panel._on_key_pressed(_press("Alt+Left")) is True assert order.moved == [] @@ -119,15 +119,15 @@ def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, order: OrderPane class TestCursorMoves: def test_the_next_position_key_moves_the_cursor_one_column_on(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("Right")) is True - assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION + 1) + assert order.states[-1].cursor == OrderCursor(ChannelName.PULSE1, CURSOR_POSITION + 1) def test_the_enter_alias_moves_the_cursor_the_same_way(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("Enter")) is True - assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION + 1) + assert order.states[-1].cursor == OrderCursor(ChannelName.PULSE1, CURSOR_POSITION + 1) def test_the_last_position_key_jumps_to_the_final_column(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("End")) is True - assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, POSITION_COUNT - 1) + assert order.states[-1].cursor == OrderCursor(ChannelName.PULSE1, POSITION_COUNT - 1) class TestCellEntry: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py index 8308e931f..570da6aa0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py @@ -6,7 +6,7 @@ OrderInputState, ) from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName POSITION_COUNT = 4 @@ -55,7 +55,7 @@ class TestRemovableFrame: """The frame ``[-]`` acts on: the cursor's frame, else the followed tracker frame.""" def test_cursor_frame_wins(self) -> None: - fixture = _panel(cursor=OrderCursor(GeneratorName.PULSE1, 2), current_position=0) + fixture = _panel(cursor=OrderCursor(ChannelName.PULSE1, 2), current_position=0) assert fixture.panel._get_removable_position() == 2 @@ -104,7 +104,7 @@ def test_disabled_on_an_empty_order(self) -> None: class TestRemoveClick: def test_click_removes_the_selected_frame(self) -> None: - fixture = _panel(cursor=OrderCursor(GeneratorName.NOISE, 3)) + fixture = _panel(cursor=OrderCursor(ChannelName.NOISE, 3)) fixture.panel._on_remove_clicked() assert fixture.removed == [3] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index 9a8504006..794f9d78f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -14,7 +14,7 @@ from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.utils.display import display_sample_label from tests.suite.shortcuts import shipped_source @@ -39,8 +39,8 @@ NOISE_BYTES = NOISE_FOOTPRINT.total_bytes FOOTPRINT = SampleFootprintViewModel.from_footprints( { - GeneratorName.PULSE1: PULSE_1_FOOTPRINT, - GeneratorName.NOISE: NOISE_FOOTPRINT, + ChannelName.PULSE1: PULSE_1_FOOTPRINT, + ChannelName.NOISE: NOISE_FOOTPRINT, } ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index 97353acc9..cf3a9713a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -35,12 +35,12 @@ from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName ROW_COUNT = 64 POSITION_COUNT = 8 ORIGIN_WIDGET = 101 -ORIGIN_CELL: CellKey = (2, GeneratorName.PULSE1, SubColumn.TRANSPOSE) +ORIGIN_CELL: CellKey = (2, ChannelName.PULSE1, SubColumn.TRANSPOSE) ORIGIN_ENTRY: OrderKey = (None, 1) @@ -152,7 +152,7 @@ def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.Mo assert states == [] def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: - reached: CellKey = (5, GeneratorName.TRIANGLE, SubColumn.VOLUME) + reached: CellKey = (5, ChannelName.TRIANGLE, SubColumn.VOLUME) panel, states = _tracker(monkeypatch, reached=reached) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -166,11 +166,11 @@ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatc ) def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: - reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) panel._input_state = TrackerInputState( - cursor=TrackerCursor(20, GeneratorName.NOISE, SubColumn.VOLUME), - anchor=TrackerCursor(30, GeneratorName.NOISE, SubColumn.VOLUME), + cursor=TrackerCursor(20, ChannelName.NOISE, SubColumn.VOLUME), + anchor=TrackerCursor(30, ChannelName.NOISE, SubColumn.VOLUME), ) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -185,20 +185,20 @@ def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pyt ) def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: - reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached, shift=True) panel._input_state = TrackerInputState( - cursor=TrackerCursor(9, GeneratorName.PULSE1, SubColumn.TRANSPOSE), - anchor=TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE), + cursor=TrackerCursor(9, ChannelName.PULSE1, SubColumn.TRANSPOSE), + anchor=TrackerCursor(9, ChannelName.PULSE2, SubColumn.TRANSPOSE), ) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) - assert states[-1].anchor == TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE) + assert states[-1].anchor == TrackerCursor(9, ChannelName.PULSE2, SubColumn.TRANSPOSE) def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: - reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -223,7 +223,7 @@ def test_a_press_on_a_cell_the_cache_forgot_selects_nothing(self, monkeypatch: p def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: """The press starting a gesture ends the one before it, so its click places the cursor.""" - reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) _silence_click(monkeypatch) @@ -240,7 +240,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( monkeypatch: pytest.MonkeyPatch, ) -> None: """A drag returning to its own cell releases there, and that release reports a click.""" - reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) _silence_click(monkeypatch) @@ -377,7 +377,7 @@ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> assert states == [] def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: - reached: OrderKey = (GeneratorName.PULSE2, 4) + reached: OrderKey = (ChannelName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -391,21 +391,21 @@ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatc ) def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: - reached: OrderKey = (GeneratorName.PULSE2, 4) + reached: OrderKey = (ChannelName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached, shift=True) panel._input_state = OrderInputState( - cursor=OrderCursor(GeneratorName.NOISE, 6), - anchor=OrderCursor(GeneratorName.NOISE, 6), + cursor=OrderCursor(ChannelName.NOISE, 6), + anchor=OrderCursor(ChannelName.NOISE, 6), ) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) - assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6) + assert states[-1].anchor == OrderCursor(ChannelName.NOISE, 6) def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: """The press starting a gesture ends the one before it, so its click places the cursor.""" - reached: OrderKey = (GeneratorName.PULSE2, 4) + reached: OrderKey = (ChannelName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) _silence_click(monkeypatch) @@ -421,7 +421,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - reached: OrderKey = (GeneratorName.PULSE2, 4) + reached: OrderKey = (ChannelName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) _silence_click(monkeypatch) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index 2b2043bfc..54de611d3 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -15,7 +15,7 @@ from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.shortcuts import shipped_source ROW_COUNT = 64 @@ -31,20 +31,20 @@ def _press(text: str) -> KeyEvent: def _tracker( - generator: Optional[GeneratorName] = GeneratorName.PULSE1, + channel: Optional[ChannelName] = ChannelName.PULSE1, subcolumn: SubColumn = SubColumn.INSTRUMENT, ) -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() - panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn)) panel._current_row_count = ROW_COUNT return panel -def _order(generator: Optional[GeneratorName] = GeneratorName.PULSE1) -> GUISequencerOrderPanel: +def _order(channel: Optional[ChannelName] = ChannelName.PULSE1) -> GUISequencerOrderPanel: panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) panel._shortcuts = shipped_source() - panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION)) + panel._input_state = OrderInputState(cursor=OrderCursor(channel, CURSOR_POSITION)) panel._position_count = POSITION_COUNT return panel @@ -101,8 +101,8 @@ def test_shift_right_selects_the_next_subcolumn(self, monkeypatch: pytest.Monkey region = states[-1].region assert region is not None assert region.slots == ( - TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), - TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE), + TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE), ) def test_shift_end_selects_to_the_last_row(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -153,7 +153,7 @@ def test_shift_up_selects_up_to_the_master_row(self, monkeypatch: pytest.MonkeyP assert panel._on_key_pressed(_press("Shift+Up")) is True region = states[-1].region assert region is not None - assert region.generators == (None, GeneratorName.PULSE1) + assert region.channels == (None, ChannelName.PULSE1) def test_shift_end_selects_to_the_last_position(self, monkeypatch: pytest.MonkeyPatch) -> None: panel = _order() @@ -195,13 +195,13 @@ def test_ctrl_a_selects_the_whole_frame(self, monkeypatch: pytest.MonkeyPatch) - assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1) def test_ctrl_shift_a_selects_the_column_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel = _tracker(generator=GeneratorName.TRIANGLE, subcolumn=SubColumn.VOLUME) + panel = _tracker(channel=ChannelName.TRIANGLE, subcolumn=SubColumn.VOLUME) states = _tracker_states(monkeypatch, panel) assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True region = states[-1].region assert region is not None - assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn) + assert region.slots == tuple(TrackerSlot(ChannelName.TRIANGLE, subcolumn) for subcolumn in SubColumn) def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: panel = _tracker(subcolumn=SubColumn.VOLUME) @@ -210,7 +210,7 @@ def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch assert panel._on_key_pressed(_press("Ctrl+Alt+A")) is True region = states[-1].region assert region is not None - assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),) def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None: """A Shift+Up straight after shrinks the selection from the row the shape ended on.""" @@ -218,7 +218,7 @@ def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pyte states = _tracker_states(monkeypatch, panel) assert panel._on_key_pressed(_press("Ctrl+A")) is True - assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.NOISE, SubColumn.VOLUME) + assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, ChannelName.NOISE, SubColumn.VOLUME) class TestOrderSelectKeys: @@ -231,17 +231,17 @@ def test_ctrl_a_selects_the_whole_order(self, monkeypatch: pytest.MonkeyPatch) - assert panel._on_key_pressed(_press("Ctrl+A")) is True region = states[-1].region assert region is not None - assert region.generators == CHANNEL_AXIS + assert region.channels == CHANNEL_AXIS assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) def test_ctrl_shift_a_selects_the_row_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel = _order(generator=None) + panel = _order(channel=None) states = _order_states(monkeypatch, panel) assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True region = states[-1].region assert region is not None - assert region.generators == (None,) + assert region.channels == (None,) assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index 535a10796..e6d2111a4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -21,7 +21,7 @@ SequencerChannelsViewModel, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import ColorRGBA, Sender HEADER_WIDGET_ID = 7100 @@ -75,19 +75,19 @@ def set_value(self, item: Sender, value: bool) -> None: self.released.append(item) -HEADER_COLUMNS: Tuple[Optional[GeneratorName], ...] = (None, *GeneratorName.items()) +HEADER_COLUMNS: Tuple[Optional[ChannelName], ...] = (None, *ChannelName.items()) -def _header_widget(generator: Optional[GeneratorName]) -> int: +def _header_widget(channel: Optional[ChannelName]) -> int: """A stable stand-in widget id per header column.""" - return 100 + HEADER_COLUMNS.index(generator) + return 100 + HEADER_COLUMNS.index(channel) -def _cell_widget(generator: GeneratorName, row_index: int, subcolumn: SubColumn) -> int: - return 1000 + 100 * GeneratorName.items().index(generator) + 10 * row_index + list(SubColumn).index(subcolumn) +def _cell_widget(channel: ChannelName, row_index: int, subcolumn: SubColumn) -> int: + return 1000 + 100 * ChannelName.items().index(channel) + 10 * row_index + list(SubColumn).index(subcolumn) -def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel: +def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: """Builds a panel around the state the channel cues read, with no DearPyGui context. The cues touch the layout colours, the theme ids, the header widgets, and the cell @@ -110,15 +110,15 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel: panel._muted_header_theme = MUTED_HEADER_THEME panel._subcolumn_themes = dict(SUBCOLUMN_THEMES) panel._muted_subcolumn_themes = dict(MUTED_SUBCOLUMN_THEMES) - panel._header_columns = {_header_widget(generator): generator for generator in HEADER_COLUMNS} + panel._header_columns = {_header_widget(channel): channel for channel in HEADER_COLUMNS} panel._create_channel_switch(LanguageManager(LANG_EN)) panel._editable_cells = EditableCells() - for generator in GeneratorName.items(): + for channel in ChannelName.items(): for row_index in range(ROW_COUNT): for subcolumn in SubColumn: panel._editable_cells.register( - (row_index, generator, subcolumn), - _cell_widget(generator, row_index, subcolumn), + (row_index, channel, subcolumn), + _cell_widget(channel, row_index, subcolumn), ) return panel @@ -146,12 +146,12 @@ def test_plain_click_toggles_the_clicked_channel( ) -> None: panel = _panel(frozenset()) _hold(monkeypatch, NO_MODIFIERS) - toggled: List[GeneratorName] = [] + toggled: List[ChannelName] = [] panel.on_channel_mute_toggled = toggled.append - panel._on_header_clicked(HEADER_WIDGET_ID, True, GeneratorName.TRIANGLE) + panel._on_header_clicked(HEADER_WIDGET_ID, True, ChannelName.TRIANGLE) - assert toggled == [GeneratorName.TRIANGLE] + assert toggled == [ChannelName.TRIANGLE] def test_ctrl_click_solos_the_clicked_channel( self, @@ -160,13 +160,13 @@ def test_ctrl_click_solos_the_clicked_channel( ) -> None: panel = _panel(frozenset()) _hold(monkeypatch, CTRL) - soloed: List[GeneratorName] = [] + soloed: List[ChannelName] = [] panel.on_channel_soloed = soloed.append panel.on_channel_mute_toggled = lambda _: pytest.fail("Ctrl+click must not toggle") - panel._on_header_clicked(HEADER_WIDGET_ID, True, GeneratorName.NOISE) + panel._on_header_clicked(HEADER_WIDGET_ID, True, ChannelName.NOISE) - assert soloed == [GeneratorName.NOISE] + assert soloed == [ChannelName.NOISE] def test_sample_header_switches_every_channel( self, @@ -199,22 +199,22 @@ def test_ctrl_on_the_sample_header_still_switches_every_channel( assert switches == [None] @pytest.mark.parametrize( - "generator", + "channel", HEADER_COLUMNS, - ids=lambda generator: "sample" if generator is None else generator.value, + ids=lambda channel: "sample" if channel is None else channel.value, ) def test_every_header_click_releases_the_selectable( self, recorder: _DearPyGuiRecorder, monkeypatch: pytest.MonkeyPatch, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: panel = _panel(frozenset()) _hold(monkeypatch, NO_MODIFIERS) panel.on_channels_toggled = lambda: None panel.on_channel_mute_toggled = lambda _: None - panel._on_header_clicked(HEADER_WIDGET_ID, True, generator) + panel._on_header_clicked(HEADER_WIDGET_ID, True, channel) assert recorder.released == [HEADER_WIDGET_ID] @@ -225,58 +225,58 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor panel._apply_channel_cues() - column = tracker_table_column(GeneratorName.PULSE1) + column = tracker_table_column(ChannelName.PULSE1) assert recorder.column_tints[column] == (240, 146, 86, 128) def test_muted_channel_takes_the_neutral_wash(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE1})) + panel = _panel(frozenset({ChannelName.PULSE1})) panel._apply_channel_cues() - column = tracker_table_column(GeneratorName.PULSE1) + column = tracker_table_column(ChannelName.PULSE1) assert recorder.column_tints[column] == MUTED_BACKGROUND def test_the_other_channels_keep_their_tint_while_one_is_muted(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) + panel = _panel(frozenset({ChannelName.TRIANGLE})) panel._apply_channel_cues() washed = { - generator - for generator in GeneratorName.items() - if recorder.column_tints[tracker_table_column(generator)] == MUTED_BACKGROUND + channel + for channel in ChannelName.items() + if recorder.column_tints[tracker_table_column(channel)] == MUTED_BACKGROUND } - assert washed == {GeneratorName.TRIANGLE} + assert washed == {ChannelName.TRIANGLE} def test_every_channel_column_is_painted(self, recorder: _DearPyGuiRecorder) -> None: panel = _panel(frozenset()) panel._apply_channel_cues() - expected = {tracker_table_column(generator) for generator in GeneratorName.items()} + expected = {tracker_table_column(channel) for channel in ChannelName.items()} assert expected <= set(recorder.column_tints) class TestHeaderLabelShade: def test_muted_channel_label_takes_the_muted_shade(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE2})) + panel = _panel(frozenset({ChannelName.PULSE2})) panel._apply_channel_cues() - assert recorder.bound_themes[_header_widget(GeneratorName.PULSE2)] == MUTED_HEADER_THEME + assert recorder.bound_themes[_header_widget(ChannelName.PULSE2)] == MUTED_HEADER_THEME def test_audible_channel_label_keeps_the_plain_shade(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE2})) + panel = _panel(frozenset({ChannelName.PULSE2})) panel._apply_channel_cues() - assert recorder.bound_themes[_header_widget(GeneratorName.NOISE)] == HEADER_THEME + assert recorder.bound_themes[_header_widget(ChannelName.NOISE)] == HEADER_THEME def test_sample_label_keeps_the_plain_shade_with_every_channel_muted( self, recorder: _DearPyGuiRecorder, ) -> None: - panel = _panel(frozenset(GeneratorName.items())) + panel = _panel(frozenset(ChannelName.items())) panel._apply_channel_cues() @@ -285,45 +285,45 @@ def test_sample_label_keeps_the_plain_shade_with_every_channel_muted( class TestCellTextShade: def test_muted_channel_cells_take_the_dimmed_theme(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) + panel = _panel(frozenset({ChannelName.TRIANGLE})) panel._apply_channel_cues() bound = { - recorder.bound_themes[_cell_widget(GeneratorName.TRIANGLE, row_index, subcolumn)] + recorder.bound_themes[_cell_widget(ChannelName.TRIANGLE, row_index, subcolumn)] for row_index in range(ROW_COUNT) for subcolumn in SubColumn } assert bound == set(MUTED_SUBCOLUMN_THEMES.values()) def test_audible_channel_cells_take_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) + panel = _panel(frozenset({ChannelName.TRIANGLE})) panel._apply_channel_cues() bound = { - recorder.bound_themes[_cell_widget(GeneratorName.PULSE1, row_index, subcolumn)] + recorder.bound_themes[_cell_widget(ChannelName.PULSE1, row_index, subcolumn)] for row_index in range(ROW_COUNT) for subcolumn in SubColumn } assert bound == set(SUBCOLUMN_THEMES.values()) def test_each_subcolumn_keeps_its_own_hue_when_dimmed(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.NOISE})) + panel = _panel(frozenset({ChannelName.NOISE})) panel._apply_channel_cues() for subcolumn in SubColumn: - widget = _cell_widget(GeneratorName.NOISE, 0, subcolumn) + widget = _cell_widget(ChannelName.NOISE, 0, subcolumn) assert recorder.bound_themes[widget] == MUTED_SUBCOLUMN_THEMES[subcolumn] def test_unmuting_restores_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None: - panel = _panel(frozenset({GeneratorName.NOISE})) + panel = _panel(frozenset({ChannelName.NOISE})) panel._apply_channel_cues() panel.update_channels(SequencerChannelsViewModel(muted=frozenset())) - widget = _cell_widget(GeneratorName.NOISE, 1, SubColumn.VOLUME) + widget = _cell_widget(ChannelName.NOISE, 1, SubColumn.VOLUME) assert recorder.bound_themes[widget] == SUBCOLUMN_THEMES[SubColumn.VOLUME] @@ -340,11 +340,11 @@ def test_the_model_is_kept_while_the_table_is_absent(self, monkeypatch: pytest.M monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", instance.bind_item_theme) panel = _panel(frozenset()) - panel.update_channels(SequencerChannelsViewModel(muted=frozenset({GeneratorName.PULSE1}))) + panel.update_channels(SequencerChannelsViewModel(muted=frozenset({ChannelName.PULSE1}))) assert not instance.column_tints assert not instance.bound_themes - assert panel._is_muted(GeneratorName.PULSE1) + assert panel._is_muted(ChannelName.PULSE1) class TestMuteStateReading: @@ -352,19 +352,19 @@ class TestMuteStateReading: "muted, expected", [ (frozenset(), set()), - (frozenset({GeneratorName.PULSE1}), {GeneratorName.PULSE1}), - (frozenset(GeneratorName.items()), set(GeneratorName.items())), + (frozenset({ChannelName.PULSE1}), {ChannelName.PULSE1}), + (frozenset(ChannelName.items()), set(ChannelName.items())), ], ids=["full mix", "one silenced", "every channel silenced"], ) def test_is_muted_follows_the_pushed_model( self, - muted: FrozenSet[GeneratorName], - expected: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], + expected: FrozenSet[ChannelName], ) -> None: panel = _panel(muted) - reported = {generator for generator in GeneratorName.items() if panel._is_muted(generator)} + reported = {channel for channel in ChannelName.items() if panel._is_muted(channel)} assert reported == expected @@ -372,25 +372,25 @@ def test_no_channel_reads_as_muted_before_a_model_arrives(self) -> None: panel = _panel(frozenset()) panel._current_channels = None - assert not any(panel._is_muted(generator) for generator in GeneratorName.items()) + assert not any(panel._is_muted(channel) for channel in ChannelName.items()) class TestChannelTintColour: @pytest.mark.parametrize( - "generator, expected", + "channel, expected", [ - (GeneratorName.PULSE1, (240, 146, 86, 128)), - (GeneratorName.PULSE2, (242, 209, 95, 128)), - (GeneratorName.TRIANGLE, (140, 193, 237, 128)), - (GeneratorName.NOISE, (187, 184, 194, 128)), + (ChannelName.PULSE1, (240, 146, 86, 128)), + (ChannelName.PULSE2, (242, 209, 95, 128)), + (ChannelName.TRIANGLE, (140, 193, 237, 128)), + (ChannelName.NOISE, (187, 184, 194, 128)), ], - ids=lambda value: value.value if isinstance(value, GeneratorName) else "", + ids=lambda value: value.value if isinstance(value, ChannelName) else "", ) def test_audible_tint_is_the_identity_colour_at_the_configured_fraction( self, - generator: GeneratorName, + channel: ChannelName, expected: Tuple[int, int, int, int], ) -> None: panel = _panel(frozenset()) - assert panel._channel_column_tint(generator) == expected + assert panel._channel_column_tint(channel) == expected diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 582850156..96b4d70ee 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -12,7 +12,7 @@ SequencerSamplesViewModel, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from tests.suite.shortcuts import shipped_source @@ -80,14 +80,14 @@ def _menu(**kwargs: Any) -> Iterator[None]: return instance -def _cell(row: int, generator: GeneratorName) -> TrackerCursor: +def _cell(row: int, channel: ChannelName) -> TrackerCursor: """The cell a menu was raised on, which the items carry as their payload.""" - return TrackerCursor(row, generator, SubColumn.INSTRUMENT) + return TrackerCursor(row, channel, SubColumn.INSTRUMENT) -def _target(row: int, generator: GeneratorName) -> TrackerTarget: +def _target(row: int, channel: ChannelName) -> TrackerTarget: """The cell a menu was raised on, paired with the block of that cell alone.""" - cell = _cell(row, generator) + cell = _cell(row, channel) return TrackerTarget(cell=cell, region=TrackerInputState().region_at(cell)) @@ -97,7 +97,7 @@ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecor deltas: List[int] = [] panel.on_adjust_transpose = lambda region, delta: deltas.append(delta) - panel._add_transpose_items(_target(2, GeneratorName.PULSE1)) + panel._add_transpose_items(_target(2, ChannelName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -112,7 +112,7 @@ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder deltas: List[int] = [] panel.on_adjust_volume = lambda region, delta: deltas.append(delta) - panel._add_volume_items(_target(2, GeneratorName.PULSE1)) + panel._add_volume_items(_target(2, ChannelName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -126,7 +126,7 @@ def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuIt panel = _panel() calls: List[Tuple[TrackerRegion, int]] = [] panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta)) - target = _target(7, GeneratorName.TRIANGLE) + target = _target(7, ChannelName.TRIANGLE) panel._add_transpose_items(target) recorder.dispatch_as_dpg() @@ -145,9 +145,9 @@ def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) ), ) chosen: List[str] = [] - panel.on_set_row = lambda row, generator, sample_id, transpose, volume: chosen.append(sample_id) + panel.on_set_row = lambda row, channel, sample_id, transpose, volume: chosen.append(sample_id) - panel._add_instrument_submenu(_cell(0, GeneratorName.PULSE2)) + panel._add_instrument_submenu(_cell(0, ChannelName.PULSE2)) recorder.dispatch_as_dpg() assert chosen == ["lead-id"] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py index 457758d15..1aa7c74f1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py @@ -12,7 +12,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback @@ -30,25 +30,25 @@ LABEL_UNMUTE_ALL = "Unmute all channels" ALL_CHANNEL_LABELS = [LABEL_MUTE_ALL, LABEL_UNMUTE_ALL] -COLUMN_LABELS: Dict[Optional[GeneratorName], str] = { +COLUMN_LABELS: Dict[Optional[ChannelName], str] = { None: "Sample", - GeneratorName.PULSE1: "Pulse 1", - GeneratorName.PULSE2: "Pulse 2", - GeneratorName.TRIANGLE: "Triangle", - GeneratorName.NOISE: "Noise", + ChannelName.PULSE1: "Pulse 1", + ChannelName.PULSE2: "Pulse 2", + ChannelName.TRIANGLE: "Triangle", + ChannelName.NOISE: "Noise", } -HEADER_WIDGETS: Dict[Optional[GeneratorName], Sender] = { +HEADER_WIDGETS: Dict[Optional[ChannelName], Sender] = { column: 200 + index for index, column in enumerate(COLUMN_LABELS) } -FULL_MIX: FrozenSet[GeneratorName] = frozenset() -EVERY_CHANNEL: FrozenSet[GeneratorName] = frozenset(GeneratorName.items()) +FULL_MIX: FrozenSet[ChannelName] = frozenset() +EVERY_CHANNEL: FrozenSet[ChannelName] = frozenset(ChannelName.items()) -def _others(generator: GeneratorName) -> FrozenSet[GeneratorName]: - """The mute set a solo of ``generator`` leaves behind.""" - return EVERY_CHANNEL - {generator} +def _others(channel: ChannelName) -> FrozenSet[ChannelName]: + """The mute set a solo of ``channel`` leaves behind.""" + return EVERY_CHANNEL - {channel} class _MenuRecorder: @@ -84,7 +84,7 @@ def click(self, label: str) -> None: self.callbacks[label]() -def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel: +def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: """Builds a panel around the state the header menu reads, with no DearPyGui context. The menu touches the column labels, the pushed mute set, and the map from header widget to @@ -116,7 +116,7 @@ def _popup() -> Iterator[None]: return instance -def _right_click(panel: GUISequencerTrackerPanel, column: Optional[GeneratorName]) -> None: +def _right_click(panel: GUISequencerTrackerPanel, column: Optional[ChannelName]) -> None: panel._on_header_right_clicked( SENDER_WIDGET_ID, (dpg.mvMouseButton_Right, HEADER_WIDGETS[column]), @@ -127,9 +127,9 @@ class TestHeaderRightClickRouting: def test_a_right_click_opens_the_menu_for_the_clicked_column(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) - _right_click(panel, GeneratorName.TRIANGLE) + _right_click(panel, ChannelName.TRIANGLE) - assert recorder.titles == [COLUMN_LABELS[GeneratorName.TRIANGLE]] + assert recorder.titles == [COLUMN_LABELS[ChannelName.TRIANGLE]] def test_the_sample_header_opens_the_whole_mix_menu(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) @@ -149,7 +149,7 @@ def test_only_the_right_button_opens_the_menu(self, recorder: _MenuRecorder, mou panel._on_header_right_clicked( SENDER_WIDGET_ID, - (mouse_button, HEADER_WIDGETS[GeneratorName.NOISE]), + (mouse_button, HEADER_WIDGETS[ChannelName.NOISE]), ) assert not recorder.titles @@ -164,10 +164,10 @@ def test_a_header_a_rebuild_replaced_opens_nothing(self, recorder: _MenuRecorder def test_every_channel_header_reaches_its_own_menu(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) - for generator in GeneratorName.items(): - _right_click(panel, generator) + for channel in ChannelName.items(): + _right_click(panel, channel) - assert recorder.titles == [COLUMN_LABELS[generator] for generator in GeneratorName.items()] + assert recorder.titles == [COLUMN_LABELS[channel] for channel in ChannelName.items()] class TestChannelItemLabels: @@ -175,19 +175,19 @@ class TestChannelItemLabels: "muted, expected", [ (FULL_MIX, LABEL_MUTE), - (frozenset({GeneratorName.PULSE1}), LABEL_UNMUTE), + (frozenset({ChannelName.PULSE1}), LABEL_UNMUTE), ], ids=["audible", "silenced"], ) def test_the_mute_item_names_the_change_it_makes( self, recorder: _MenuRecorder, - muted: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], expected: str, ) -> None: panel = _panel(muted) - _right_click(panel, GeneratorName.PULSE1) + _right_click(panel, ChannelName.PULSE1) assert expected in recorder.labels @@ -195,35 +195,35 @@ def test_the_mute_item_names_the_change_it_makes( "muted, expected", [ (FULL_MIX, LABEL_SOLO), - (frozenset({GeneratorName.PULSE2}), LABEL_SOLO), - (_others(GeneratorName.PULSE1), LABEL_UNSOLO), + (frozenset({ChannelName.PULSE2}), LABEL_SOLO), + (_others(ChannelName.PULSE1), LABEL_UNSOLO), ], ids=["full mix", "another channel silenced", "already alone"], ) def test_the_solo_item_names_the_change_it_makes( self, recorder: _MenuRecorder, - muted: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], expected: str, ) -> None: panel = _panel(muted) - _right_click(panel, GeneratorName.PULSE1) + _right_click(panel, ChannelName.PULSE1) assert expected in recorder.labels def test_each_column_reads_its_own_state(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE1})) + panel = _panel(frozenset({ChannelName.PULSE1})) - _right_click(panel, GeneratorName.NOISE) + _right_click(panel, ChannelName.NOISE) assert LABEL_MUTE in recorder.labels def test_a_solo_elsewhere_leaves_this_channel_offering_a_solo(self, recorder: _MenuRecorder) -> None: """A silenced channel of a solo is offered its own solo, which moves the solo onto it.""" - panel = _panel(_others(GeneratorName.PULSE1)) + panel = _panel(_others(ChannelName.PULSE1)) - _right_click(panel, GeneratorName.PULSE2) + _right_click(panel, ChannelName.PULSE2) assert LABEL_UNMUTE in recorder.labels assert LABEL_SOLO in recorder.labels @@ -231,12 +231,12 @@ def test_a_solo_elsewhere_leaves_this_channel_offering_a_solo(self, recorder: _M def test_a_channel_menu_carries_both_blocks(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) - _right_click(panel, GeneratorName.NOISE) + _right_click(panel, ChannelName.NOISE) assert recorder.labels == [LABEL_MUTE, LABEL_SOLO, *ALL_CHANNEL_LABELS] def test_the_sample_menu_addresses_no_single_channel(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.NOISE})) + panel = _panel(frozenset({ChannelName.NOISE})) _right_click(panel, None) @@ -248,13 +248,13 @@ def test_the_sample_menu_addresses_no_single_channel(self, recorder: _MenuRecord class TestAllChannelItems: @pytest.mark.parametrize( "muted", - [FULL_MIX, frozenset({GeneratorName.NOISE})], + [FULL_MIX, frozenset({ChannelName.NOISE})], ids=["full mix", "one silenced"], ) def test_muting_everything_is_offered_while_a_channel_sounds( self, recorder: _MenuRecorder, - muted: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], ) -> None: panel = _panel(muted) @@ -271,13 +271,13 @@ def test_muting_everything_is_withheld_in_full_silence(self, recorder: _MenuReco @pytest.mark.parametrize( "muted", - [frozenset({GeneratorName.TRIANGLE}), EVERY_CHANNEL], + [frozenset({ChannelName.TRIANGLE}), EVERY_CHANNEL], ids=["one silenced", "every channel silenced"], ) def test_restoring_everything_is_offered_while_a_channel_is_silent( self, recorder: _MenuRecorder, - muted: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], ) -> None: panel = _panel(muted) @@ -295,7 +295,7 @@ def test_restoring_everything_is_withheld_in_the_full_mix(self, recorder: _MenuR def test_both_menus_end_with_the_whole_mix_items(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) - _right_click(panel, GeneratorName.PULSE1) + _right_click(panel, ChannelName.PULSE1) channel_menu = recorder.labels[-2:] _right_click(panel, None) @@ -306,47 +306,47 @@ def test_both_menus_end_with_the_whole_mix_items(self, recorder: _MenuRecorder) class TestHeaderMenuActions: def test_the_mute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) - toggled: List[GeneratorName] = [] + toggled: List[ChannelName] = [] panel.on_channel_mute_toggled = toggled.append - _right_click(panel, GeneratorName.TRIANGLE) + _right_click(panel, ChannelName.TRIANGLE) recorder.click(LABEL_MUTE) - assert toggled == [GeneratorName.TRIANGLE] + assert toggled == [ChannelName.TRIANGLE] def test_the_unmute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) - toggled: List[GeneratorName] = [] + panel = _panel(frozenset({ChannelName.TRIANGLE})) + toggled: List[ChannelName] = [] panel.on_channel_mute_toggled = toggled.append - _right_click(panel, GeneratorName.TRIANGLE) + _right_click(panel, ChannelName.TRIANGLE) recorder.click(LABEL_UNMUTE) - assert toggled == [GeneratorName.TRIANGLE] + assert toggled == [ChannelName.TRIANGLE] def test_the_solo_item_solos_the_clicked_channel(self, recorder: _MenuRecorder) -> None: panel = _panel(FULL_MIX) - soloed: List[GeneratorName] = [] + soloed: List[ChannelName] = [] panel.on_channel_soloed = soloed.append - _right_click(panel, GeneratorName.NOISE) + _right_click(panel, ChannelName.NOISE) recorder.click(LABEL_SOLO) - assert soloed == [GeneratorName.NOISE] + assert soloed == [ChannelName.NOISE] def test_the_unsolo_item_takes_the_same_route_back(self, recorder: _MenuRecorder) -> None: """Leaving a solo restores the mix the solo interrupted, which the channels logic owns.""" - panel = _panel(_others(GeneratorName.NOISE)) - soloed: List[GeneratorName] = [] + panel = _panel(_others(ChannelName.NOISE)) + soloed: List[ChannelName] = [] panel.on_channel_soloed = soloed.append - _right_click(panel, GeneratorName.NOISE) + _right_click(panel, ChannelName.NOISE) recorder.click(LABEL_UNSOLO) - assert soloed == [GeneratorName.NOISE] + assert soloed == [ChannelName.NOISE] def test_muting_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE1})) + panel = _panel(frozenset({ChannelName.PULSE1})) muted: List[None] = [] panel.on_channels_muted = lambda: muted.append(None) panel.on_channels_toggled = lambda: pytest.fail("the menu names the state it leaves") @@ -357,7 +357,7 @@ def test_muting_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) - assert muted == [None] def test_restoring_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE1})) + panel = _panel(frozenset({ChannelName.PULSE1})) unmuted: List[None] = [] panel.on_channels_unmuted = lambda: unmuted.append(None) panel.on_channels_toggled = lambda: pytest.fail("the menu names the state it leaves") @@ -372,7 +372,7 @@ def test_a_channel_menu_reaches_the_whole_mix_too(self, recorder: _MenuRecorder) muted: List[None] = [] panel.on_channels_muted = lambda: muted.append(None) - _right_click(panel, GeneratorName.PULSE2) + _right_click(panel, ChannelName.PULSE2) recorder.click(LABEL_MUTE_ALL) assert muted == [None] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index 6ae66a05d..16c9344c4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -16,7 +16,7 @@ SequencerSettingsViewModel, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.song_position import SongPosition from sampletones_shared.types.application import ColorRGBA @@ -124,11 +124,11 @@ def _playhead(frame_index: int, row_index: int) -> SongPosition: def _place_cursor( panel: GUISequencerTrackerPanel, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], ) -> None: """Puts the cursor where the panel's own state keeps it, the way an edit action does.""" panel._input_state = TrackerInputState( - cursor=TrackerCursor(row_index, generator, SubColumn.INSTRUMENT), + cursor=TrackerCursor(row_index, channel, SubColumn.INSTRUMENT), pending="", ) @@ -221,9 +221,9 @@ def test_the_cursor_lands_on_the_mapped_table_row( row_index: int, ) -> None: panel = _panel() - _place_cursor(panel, row_index, GeneratorName.TRIANGLE) + _place_cursor(panel, row_index, ChannelName.TRIANGLE) - panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE) + panel._apply_cell_highlight(row_index, ChannelName.TRIANGLE) assert recorder.highlighted_rows == {tracker_table_row(row_index): CURSOR_ROW} @@ -234,20 +234,20 @@ def test_the_cursor_on_a_group_row_carries_both_shades( row_index: int, ) -> None: panel = _panel() - _place_cursor(panel, row_index, GeneratorName.TRIANGLE) + _place_cursor(panel, row_index, ChannelName.TRIANGLE) - panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE) + panel._apply_cell_highlight(row_index, ChannelName.TRIANGLE) painted = recorder.highlighted_rows[tracker_table_row(row_index)] assert painted[3] > CURSOR_ROW[3] def test_the_cursor_cell_lands_on_the_mapped_row_and_column(self, recorder: _TableRecorder) -> None: panel = _panel() - _place_cursor(panel, 2, GeneratorName.NOISE) + _place_cursor(panel, 2, ChannelName.NOISE) - panel._apply_cell_highlight(2, GeneratorName.NOISE) + panel._apply_cell_highlight(2, ChannelName.NOISE) - key = (tracker_table_row(2), tracker_table_column(GeneratorName.NOISE)) + key = (tracker_table_row(2), tracker_table_column(ChannelName.NOISE)) assert recorder.highlighted_cells == {key: CELL_CURSOR} def test_no_cursor_ever_paints_the_header_row(self, recorder: _TableRecorder) -> None: @@ -345,7 +345,7 @@ def test_the_playhead_over_a_group_row_carries_both_shades( def test_the_playhead_outranks_the_cursor_on_the_same_row(self, recorder: _TableRecorder) -> None: panel = _panel() - _place_cursor(panel, 1, GeneratorName.PULSE1) + _place_cursor(panel, 1, ChannelName.PULSE1) panel.set_playing_position(_playhead(SHOWN_FRAME, 1)) @@ -421,7 +421,7 @@ def test_returning_to_the_sounding_frame_marks_its_row_again(self, recorder: _Ta def test_the_cursor_keeps_its_row_on_a_frame_the_playhead_left(self, recorder: _TableRecorder) -> None: """A frame the playhead is away from shows the reader's own cursor on the row it sits on.""" panel = _panel() - _place_cursor(panel, 3, GeneratorName.PULSE1) + _place_cursor(panel, 3, ChannelName.PULSE1) panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) panel._show_frame(OTHER_FRAME) @@ -459,7 +459,7 @@ def test_the_header_shade_covers_the_sample_and_channel_columns( panel._highlight_header_row() - washed: List[Optional[GeneratorName]] = [None, *GeneratorName.items()] - for generator in washed: - key = (HEADER_TABLE_ROW, tracker_table_column(generator)) + washed: List[Optional[ChannelName]] = [None, *ChannelName.items()] + for channel in washed: + key = (HEADER_TABLE_ROW, tracker_table_column(channel)) assert recorder.highlighted_cells[key] == HEADER_SHADE diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index a040aac83..d2aa33864 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -26,7 +26,7 @@ SequencerChannelsViewModel, ) from sampletones_application.view_model.shared.menu import MenuBarViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName CHANNEL_NAMES = ["Pulse 1", "Pulse 2", "Triangle", "Noise"] UNMUTE_ALL = "Unmute all channels" @@ -117,7 +117,7 @@ def configure_item(self, item: str, **kwargs: Any) -> None: def _state( - muted: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], *, reconstruction_loaded: bool = False, follow_mode: FollowMode = FollowMode.OFF, @@ -175,7 +175,7 @@ def shortcuts(framework: _DearPyGuiRecorder) -> _ShortcutManagerRecorder: @pytest.fixture -def switched() -> List[GeneratorName]: +def switched() -> List[ChannelName]: """The channels the bar asks the sequencer to switch, in the order it asks.""" return [] @@ -183,7 +183,7 @@ def switched() -> List[GeneratorName]: @pytest.fixture def menu_bar( shortcuts: _ShortcutManagerRecorder, - switched: List[GeneratorName], + switched: List[ChannelName], ) -> MenuBar: """A bar with the collaborators its submenus read, from the real language file.""" instance = MenuBar.__new__(MenuBar) @@ -328,7 +328,7 @@ def test_choosing_a_channel_switches_the_sequencer_mix( menu_bar: MenuBar, shortcuts: _ShortcutManagerRecorder, framework: _DearPyGuiRecorder, - switched: List[GeneratorName], + switched: List[ChannelName], ) -> None: """The check beside an item names the sequencer's mix, so the item switches that mix wherever the reader stands, while the key printed beside it reads the tab in front.""" @@ -348,7 +348,7 @@ def test_each_channel_carries_its_own_tag( menu_bar._create_channels_menu(_state(frozenset())) tags = [item["tag"] for item in shortcuts.items[:-1]] - assert tags == [MenuBar._channel_menu_item_tag(generator) for generator in CHANNEL_SHORTCUT_IDS] + assert tags == [MenuBar._channel_menu_item_tag(channel) for channel in CHANNEL_SHORTCUT_IDS] def test_a_channel_is_checked_while_it_sounds( self, @@ -356,7 +356,7 @@ def test_a_channel_is_checked_while_it_sounds( shortcuts: _ShortcutManagerRecorder, framework: _DearPyGuiRecorder, ) -> None: - menu_bar._create_channels_menu(_state(frozenset({GeneratorName.TRIANGLE}))) + menu_bar._create_channels_menu(_state(frozenset({ChannelName.TRIANGLE}))) assert shortcuts.item("Pulse 1")["default_value"] assert not shortcuts.item("Triangle")["default_value"] @@ -365,7 +365,7 @@ def test_a_channel_is_checked_while_it_sounds( ("muted", "offered"), [ (frozenset(), False), - (frozenset({GeneratorName.NOISE}), True), + (frozenset({ChannelName.NOISE}), True), ], ids=["full_mix_withholds_the_restore", "a_silenced_channel_offers_the_restore"], ) @@ -374,7 +374,7 @@ def test_the_restore_is_offered_while_a_channel_is_silenced( menu_bar: MenuBar, shortcuts: _ShortcutManagerRecorder, framework: _DearPyGuiRecorder, - muted: FrozenSet[GeneratorName], + muted: FrozenSet[ChannelName], offered: bool, ) -> None: menu_bar._create_channels_menu(_state(muted)) @@ -388,13 +388,13 @@ def test_the_checks_follow_the_mute_set( menu_bar: MenuBar, framework: _DearPyGuiRecorder, ) -> None: - menu_bar._update_channels(_state(frozenset({GeneratorName.PULSE2}))) + menu_bar._update_channels(_state(frozenset({ChannelName.PULSE2}))) assert framework.values == { - MenuBar._channel_menu_item_tag(GeneratorName.PULSE1): True, - MenuBar._channel_menu_item_tag(GeneratorName.PULSE2): False, - MenuBar._channel_menu_item_tag(GeneratorName.TRIANGLE): True, - MenuBar._channel_menu_item_tag(GeneratorName.NOISE): True, + MenuBar._channel_menu_item_tag(ChannelName.PULSE1): True, + MenuBar._channel_menu_item_tag(ChannelName.PULSE2): False, + MenuBar._channel_menu_item_tag(ChannelName.TRIANGLE): True, + MenuBar._channel_menu_item_tag(ChannelName.NOISE): True, } def test_the_restore_follows_the_mute_set( @@ -402,7 +402,7 @@ def test_the_restore_follows_the_mute_set( menu_bar: MenuBar, framework: _DearPyGuiRecorder, ) -> None: - menu_bar._update_channels(_state(frozenset({GeneratorName.PULSE2}))) + menu_bar._update_channels(_state(frozenset({ChannelName.PULSE2}))) assert framework.enabled == {TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS: True} diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 0a07b7869..5dfd920f9 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -71,8 +71,8 @@ def test_enablement_follows_original_audio_state( ) -> None: view_model = ReconstructionViewModel( reconstruction_loaded=case.reconstruction_loaded, - playing_generators=frozenset(), - selected_generators=frozenset(), + playing_channels=frozenset(), + selected_channels=frozenset(), reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path=""), original_audio=ReconstructionPathViewModel(state=case.original_audio_state, path=""), ) diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py index b76b16938..679b4345a 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py @@ -4,22 +4,22 @@ import pytest from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase -ALL_CHANNELS = frozenset(GeneratorName.items()) +ALL_CHANNELS = frozenset(ChannelName.items()) -PULSE1 = GeneratorName.PULSE1 -PULSE2 = GeneratorName.PULSE2 -TRIANGLE = GeneratorName.TRIANGLE -NOISE = GeneratorName.NOISE +PULSE1 = ChannelName.PULSE1 +PULSE2 = ChannelName.PULSE2 +TRIANGLE = ChannelName.TRIANGLE +NOISE = ChannelName.NOISE class TestAllMuted(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class AllMutedCase(BaseRegularTestCase): - muted: FrozenSet[GeneratorName] + muted: FrozenSet[ChannelName] expected: bool test_cases = ( @@ -55,7 +55,7 @@ def test_all_muted_reports_full_silence(self, case: AllMutedCase) -> None: class TestAnyMuted(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class AllMutedCase(BaseRegularTestCase): - muted: FrozenSet[GeneratorName] + muted: FrozenSet[ChannelName] expected: bool test_cases = ( @@ -98,29 +98,29 @@ def test_the_two_readings_agree_in_full_silence(self) -> None: class TestIsSoloed: @pytest.mark.parametrize( - "generator", - GeneratorName.items(), - ids=lambda generator: generator.value, + "channel", + ChannelName.items(), + ids=lambda channel: channel.value, ) def test_the_one_audible_channel_reads_as_soloed( self, - generator: GeneratorName, + channel: ChannelName, ) -> None: - view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS - {generator}) + view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS - {channel}) - soloed = {other for other in GeneratorName.items() if view_model.is_soloed(other)} + soloed = {other for other in ChannelName.items() if view_model.is_soloed(other)} - assert soloed == {generator} + assert soloed == {channel} def test_no_channel_is_soloed_in_a_full_mix(self) -> None: view_model = SequencerChannelsViewModel(muted=frozenset()) - assert not any(view_model.is_soloed(generator) for generator in GeneratorName.items()) + assert not any(view_model.is_soloed(channel) for channel in ChannelName.items()) def test_no_channel_is_soloed_in_full_silence(self) -> None: view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS) - assert not any(view_model.is_soloed(generator) for generator in GeneratorName.items()) + assert not any(view_model.is_soloed(channel) for channel in ChannelName.items()) def test_two_audible_channels_leave_neither_soloed(self) -> None: view_model = SequencerChannelsViewModel(muted=frozenset({PULSE1, PULSE2})) @@ -131,19 +131,19 @@ def test_two_audible_channels_leave_neither_soloed(self) -> None: class TestIsMuted: @pytest.mark.parametrize( - "generator", - GeneratorName.items(), - ids=lambda generator: generator.value, + "channel", + ChannelName.items(), + ids=lambda channel: channel.value, ) def test_is_muted_reports_membership_of_the_mute_set( self, - generator: GeneratorName, + channel: ChannelName, ) -> None: view_model = SequencerChannelsViewModel(muted=frozenset({TRIANGLE, NOISE})) - assert view_model.is_muted(generator) is (generator in {TRIANGLE, NOISE}) + assert view_model.is_muted(channel) is (channel in {TRIANGLE, NOISE}) def test_no_channel_is_muted_in_a_full_mix(self) -> None: view_model = SequencerChannelsViewModel(muted=frozenset()) - assert not any(view_model.is_muted(generator) for generator in GeneratorName.items()) + assert not any(view_model.is_muted(channel) for channel in ChannelName.items()) diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_order.py b/tests/unit/sampletones_application/view_model/sequencer/test_order.py index 678d2c8d7..dab1768d1 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_order.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_order.py @@ -8,7 +8,7 @@ SequencerOrderTrackerViewModel, SequencerOrderViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id from sampletones_shared.constants.symbols import MIXED from tests.suite.base import BaseTestSuite @@ -18,11 +18,11 @@ def _tracker( - channels: Dict[GeneratorName, List[Optional[int]]], + channels: Dict[ChannelName, List[Optional[int]]], ) -> SequencerOrderTrackerViewModel: views = { - generator: SequencerOrderViewModel( - generator=generator, + channel: SequencerOrderViewModel( + channel=channel, entries=tuple( OrderEntryViewModel( position=position, @@ -31,7 +31,7 @@ def _tracker( for position, index in enumerate(indices) ), ) - for generator, indices in channels.items() + for channel, indices in channels.items() } position_count = max( (len(view.entries) for view in views.values()), @@ -43,22 +43,22 @@ def _tracker( ) -def _uniform(*indices: Optional[int]) -> Dict[GeneratorName, List[Optional[int]]]: - return {generator: list(indices) for generator in GeneratorName.items()} +def _uniform(*indices: Optional[int]) -> Dict[ChannelName, List[Optional[int]]]: + return {channel: list(indices) for channel in ChannelName.items()} def test_entry_label_renders_index_and_empty_slot() -> None: tracker = _tracker(_uniform(5, None)) - assert tracker.entry_label(GeneratorName.PULSE1, 0) == display_id(5) - assert tracker.entry_label(GeneratorName.PULSE1, 1) == _EMPTY + assert tracker.entry_label(ChannelName.PULSE1, 0) == display_id(5) + assert tracker.entry_label(ChannelName.PULSE1, 1) == _EMPTY class TestMasterLabel(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class MasterCase(BaseRegularTestCase): label: str - channels: Dict[GeneratorName, List[Optional[int]]] + channels: Dict[ChannelName, List[Optional[int]]] expected: str test_cases = ( @@ -75,20 +75,20 @@ class MasterCase(BaseRegularTestCase): MasterCase( label="divergent_index", channels={ - GeneratorName.PULSE1: [5], - GeneratorName.PULSE2: [5], - GeneratorName.TRIANGLE: [7], - GeneratorName.NOISE: [5], + ChannelName.PULSE1: [5], + ChannelName.PULSE2: [5], + ChannelName.TRIANGLE: [7], + ChannelName.NOISE: [5], }, expected=MIXED, ), MasterCase( label="index_versus_empty", channels={ - GeneratorName.PULSE1: [5], - GeneratorName.PULSE2: [5], - GeneratorName.TRIANGLE: [None], - GeneratorName.NOISE: [5], + ChannelName.PULSE1: [5], + ChannelName.PULSE2: [5], + ChannelName.TRIANGLE: [None], + ChannelName.NOISE: [5], }, expected=MIXED, ), diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py index ebd29edab..b18bc2a74 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -14,7 +14,7 @@ slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName class TestTrackerRegion: @@ -22,7 +22,7 @@ def test_a_single_cell_region_covers_that_cell(self) -> None: region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4) assert tuple(region.rows) == (3,) - assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) + assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE),) def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None: """A region's edges are subcolumns, so a run reaches across a column boundary mid-cell.""" @@ -30,7 +30,7 @@ def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None assert region.slots == ( TrackerSlot(None, SubColumn.VOLUME), - TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT), ) def test_a_region_spans_the_whole_axis(self) -> None: @@ -61,13 +61,13 @@ class TestOrderRegion: def test_a_single_cell_region_covers_that_cell(self) -> None: region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2) - assert region.generators == (None,) + assert region.channels == (None,) assert tuple(region.positions) == (2,) def test_the_rows_read_as_the_channels_they_address(self) -> None: region = OrderRegion(first_row=0, last_row=2, first_position=0, last_position=0) - assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2) + assert region.channels == (None, ChannelName.PULSE1, ChannelName.PULSE2) def test_a_region_spans_the_whole_channel_axis(self) -> None: region = OrderRegion( @@ -77,7 +77,7 @@ def test_a_region_spans_the_whole_channel_axis(self) -> None: last_position=7, ) - assert region.generators == CHANNEL_AXIS + assert region.channels == CHANNEL_AXIS assert tuple(region.positions) == tuple(range(8)) def test_inverted_positions_are_rejected(self) -> None: @@ -141,34 +141,34 @@ def region(self) -> OrderRegion: return OrderRegion(first_row=1, last_row=2, first_position=3, last_position=6) @pytest.mark.parametrize( - ("generator", "position"), + ("channel", "position"), [ - (GeneratorName.PULSE1, 3), - (GeneratorName.PULSE2, 6), - (GeneratorName.PULSE1, 5), + (ChannelName.PULSE1, 3), + (ChannelName.PULSE2, 6), + (ChannelName.PULSE1, 5), ], ) def test_a_cell_inside_the_rectangle_belongs_to_it( self, region: OrderRegion, - generator: GeneratorName, + channel: ChannelName, position: int, ) -> None: - assert region.covers(generator, position) is True + assert region.covers(channel, position) is True @pytest.mark.parametrize( - ("generator", "position"), + ("channel", "position"), [ (None, 5), - (GeneratorName.TRIANGLE, 5), - (GeneratorName.PULSE1, 2), - (GeneratorName.PULSE1, 7), + (ChannelName.TRIANGLE, 5), + (ChannelName.PULSE1, 2), + (ChannelName.PULSE1, 7), ], ) def test_a_cell_outside_the_rectangle_stands_on_its_own( self, region: OrderRegion, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], position: int, ) -> None: - assert region.covers(generator, position) is False + assert region.covers(channel, position) is False diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py index 34fb7cc8d..0e8863be6 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py @@ -11,14 +11,14 @@ slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName _OUT_OF_RANGE = [-1, -SLOT_COUNT, SLOT_COUNT, SLOT_COUNT + 1] class TestAxis: def test_the_sample_column_leads_the_four_channels(self) -> None: - assert CHANNEL_AXIS == (None, *GeneratorName.items()) + assert CHANNEL_AXIS == (None, *ChannelName.items()) def test_the_axis_covers_every_column_once_over(self) -> None: assert SLOT_COUNT == len(CHANNEL_AXIS) * len(SUBCOLUMNS) @@ -30,9 +30,7 @@ def test_every_index_round_trips_through_its_slot(self, index: int) -> None: assert slot_from_flat(index).flat_index == index def test_the_axis_maps_onto_the_whole_index_range(self) -> None: - indices = { - TrackerSlot(generator, subcolumn).flat_index for generator in CHANNEL_AXIS for subcolumn in SUBCOLUMNS - } + indices = {TrackerSlot(channel, subcolumn).flat_index for channel in CHANNEL_AXIS for subcolumn in SUBCOLUMNS} assert indices == set(range(SLOT_COUNT)) @@ -41,14 +39,14 @@ def test_the_sample_columns_instrument_opens_the_axis(self) -> None: class TestColumnBase: - @pytest.mark.parametrize("generator", CHANNEL_AXIS) - def test_every_base_starts_a_whole_column(self, generator: Optional[GeneratorName]) -> None: + @pytest.mark.parametrize("channel", CHANNEL_AXIS) + def test_every_base_starts_a_whole_column(self, channel: Optional[ChannelName]) -> None: """Kind alignment rests on this: an offset from any base addresses the same subcolumn.""" - assert column_slot_base(generator) % len(SUBCOLUMNS) == 0 + assert column_slot_base(channel) % len(SUBCOLUMNS) == 0 - @pytest.mark.parametrize("generator", CHANNEL_AXIS) - def test_a_base_addresses_its_columns_first_subcolumn(self, generator: Optional[GeneratorName]) -> None: - assert slot_from_flat(column_slot_base(generator)) == TrackerSlot(generator, SUBCOLUMNS[0]) + @pytest.mark.parametrize("channel", CHANNEL_AXIS) + def test_a_base_addresses_its_columns_first_subcolumn(self, channel: Optional[ChannelName]) -> None: + assert slot_from_flat(column_slot_base(channel)) == TrackerSlot(channel, SUBCOLUMNS[0]) class TestBounds: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index e9dd5c3ad..7f4acc2f3 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -7,7 +7,7 @@ SequencerCellViewModel, SequencerRowViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import ( NOTE_OFF, display_id, @@ -49,10 +49,10 @@ def _empty_cell() -> SequencerCellViewModel: def _row_cells( **overrides: SequencerCellViewModel, -) -> Dict[GeneratorName, SequencerCellViewModel]: - cells = {generator: _empty_cell() for generator in GeneratorName.items()} +) -> Dict[ChannelName, SequencerCellViewModel]: + cells = {channel: _empty_cell() for channel in ChannelName.items()} for name, cell in overrides.items(): - cells[GeneratorName[name.upper()]] = cell + cells[ChannelName[name.upper()]] = cell return cells @@ -60,8 +60,8 @@ def _row_cells( class TestSampleColumnAggregate(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class AggregateCase(BaseRegularTestCase): - cells: Dict[GeneratorName, SequencerCellViewModel] - relevant_generators: FrozenSet[GeneratorName] + cells: Dict[ChannelName, SequencerCellViewModel] + relevant_channels: FrozenSet[ChannelName] expected_instrument: str expected_transpose: str expected_volume: str @@ -70,15 +70,15 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="no_relevant_channels_fall_back_to_defaults", cells=_row_cells(), - relevant_generators=frozenset(), + relevant_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), AggregateCase( label="transpose_and_volume_span_all_channels_when_no_sample_is_present", - cells={generator: _cell(volume=display_volume(8)) for generator in GeneratorName.items()}, - relevant_generators=frozenset(), + cells={channel: _cell(volume=display_volume(8)) for channel in ChannelName.items()}, + relevant_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=display_volume(8), @@ -86,7 +86,7 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="single_relevant_channel_present", cells=_row_cells(pulse1=_OCCUPIED), - relevant_generators=frozenset({GeneratorName.PULSE1}), + relevant_channels=frozenset({ChannelName.PULSE1}), expected_instrument=display_id(0), expected_transpose=display_transpose(5), expected_volume=display_volume(8), @@ -94,10 +94,10 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="sample_present_across_all_its_relevant_channels", cells=_row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED), - relevant_generators=frozenset( + relevant_channels=frozenset( { - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, } ), expected_instrument=display_id(0), @@ -107,10 +107,10 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="sample_missing_from_one_relevant_channel_is_mixed", cells=_row_cells(pulse1=_OCCUPIED), - relevant_generators=frozenset( + relevant_channels=frozenset( { - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, } ), expected_instrument=MIXED, @@ -127,10 +127,10 @@ class AggregateCase(BaseRegularTestCase): volume=display_volume(8), ), ), - relevant_generators=frozenset( + relevant_channels=frozenset( { - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, } ), expected_instrument=display_id(0), @@ -139,8 +139,8 @@ class AggregateCase(BaseRegularTestCase): ), AggregateCase( label="all_channels_note_off_reads_as_note_off", - cells={generator: _cell(instrument=NOTE_OFF) for generator in GeneratorName.items()}, - relevant_generators=frozenset(), + cells={channel: _cell(instrument=NOTE_OFF) for channel in ChannelName.items()}, + relevant_channels=frozenset(), expected_instrument=NOTE_OFF, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, @@ -148,7 +148,7 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="half_cut_row_is_mixed", cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)), - relevant_generators=frozenset(), + relevant_channels=frozenset(), expected_instrument=MIXED, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, @@ -156,15 +156,15 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="zero_transpose_beside_an_empty_one_is_mixed", cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))), - relevant_generators=frozenset(), + relevant_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=MIXED, expected_volume=_EMPTY_VOLUME, ), AggregateCase( label="zero_transpose_shared_by_every_channel_reads_as_zero", - cells={generator: _cell(transpose=display_transpose(0)) for generator in GeneratorName.items()}, - relevant_generators=frozenset(), + cells={channel: _cell(transpose=display_transpose(0)) for channel in ChannelName.items()}, + relevant_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=display_transpose(0), expected_volume=_EMPTY_VOLUME, @@ -179,7 +179,7 @@ def test_sample_column_aggregates_over_relevant_channels( row = SequencerRowViewModel( index=0, cells=case.cells, - relevant_generators=case.relevant_generators, + relevant_channels=case.relevant_channels, ) assert row.sample_instrument == case.expected_instrument diff --git a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py index 942723b9e..be2ac965c 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py +++ b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py @@ -4,19 +4,19 @@ import pytest from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName @pytest.fixture -def approximations() -> Dict[GeneratorName, np.ndarray]: +def approximations() -> Dict[ChannelName, np.ndarray]: return { - GeneratorName.PULSE1: np.array([1.0, 0.0, -1.0, 0.0]), - GeneratorName.NOISE: np.array([0.5, 0.5, 0.5, 0.5]), + ChannelName.PULSE1: np.array([1.0, 0.0, -1.0, 0.0]), + ChannelName.NOISE: np.array([0.5, 0.5, 0.5, 0.5]), } @pytest.fixture -def waveform_data(approximations: Dict[GeneratorName, np.ndarray]) -> WaveformData: +def waveform_data(approximations: Dict[ChannelName, np.ndarray]) -> WaveformData: return WaveformData( original_audio=np.zeros(4), approximation=np.array([1.5, 0.5, -0.5, 0.5]), @@ -31,32 +31,32 @@ def test_empty_selection_is_silent(self, waveform_data: WaveformData) -> None: assert np.all(waveform_data.partials([]) == 0.0) def test_missing_generator_is_silent(self, waveform_data: WaveformData) -> None: - assert np.all(waveform_data.partials([GeneratorName.TRIANGLE]) == 0.0) + assert np.all(waveform_data.partials([ChannelName.TRIANGLE]) == 0.0) def test_single_generator_returns_its_approximation( self, waveform_data: WaveformData, - approximations: Dict[GeneratorName, np.ndarray], + approximations: Dict[ChannelName, np.ndarray], ) -> None: - result = waveform_data.partials([GeneratorName.PULSE1]) + result = waveform_data.partials([ChannelName.PULSE1]) - assert np.array_equal(result, approximations[GeneratorName.PULSE1]) + assert np.array_equal(result, approximations[ChannelName.PULSE1]) def test_selection_sums_the_selected_generators( self, waveform_data: WaveformData, - approximations: Dict[GeneratorName, np.ndarray], + approximations: Dict[ChannelName, np.ndarray], ) -> None: - result = waveform_data.partials([GeneratorName.PULSE1, GeneratorName.NOISE]) + result = waveform_data.partials([ChannelName.PULSE1, ChannelName.NOISE]) - expected = approximations[GeneratorName.PULSE1] + approximations[GeneratorName.NOISE] + expected = approximations[ChannelName.PULSE1] + approximations[ChannelName.NOISE] assert np.array_equal(result, expected) def test_unknown_generators_are_skipped_within_a_selection( self, waveform_data: WaveformData, - approximations: Dict[GeneratorName, np.ndarray], + approximations: Dict[ChannelName, np.ndarray], ) -> None: - result = waveform_data.partials([GeneratorName.PULSE1, GeneratorName.TRIANGLE]) + result = waveform_data.partials([ChannelName.PULSE1, ChannelName.TRIANGLE]) - assert np.array_equal(result, approximations[GeneratorName.PULSE1]) + assert np.array_equal(result, approximations[ChannelName.PULSE1]) diff --git a/tests/unit/sampletones_core/compatibility/project/__init__.py b/tests/unit/sampletones_core/compatibility/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py new file mode 100644 index 000000000..e6f8d8749 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py @@ -0,0 +1,84 @@ +from typing import Any, Dict + +from sampletones_core.compatibility.fields import CHANNEL_NAME +from sampletones_core.compatibility.project.v1_1 import update + + +def _pool(extra: Dict[str, Any]) -> Dict[str, Any]: + return {"generator": "pulse1", "patterns": {}, **extra} + + +class TestProjectV1_1: + def test_renames_channel_pool_field(self) -> None: + data = {"song": {"channels": {"pulse1": _pool({})}}} + + upgraded = update(data) + + assert upgraded["song"]["channels"]["pulse1"][CHANNEL_NAME] == "pulse1" + assert "generator" not in upgraded["song"]["channels"]["pulse1"] + + def test_renames_instrument_command_channel(self) -> None: + data = { + "song": { + "channels": { + "pulse1": { + "generator": "pulse1", + "patterns": { + "0": { + "rows": { + "0": { + "command": { + "sample_id": "s", + "generator_name": "pulse1", + } + }, + } + } + }, + } + } + } + } + + upgraded = update(data) + + command = upgraded["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"]["0"]["command"] + assert command[CHANNEL_NAME] == "pulse1" + assert "generator_name" not in command + + def test_leaves_note_off_commands_untouched(self) -> None: + data = { + "song": { + "channels": { + "pulse1": { + "generator": "pulse1", + "patterns": { + "0": { + "rows": { + "0": { + "command": {}, + } + } + } + }, + } + } + } + } + + upgraded = update(data) + + command = upgraded["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"]["0"]["command"] + assert command == {} + + def test_leaves_the_input_untouched(self) -> None: + data = {"song": {"channels": {"pulse1": _pool({})}}} + + update(data) + + assert data["song"]["channels"]["pulse1"]["generator"] == "pulse1" + + def test_document_without_a_song_stays_the_same_shape(self) -> None: + data = {"format_version": "1.0"} + + assert update(data) == data diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/__init__.py b/tests/unit/sampletones_core/compatibility/reconstruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py new file mode 100644 index 000000000..28a069755 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -0,0 +1,50 @@ +from typing import Any, Dict + +from sampletones_core.compatibility.fields import CHANNEL_NAME, GENERATOR_NAME +from sampletones_core.compatibility.reconstruction.v2_2 import update + + +def _stream(extra: Dict[str, Any]) -> Dict[str, Any]: + return {GENERATOR_NAME: "pulse1", "instructions": [], **extra} + + +class TestReconstructionV2_2: + def test_renames_approximation_entries(self) -> None: + data = {"approximations_data": [{GENERATOR_NAME: "pulse1", "approximation": [1.0, 2.0]}]} + + upgraded = update(data) + + assert upgraded["approximations_data"][0][CHANNEL_NAME] == "pulse1" + assert GENERATOR_NAME not in upgraded["approximations_data"][0] + + def test_renames_instruction_entries(self) -> None: + data = {"instructions_data": [_stream({})]} + + upgraded = update(data) + + assert upgraded["instructions_data"][0][CHANNEL_NAME] == "pulse1" + assert GENERATOR_NAME not in upgraded["instructions_data"][0] + + def test_renames_embedded_channel_selection(self) -> None: + data = {"config": {"generation": {"generators": ["pulse1", "noise"], "drive": 1.0}}} + + upgraded = update(data) + + assert upgraded["config"]["generation"]["channels"] == ["pulse1", "noise"] + assert "generators" not in upgraded["config"]["generation"] + + def test_leaves_the_input_untouched(self) -> None: + data = { + "approximations_data": [{GENERATOR_NAME: "pulse1"}], + "instructions_data": [_stream({})], + } + + update(data) + + assert data["approximations_data"][0][GENERATOR_NAME] == "pulse1" + assert data["instructions_data"][0][GENERATOR_NAME] == "pulse1" + + def test_payload_without_known_sections_stays_the_same_shape(self) -> None: + data = {"id": "abc"} + + assert update(data) == data diff --git a/tests/unit/sampletones_core/compatibility/test_binary.py b/tests/unit/sampletones_core/compatibility/test_binary.py index c94bea91b..5c77ca53e 100644 --- a/tests/unit/sampletones_core/compatibility/test_binary.py +++ b/tests/unit/sampletones_core/compatibility/test_binary.py @@ -39,3 +39,22 @@ def test_malformed_payload_returns_the_same_bytes(self) -> None: binary = b"\xc1" assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary + + def test_reconstruction_upgrade_renames_channels_and_stamps(self) -> None: + binary = msgpack.packb( + { + "metadata": {"reconstruction_data_version": "2.1"}, + "approximations_data": [{"generator_name": "pulse1", "approximation": [1.0, 2.0]}], + "instructions_data": [], + "config": {"generation": {"generators": ["pulse1", "noise"]}}, + }, + use_bin_type=True, + ) + + upgraded = upgrade_binary(ObjectKind.RECONSTRUCTION, binary) + data = msgpack.unpackb(upgraded, raw=False) + + assert data["approximations_data"][0]["channel_name"] == "pulse1" + assert "generator_name" not in data["approximations_data"][0] + assert data["config"]["generation"]["channels"] == ["pulse1", "noise"] + assert data["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION diff --git a/tests/unit/sampletones_core/compatibility/test_json.py b/tests/unit/sampletones_core/compatibility/test_json.py index bf4cbaab3..14300b795 100644 --- a/tests/unit/sampletones_core/compatibility/test_json.py +++ b/tests/unit/sampletones_core/compatibility/test_json.py @@ -25,3 +25,35 @@ def test_malformed_document_returns_the_same_bytes(self) -> None: raw = b"{ not valid json" assert upgrade_json(ObjectKind.PROJECT, raw) is raw + + def test_project_upgrade_renames_channel_fields_and_stamps(self) -> None: + raw = json.dumps( + { + "format_version": "1.0", + "song": { + "channels": { + "pulse1": { + "generator": "pulse1", + "patterns": { + "0": { + "rows": { + "0": {"command": {"sample_id": "s", "generator_name": "pulse1"}}, + } + } + }, + } + } + }, + } + ).encode("utf-8") + + upgraded = upgrade_json(ObjectKind.PROJECT, raw) + data = json.loads(upgraded) + + assert data["format_version"] == SAMPLETONES_PROJECT_DATA_VERSION + channel = data["song"]["channels"]["pulse1"] + assert channel["channel_name"] == "pulse1" + assert "generator" not in channel + command = channel["patterns"]["0"]["rows"]["0"]["command"] + assert command["channel_name"] == "pulse1" + assert "generator_name" not in command diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index 6d3d06174..baa20bc9f 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -6,8 +6,8 @@ DISPLAY_HASH_LENGTH, DISPLAY_SEPARATOR, disambiguated_display_name, + format_channels, format_frequencies, - format_generators, format_nes_frequency, format_sample_rate, format_transformation, @@ -15,7 +15,7 @@ short_hash, unique_display_names, ) -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.constants.enums import ChannelName, SpectrumMethod class TestFormatSampleRate: @@ -43,24 +43,24 @@ def test_marks_the_gamma(self) -> None: assert format_transformation_gamma(0) == "γ0" -class TestFormatGenerators: +class TestFormatChannels: def test_reads_the_generators_in_the_order_they_are_given(self) -> None: assert ( - format_generators( + format_channels( [ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, ], ) == "Pulse 1, Triangle, Noise" ) def test_a_lone_generator_reads_as_its_own_name(self) -> None: - assert format_generators([GeneratorName.PULSE2]) == "Pulse 2" + assert format_channels([ChannelName.PULSE2]) == "Pulse 2" def test_no_generator_reads_as_nothing(self) -> None: - assert format_generators([]) == "" + assert format_channels([]) == "" class TestFormatFrequencies: diff --git a/tests/unit/sampletones_core/constants/test_enums.py b/tests/unit/sampletones_core/constants/test_enums.py index 5fb55627f..3d4d08a5a 100644 --- a/tests/unit/sampletones_core/constants/test_enums.py +++ b/tests/unit/sampletones_core/constants/test_enums.py @@ -1,15 +1,15 @@ -from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names +from sampletones_core.constants.enums import ChannelName, abbreviate_channel_names -class TestAbbreviateGeneratorNames: +class TestAbbreviateChannelNames: def test_single_generator_produces_its_abbreviation(self) -> None: - assert abbreviate_generator_names([GeneratorName.PULSE1]) == "P" + assert abbreviate_channel_names([ChannelName.PULSE1]) == "P" def test_multiple_generators_concatenates_in_order(self) -> None: - assert "PTN" == abbreviate_generator_names( + assert "PTN" == abbreviate_channel_names( [ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, ] ) diff --git a/tests/unit/sampletones_core/exporters/test_naming.py b/tests/unit/sampletones_core/exporters/test_naming.py index a3ffb93ac..61ffb649b 100644 --- a/tests/unit/sampletones_core/exporters/test_naming.py +++ b/tests/unit/sampletones_core/exporters/test_naming.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.naming import instrument_slice_name from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -14,29 +14,29 @@ class TestInstrumentSliceName(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class NameCase(BaseRegularTestCase): - generator: GeneratorName + channel: ChannelName expected: str test_cases = ( NameCase( - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, expected="Kick (pulse1)", - label=GeneratorName.PULSE1.value, + label=ChannelName.PULSE1.value, ), NameCase( - generator=GeneratorName.PULSE2, + channel=ChannelName.PULSE2, expected="Kick (pulse2)", - label=GeneratorName.PULSE2.value, + label=ChannelName.PULSE2.value, ), NameCase( - generator=GeneratorName.TRIANGLE, + channel=ChannelName.TRIANGLE, expected="Kick (triangle)", - label=GeneratorName.TRIANGLE.value, + label=ChannelName.TRIANGLE.value, ), NameCase( - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, expected="Kick (noise)", - label=GeneratorName.NOISE.value, + label=ChannelName.NOISE.value, ), ) @@ -45,11 +45,11 @@ def test_the_generator_follows_the_base_name_in_parentheses( self, case: NameCase, ) -> None: - assert instrument_slice_name(BASE_NAME, case.generator) == case.expected + assert instrument_slice_name(BASE_NAME, case.channel) == case.expected def test_every_generator_gets_a_distinct_name(self) -> None: - names = {instrument_slice_name(BASE_NAME, generator) for generator in GeneratorName.items()} - assert len(names) == len(GeneratorName.items()) + names = {instrument_slice_name(BASE_NAME, channel) for channel in ChannelName.items()} + assert len(names) == len(ChannelName.items()) def test_the_base_name_is_carried_verbatim(self) -> None: - assert instrument_slice_name("Lead 2 (alt)", GeneratorName.PULSE1).startswith("Lead 2 (alt) ") + assert instrument_slice_name("Lead 2 (alt)", ChannelName.PULSE1).startswith("Lead 2 (alt) ") diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index 856bb3bd4..6d006e365 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.slices import iterate_sample_slices from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project @@ -21,8 +21,8 @@ def _project(samples: Sequence[Sample]) -> Project: return project -def _sample(name: str, generators: Sequence[GeneratorName]) -> Sample: - return Sample(name=name, reconstruction=sample_reconstruction(list(generators))) +def _sample(name: str, channels: Sequence[ChannelName]) -> Sample: + return Sample(name=name, reconstruction=sample_reconstruction(list(channels))) class TestSampleSlices: @@ -33,37 +33,37 @@ class TestSampleSlices: """ def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None: - project = _project([_sample("lead", [GeneratorName.PULSE1, GeneratorName.NOISE])]) + project = _project([_sample("lead", [ChannelName.PULSE1, ChannelName.NOISE])]) slices = list(iterate_sample_slices(project)) - assert [sample_slice.generator for sample_slice in slices] == [ - GeneratorName.PULSE1, - GeneratorName.NOISE, + assert [sample_slice.channel for sample_slice in slices] == [ + ChannelName.PULSE1, + ChannelName.NOISE, ] def test_a_channel_standing_by_takes_no_place_in_the_table(self) -> None: - sample = _sample("lead", [GeneratorName.PULSE1, GeneratorName.PULSE2]) - sample.reconstruction.update_generator_data( - GeneratorName.PULSE1, + sample = _sample("lead", [ChannelName.PULSE1, ChannelName.PULSE2]) + sample.reconstruction.update_channel_data( + ChannelName.PULSE1, [], np.zeros(0, dtype=np.float32), - sample.reconstruction.initial_pitches[GeneratorName.PULSE1], + sample.reconstruction.initial_pitches[ChannelName.PULSE1], (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), ) project = _project([sample]) slices = list(iterate_sample_slices(project)) - assert [(sample_slice.index, sample_slice.generator) for sample_slice in slices] == [ - (0, GeneratorName.PULSE2), + assert [(sample_slice.index, sample_slice.channel) for sample_slice in slices] == [ + (0, ChannelName.PULSE2), ] def test_slices_are_numbered_across_the_samples_in_order(self) -> None: project = _project( [ - _sample("lead", [GeneratorName.PULSE1]), - _sample("pad", [GeneratorName.TRIANGLE, GeneratorName.NOISE]), + _sample("lead", [ChannelName.PULSE1]), + _sample("pad", [ChannelName.TRIANGLE, ChannelName.NOISE]), ] ) diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 012d27341..53addb9df 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -1,14 +1,14 @@ from sampletones_core.constants.enums import ( + ChannelName, FeatureKey, - GeneratorName, LibraryGeneratorName, ) from sampletones_core.exporters.implementation.noise import NoiseExporter from sampletones_core.exporters.implementation.pulse import PulseExporter from sampletones_core.exporters.implementation.triangle import TriangleExporter from sampletones_core.features import ( + CHANNEL_GENERATOR_KIND, FEATURE_DIMENSION_ORDER, - GENERATOR_KIND, feature_range, supported_features, supports, @@ -38,7 +38,7 @@ def test_supported_features_follow_dimension_order() -> None: def test_feature_ranges_match_expected_channel_domains() -> None: assert feature_range(LibraryGeneratorName.PULSE, FeatureKey.DUTY_CYCLE) == feature_range( - GENERATOR_KIND[GeneratorName.PULSE1], + CHANNEL_GENERATOR_KIND[ChannelName.PULSE1], FeatureKey.DUTY_CYCLE, ) assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.DUTY_CYCLE).maximum == 1 diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index ae7d40788..567372954 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.trackers.request import InstrumentExport, SampleExport @@ -17,7 +17,7 @@ def build_features( duty_cycle: Optional[Sequence[int]] = None, initial_pitch: int = REFERENCE_PITCH, ) -> Features: - """Builds the envelopes of one generator slice, flat in every dimension left out.""" + """Builds the envelopes of one channel slice, flat in every dimension left out.""" contour = np.zeros(len(volume), dtype=int) if arpeggio is None else np.array(arpeggio, dtype=int) return Features( initial_pitch=initial_pitch, @@ -33,12 +33,12 @@ def build_instrument( name: str, features: Features, *, - generator: GeneratorName = GeneratorName.PULSE1, + channel: ChannelName = ChannelName.PULSE1, loop: bool = False, ) -> InstrumentExport: return InstrumentExport( name=name, - generator=generator, + channel=channel, features=features, loop=loop, nes_frequency=NES_FREQUENCY, diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index dc7b3abaa..1c65bdd76 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -5,7 +5,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.bitphase.btp import project_to_bytes, write_btp from sampletones_core.formats.bitphase.builder import sample_to_bitphase from sampletones_core.formats.bitphase.model.project import BitphaseProject @@ -68,7 +68,7 @@ def project_fixture() -> BitphaseProject: build_instrument( "Kick (noise)", build_features(VOLUME_ENVELOPE, duty_cycle=[1, 1, 0, 0]), - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, ), ) ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py index 7494578f5..424525221 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import NUM_PERIODS from sampletones_core.formats.bitphase.builder import ( PREVIEW_REST_PATTERN_ID, @@ -62,7 +62,7 @@ def project_fixture() -> BitphaseProject: build_instrument( "Kick (noise)", build_features(VOLUME_ENVELOPE, initial_pitch=NOISE_PERIOD), - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, ), ) ) @@ -207,7 +207,7 @@ def test_a_noise_slice_reaches_the_noise_channel(self) -> None: build_instrument( "Hat", build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD), - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, ) ) row = project.songs[0].patterns[0].channels[int(ChannelIndex.NOISE)].rows[PREVIEW_TRIGGER_ROW] @@ -222,7 +222,7 @@ def test_a_noise_table_holds_offsets_within_one_period_cycle(self) -> None: arpeggio=[0, -1, -2, -3], initial_pitch=NOISE_PERIOD, ), - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, ) ) assert all(0 <= offset < NUM_PERIODS for offset in project.tables[0].rows) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index 414cbc07f..e8fc2c373 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import NUM_PERIODS from sampletones_core.formats.bitphase.envelopes import ( ChannelEnvelopes, @@ -24,17 +24,17 @@ @dataclass class PulseWidthCase: - generator: GeneratorName + channel: ChannelName duty_cycle: int pulse_width: int PULSE_WIDTH_CASES: List[PulseWidthCase] = [ - PulseWidthCase(generator=GeneratorName.PULSE1, duty_cycle=2, pulse_width=2), - PulseWidthCase(generator=GeneratorName.PULSE2, duty_cycle=3, pulse_width=3), - PulseWidthCase(generator=GeneratorName.TRIANGLE, duty_cycle=3, pulse_width=FLAT_PULSE_WIDTH), - PulseWidthCase(generator=GeneratorName.NOISE, duty_cycle=0, pulse_width=NOISE_MODE_LONG), - PulseWidthCase(generator=GeneratorName.NOISE, duty_cycle=1, pulse_width=NOISE_MODE_SHORT), + PulseWidthCase(channel=ChannelName.PULSE1, duty_cycle=2, pulse_width=2), + PulseWidthCase(channel=ChannelName.PULSE2, duty_cycle=3, pulse_width=3), + PulseWidthCase(channel=ChannelName.TRIANGLE, duty_cycle=3, pulse_width=FLAT_PULSE_WIDTH), + PulseWidthCase(channel=ChannelName.NOISE, duty_cycle=0, pulse_width=NOISE_MODE_LONG), + PulseWidthCase(channel=ChannelName.NOISE, duty_cycle=1, pulse_width=NOISE_MODE_SHORT), ] VOLUME_ENVELOPE: Final[List[int]] = [15, 12, 8, 4, 0] @@ -45,7 +45,7 @@ class TestRowsCarryTheEnvelopes: def test_each_volume_item_becomes_one_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE @@ -53,7 +53,7 @@ def test_each_volume_item_becomes_one_row(self) -> None: def test_the_contour_becomes_the_table(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert list(envelopes.table_rows) == PITCH_CONTOUR @@ -61,12 +61,12 @@ def test_the_contour_becomes_the_table(self) -> None: @pytest.mark.parametrize( "case", PULSE_WIDTH_CASES, - ids=lambda case: f"{case.generator}-{case.duty_cycle}", + ids=lambda case: f"{case.channel}-{case.duty_cycle}", ) def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidthCase) -> None: envelopes = features_to_envelopes( build_features([15], duty_cycle=[case.duty_cycle]), - case.generator, + case.channel, loop=False, ) assert envelopes.rows[0].pulse_width == case.pulse_width @@ -74,7 +74,7 @@ def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidt def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), - GeneratorName.TRIANGLE, + ChannelName.TRIANGLE, loop=False, ) assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} @@ -83,7 +83,7 @@ def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: steps = [0, 1, -1, 5] envelopes = features_to_envelopes( build_features([15] * len(steps), arpeggio=steps), - GeneratorName.NOISE, + ChannelName.NOISE, loop=False, ) assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] @@ -98,7 +98,7 @@ class TestTheDimensionsStayInStep: def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=loop, ) assert len(envelopes.rows) == len(envelopes.table_rows) @@ -106,7 +106,7 @@ def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=True, ) assert len(envelopes.rows) == 2 @@ -114,7 +114,7 @@ def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] @@ -122,7 +122,7 @@ def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: def test_a_slice_without_a_contour_holds_its_note(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=[]), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) @@ -132,7 +132,7 @@ class TestTheLoopPoint: def test_a_looping_slice_returns_to_its_first_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=True, ) assert envelopes.loop == LOOP_FROM_START @@ -140,7 +140,7 @@ def test_a_looping_slice_returns_to_its_first_row(self) -> None: def test_a_one_shot_rests_on_its_last_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert envelopes.loop == len(envelopes.rows) - 1 @@ -151,7 +151,7 @@ def test_a_one_shot_rests_in_silence(self) -> None: """ envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME @@ -160,7 +160,7 @@ def test_a_one_shot_rests_in_silence(self) -> None: def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=loop, ) assert envelopes.loop < len(envelopes.rows) @@ -175,7 +175,7 @@ class TestASliceThatLeavesItsVolumeToTheChannel: def test_it_holds_a_full_row_per_frame(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) @@ -183,7 +183,7 @@ def test_it_holds_a_full_row_per_frame(self) -> None: def test_its_contour_still_moves_the_note(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert list(envelopes.table_rows) == PITCH_CONTOUR @@ -192,7 +192,7 @@ def test_its_duty_envelope_still_reaches_the_rows(self) -> None: duty_cycles = [0, 1, 2, 3] envelopes = features_to_envelopes( build_features([], duty_cycle=duty_cycles), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert [row.pulse_width for row in envelopes.rows] == duty_cycles @@ -200,7 +200,7 @@ def test_its_duty_envelope_still_reaches_the_rows(self) -> None: def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=False, ) assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE @@ -208,7 +208,7 @@ def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, + ChannelName.PULSE1, loop=True, ) assert len(envelopes.rows) == len(PITCH_CONTOUR) @@ -222,7 +222,7 @@ class TestAnEmptySlice: @pytest.fixture(name="envelopes") def envelopes_fixture(self) -> ChannelEnvelopes: - return features_to_envelopes(build_features([]), GeneratorName.PULSE1, loop=False) + return features_to_envelopes(build_features([]), ChannelName.PULSE1, loop=False) def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index ed3b8e11f..1e3345a16 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -4,7 +4,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset from sampletones_core.formats.bitphase.notes import pitch_to_note_index from sampletones_core.formats.bitphase.preset import ( @@ -92,7 +92,7 @@ def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: build_instrument( "Hat", build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD), - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, ), ) assert {row.tone_add for row in preset.rows} == {NO_TONE_OFFSET} diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index c1ff20a1d..cbe3166b7 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -5,7 +5,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.formats.bitphase.builder import project_to_bitphase from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell @@ -61,9 +61,9 @@ def build_reconstruction( - instructions: Mapping[GeneratorName, Sequence[Instruction]], + instructions: Mapping[ChannelName, Sequence[Instruction]], ) -> Reconstruction: - approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions} + approximations = {channel: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for channel in instructions} return Reconstruction.create( approximation=np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32), approximations=approximations, @@ -78,7 +78,7 @@ def pulse_sample(name: str, pitch: int) -> Sample: instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0)] return Sample( name=name, - reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}), + reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}), ) @@ -86,7 +86,7 @@ def triangle_sample(name: str, pitch: int) -> Sample: instructions = [TriangleInstruction(on=True, pitch=pitch)] return Sample( name=name, - reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions}), + reconstruction=build_reconstruction({ChannelName.TRIANGLE: instructions}), ) @@ -108,32 +108,32 @@ def source_fixture(lead: Sample, bass: Sample) -> Project: pulse_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] pulse_rows[TRIGGER_ROW] = Row( - command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE1), transpose=0, volume=ROW_VOLUME, ) pulse_rows[NOTE_OFF_ROW] = Row(command=NoteOff()) pulse_rows[TRANSPOSED_ROW] = Row( - command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE1), transpose=TRANSPOSE, ) pulse_rows[SILENCED_ROW] = Row(volume=SILENT_VOLUME) triangle_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] triangle_rows[TRIGGER_ROW] = Row( - command=Instrument(sample_id=bass.id, generator_name=GeneratorName.TRIANGLE), + command=Instrument(sample_id=bass.id, channel_name=ChannelName.TRIANGLE), transpose=0, ) channels = { - GeneratorName.PULSE1: Channel(generator=GeneratorName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), - GeneratorName.PULSE2: Channel(generator=GeneratorName.PULSE2, patterns={}), - GeneratorName.TRIANGLE: Channel(generator=GeneratorName.TRIANGLE, patterns={0: Pattern(rows=triangle_rows)}), - GeneratorName.NOISE: Channel(generator=GeneratorName.NOISE, patterns={}), + ChannelName.PULSE1: Channel(name=ChannelName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), + ChannelName.PULSE2: Channel(name=ChannelName.PULSE2, patterns={}), + ChannelName.TRIANGLE: Channel(name=ChannelName.TRIANGLE, patterns={0: Pattern(rows=triangle_rows)}), + ChannelName.NOISE: Channel(name=ChannelName.NOISE, patterns={}), } - order: List[Dict[GeneratorName, Optional[int]]] = [ - {GeneratorName.PULSE1: 0, GeneratorName.TRIANGLE: 0}, - {GeneratorName.PULSE1: None, GeneratorName.TRIANGLE: 0}, + order: List[Dict[ChannelName, Optional[int]]] = [ + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: None, ChannelName.TRIANGLE: 0}, ] project = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) @@ -318,12 +318,12 @@ def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: class TestAnUnbuildableRow: def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] - rows[TRIGGER_ROW] = Row(command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE2)) - source.song.channels[GeneratorName.PULSE2] = Channel( - generator=GeneratorName.PULSE2, + rows[TRIGGER_ROW] = Row(command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE2)) + source.song.channels[ChannelName.PULSE2] = Channel( + name=ChannelName.PULSE2, patterns={0: Pattern(rows=rows)}, ) - source.song.order[0][GeneratorName.PULSE2] = 0 + source.song.order[0][ChannelName.PULSE2] = 0 with pytest.raises(ValueError, match="has no instrument"): project_to_bitphase(source) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py index 7763628aa..13583ba15 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py @@ -52,7 +52,7 @@ def ntsc_table_fixture() -> Tuple[int, ...]: class TestTheTableMatchesBitphase: """The tuning table is the contract with Bitphase: the tracker derives its own from the same settings, so a document whose table differs plays at a different pitch than - the reconstruction it came from. These numbers come from Bitphase's own generator. + the reconstruction it came from. These numbers come from Bitphase's own channel. """ def test_the_ntsc_table_equals_the_one_bitphase_derives( diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index aaf858000..3315dc074 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -6,7 +6,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions.implementation.noise import NoiseInstruction from sampletones_core.instructions.implementation.pulse import PulseInstruction from sampletones_core.instructions.implementation.triangle import TriangleInstruction @@ -27,9 +27,9 @@ def build_reconstruction( - instructions: Mapping[GeneratorName, Sequence[Instruction]], + instructions: Mapping[ChannelName, Sequence[Instruction]], ) -> Reconstruction: - approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions} + approximations = {channel: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for channel in instructions} return Reconstruction.create( approximation=np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32), approximations=approximations, @@ -47,7 +47,7 @@ def pulse_sample(name: str, pitch: int, *, loop: bool = False) -> Sample: ] return Sample( name=name, - reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}), + reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}), loop=loop, ) @@ -56,14 +56,14 @@ def noise_sample(name: str, period: int) -> Sample: instructions = [NoiseInstruction(on=True, period=period, volume=15, short=False)] return Sample( name=name, - reconstruction=build_reconstruction({GeneratorName.NOISE: instructions}), + reconstruction=build_reconstruction({ChannelName.NOISE: instructions}), ) def dual_generator_sample(name: str, pulse_pitch: int, triangle_pitch: int) -> Sample: - instructions: Mapping[GeneratorName, Sequence[Instruction]] = { - GeneratorName.PULSE1: [PulseInstruction(on=True, pitch=pulse_pitch, volume=15, duty_cycle=0)], - GeneratorName.TRIANGLE: [TriangleInstruction(on=True, pitch=triangle_pitch)], + instructions: Mapping[ChannelName, Sequence[Instruction]] = { + ChannelName.PULSE1: [PulseInstruction(on=True, pitch=pulse_pitch, volume=15, duty_cycle=0)], + ChannelName.TRIANGLE: [TriangleInstruction(on=True, pitch=triangle_pitch)], } return Sample(name=name, reconstruction=build_reconstruction(instructions)) @@ -90,7 +90,7 @@ def project_fixture() -> ProjectFixture: pulse_rows: List[Row] = [Row() for _ in range(8)] pulse_rows[0] = Row( - command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), + command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE1), transpose=0, volume=10, ) @@ -99,29 +99,29 @@ def project_fixture() -> ProjectFixture: noise_rows: List[Row] = [Row() for _ in range(8)] noise_rows[0] = Row( - command=Instrument(sample_id=drum.id, generator_name=GeneratorName.NOISE), + command=Instrument(sample_id=drum.id, channel_name=ChannelName.NOISE), transpose=0, volume=15, ) channels = { - GeneratorName.PULSE1: Channel(generator=GeneratorName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), - GeneratorName.PULSE2: Channel(generator=GeneratorName.PULSE2, patterns={}), - GeneratorName.TRIANGLE: Channel(generator=GeneratorName.TRIANGLE, patterns={}), - GeneratorName.NOISE: Channel(generator=GeneratorName.NOISE, patterns={0: Pattern(rows=noise_rows)}), + ChannelName.PULSE1: Channel(channel_name=ChannelName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), + ChannelName.PULSE2: Channel(channel_name=ChannelName.PULSE2, patterns={}), + ChannelName.TRIANGLE: Channel(channel_name=ChannelName.TRIANGLE, patterns={}), + ChannelName.NOISE: Channel(channel_name=ChannelName.NOISE, patterns={0: Pattern(rows=noise_rows)}), } order = [ { - GeneratorName.PULSE1: 0, - GeneratorName.PULSE2: None, - GeneratorName.TRIANGLE: None, - GeneratorName.NOISE: 0, + ChannelName.PULSE1: 0, + ChannelName.PULSE2: None, + ChannelName.TRIANGLE: None, + ChannelName.NOISE: 0, }, { - GeneratorName.PULSE1: None, - GeneratorName.PULSE2: None, - GeneratorName.TRIANGLE: None, - GeneratorName.NOISE: None, + ChannelName.PULSE1: None, + ChannelName.PULSE2: None, + ChannelName.TRIANGLE: None, + ChannelName.NOISE: None, }, ] song = Song(rows_per_pattern=8, order=order, channels=channels) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index 0d04f2812..768790831 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.builder import ( build_instrument_table, project_to_module, @@ -46,12 +46,12 @@ def test_one_instrument_per_generator_slice(self, project_fixture: ProjectFixtur def test_slot_maps_sample_and_generator_to_index(self, project_fixture: ProjectFixture) -> None: _, slots = build_instrument_table(project_fixture.project) - assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].index == 0 - assert slots[(project_fixture.bell.id, GeneratorName.TRIANGLE)].index == 4 + assert slots[(project_fixture.lead.id, ChannelName.PULSE1)].index == 0 + assert slots[(project_fixture.bell.id, ChannelName.TRIANGLE)].index == 4 def test_slot_carries_initial_pitch(self, project_fixture: ProjectFixture) -> None: _, slots = build_instrument_table(project_fixture.project) - assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].initial_pitch == LEAD_PITCH + assert slots[(project_fixture.lead.id, ChannelName.PULSE1)].initial_pitch == LEAD_PITCH def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: ProjectFixture) -> None: """A pattern row triggers the instrument at the note its sample was reconstructed at. @@ -64,8 +64,8 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj PulseInstruction(on=True, pitch=LEAD_PITCH + OCTAVE, volume=15, duty_cycle=0), PulseInstruction(on=True, pitch=LEAD_PITCH, volume=8, duty_cycle=0), ] - project_fixture.lead.reconstruction.update_generator_data( - GeneratorName.PULSE1, + project_fixture.lead.reconstruction.update_channel_data( + ChannelName.PULSE1, arpeggiated, np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32), LEAD_PITCH, @@ -74,26 +74,26 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj instruments, slots = build_instrument_table(project_fixture.project) - slot = slots[(project_fixture.lead.id, GeneratorName.PULSE1)] + slot = slots[(project_fixture.lead.id, ChannelName.PULSE1)] assert slot.initial_pitch == LEAD_PITCH assert list(instruments[slot.index].sequences[SequenceKind.ARPEGGIO].items)[0] == OCTAVE def test_looping_sample_loops_populated_sequences(self, project_fixture: ProjectFixture) -> None: instruments, slots = build_instrument_table(project_fixture.project) - pad_index = slots[(project_fixture.pad.id, GeneratorName.PULSE1)].index + pad_index = slots[(project_fixture.pad.id, ChannelName.PULSE1)].index pad = instruments[pad_index] assert pad.sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START def test_non_looping_sample_leaves_loop_disabled(self, project_fixture: ProjectFixture) -> None: instruments, slots = build_instrument_table(project_fixture.project) - lead_index = slots[(project_fixture.lead.id, GeneratorName.PULSE1)].index + lead_index = slots[(project_fixture.lead.id, ChannelName.PULSE1)].index assert instruments[lead_index].sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT def test_exceeding_max_instruments_raises(self) -> None: project = Project.create() for number in range(MAX_INSTRUMENTS + 1): instructions = [PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0)] - reconstruction = build_reconstruction({GeneratorName.PULSE1: instructions}) + reconstruction = build_reconstruction({ChannelName.PULSE1: instructions}) project.samples.append(Sample(name=f"sample-{number}", reconstruction=reconstruction)) with pytest.raises(ValueError): build_instrument_table(project) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index ca7a69fc0..b19ac276d 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.footprint import ( @@ -40,7 +40,7 @@ def build_features( arpeggio: Sequence[int], duty_cycle: Optional[Sequence[int]], ) -> Features: - """Builds the envelopes of one generator slice, leaving the pitch dimensions unused.""" + """Builds the envelopes of one channel slice, leaving the pitch dimensions unused.""" return Features( initial_pitch=REFERENCE_PITCH, volume=np.array(volume, dtype=int), @@ -162,14 +162,14 @@ def test_one_entry_per_playing_channel(self) -> None: """The sample holds every channel; the two that play are the two an export writes.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) - assert set(footprints) == {GeneratorName.PULSE1, GeneratorName.TRIANGLE} + assert set(footprints) == {ChannelName.PULSE1, ChannelName.TRIANGLE} def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None: """Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) - pulse = footprints[GeneratorName.PULSE1] - triangle = footprints[GeneratorName.TRIANGLE] + pulse = footprints[ChannelName.PULSE1] + triangle = footprints[ChannelName.TRIANGLE] assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: @@ -177,8 +177,8 @@ def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: features = sample.reconstruction.export() for loop in (False, True): assert reconstruction_footprints(sample.reconstruction, loop=loop) == { - generator_name: features_footprint(feature, loop=loop) - for generator_name, feature in features.items() + channel_name: features_footprint(feature, loop=loop) + for channel_name, feature in features.items() if feature.has_frames } diff --git a/tests/unit/sampletones_core/generators/implementation/test_noise.py b/tests/unit/sampletones_core/generators/implementation/test_noise.py index ae230967e..4da629faf 100644 --- a/tests/unit/sampletones_core/generators/implementation/test_noise.py +++ b/tests/unit/sampletones_core/generators/implementation/test_noise.py @@ -2,7 +2,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.constants.general import ( MAX_VOLUME, MIXER_NOISE, @@ -30,12 +30,12 @@ def config() -> Config: @pytest.fixture def generator(config: Config) -> NoiseGenerator: - return NoiseGenerator(config, GeneratorName.NOISE) + return NoiseGenerator(config, ChannelName.NOISE) @pytest.fixture def pulse_generator(config: Config) -> PulseGenerator: - return PulseGenerator(config, GeneratorName.PULSE1) + return PulseGenerator(config, ChannelName.PULSE1) class TestNoiseGeneratorCall: diff --git a/tests/unit/sampletones_core/generators/implementation/test_pulse.py b/tests/unit/sampletones_core/generators/implementation/test_pulse.py index c096bfc4e..a879ee71c 100644 --- a/tests/unit/sampletones_core/generators/implementation/test_pulse.py +++ b/tests/unit/sampletones_core/generators/implementation/test_pulse.py @@ -2,7 +2,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.constants.general import DUTY_CYCLES, MAX_VOLUME, MIXER_PULSE from sampletones_core.generators.implementation.pulse import PulseGenerator from sampletones_core.instructions import NoiseInstruction, PulseInstruction @@ -19,7 +19,7 @@ def config() -> Config: @pytest.fixture def generator(config: Config) -> PulseGenerator: - return PulseGenerator(config, GeneratorName.PULSE1) + return PulseGenerator(config, ChannelName.PULSE1) class TestPulseGeneratorCall: diff --git a/tests/unit/sampletones_core/generators/implementation/test_triangle.py b/tests/unit/sampletones_core/generators/implementation/test_triangle.py index d0dc6419b..e51fb15f0 100644 --- a/tests/unit/sampletones_core/generators/implementation/test_triangle.py +++ b/tests/unit/sampletones_core/generators/implementation/test_triangle.py @@ -2,7 +2,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.constants.general import MIXER_TRIANGLE from sampletones_core.generators.implementation.triangle import TriangleGenerator from sampletones_core.instructions import PulseInstruction, TriangleInstruction @@ -19,7 +19,7 @@ def config() -> Config: @pytest.fixture def generator(config: Config) -> TriangleGenerator: - return TriangleGenerator(config, GeneratorName.TRIANGLE) + return TriangleGenerator(config, ChannelName.TRIANGLE) class TestTriangleGeneratorCall: diff --git a/tests/unit/sampletones_core/generators/test_generator.py b/tests/unit/sampletones_core/generators/test_generator.py index 94a418b71..64493a5b6 100644 --- a/tests/unit/sampletones_core/generators/test_generator.py +++ b/tests/unit/sampletones_core/generators/test_generator.py @@ -3,7 +3,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.algorithm import MIN_SAMPLE_LENGTH -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import CyclicArray from sampletones_core.generators.implementation.pulse import PulseGenerator from sampletones_core.instructions import PulseInstruction @@ -16,13 +16,13 @@ def config() -> Config: @pytest.fixture def generator(config: Config) -> PulseGenerator: - return PulseGenerator(config, GeneratorName.PULSE1) + return PulseGenerator(config, ChannelName.PULSE1) class TestGeneratorInit: def test_non_config_raises(self) -> None: with pytest.raises(TypeError): - PulseGenerator("not_a_config", GeneratorName.PULSE1) + PulseGenerator("not_a_config", ChannelName.PULSE1) def test_non_str_name_raises(self, config: Config) -> None: with pytest.raises(TypeError): diff --git a/tests/unit/sampletones_core/generators/test_utils.py b/tests/unit/sampletones_core/generators/test_utils.py index e30432696..d320ba5b0 100644 --- a/tests/unit/sampletones_core/generators/test_utils.py +++ b/tests/unit/sampletones_core/generators/test_utils.py @@ -3,13 +3,13 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName +from sampletones_core.constants.enums import ChannelName, GeneratorClassName from sampletones_core.generators.implementation.noise import NoiseGenerator from sampletones_core.generators.implementation.pulse import PulseGenerator from sampletones_core.generators.implementation.triangle import TriangleGenerator from sampletones_core.generators.utils import ( get_generator_by_instruction, - get_generators_by_names, + get_generators_by_channels, get_generators_map, get_remaining_generator_classes, ) @@ -28,27 +28,27 @@ def config() -> Config: @pytest.fixture def all_generators(config: Config) -> dict: return { - GeneratorClassName.PULSE_GENERATOR: PulseGenerator(config, GeneratorName.PULSE1), - GeneratorClassName.TRIANGLE_GENERATOR: TriangleGenerator(config, GeneratorName.TRIANGLE), - GeneratorClassName.NOISE_GENERATOR: NoiseGenerator(config, GeneratorName.NOISE), + GeneratorClassName.PULSE_GENERATOR: PulseGenerator(config, ChannelName.PULSE1), + GeneratorClassName.TRIANGLE_GENERATOR: TriangleGenerator(config, ChannelName.TRIANGLE), + GeneratorClassName.NOISE_GENERATOR: NoiseGenerator(config, ChannelName.NOISE), } -class TestGetGeneratorsByNames: +class TestGetGeneratorsByChannels: def test_pulse1_returns_pulse1(self, config: Config) -> None: - result = get_generators_by_names(config, [GeneratorName.PULSE1]) - assert GeneratorName.PULSE1 in result - assert isinstance(result[GeneratorName.PULSE1], PulseGenerator) + result = get_generators_by_channels(config, [ChannelName.PULSE1]) + assert ChannelName.PULSE1 in result + assert isinstance(result[ChannelName.PULSE1], PulseGenerator) def test_pulse2_without_pulse1_is_replaced_by_pulse1(self, config: Config) -> None: - result = get_generators_by_names(config, [GeneratorName.PULSE2]) - assert GeneratorName.PULSE1 in result - assert GeneratorName.PULSE2 not in result + result = get_generators_by_channels(config, [ChannelName.PULSE2]) + assert ChannelName.PULSE1 in result + assert ChannelName.PULSE2 not in result def test_multiple_names_all_returned(self, config: Config) -> None: - result = get_generators_by_names(config, [GeneratorName.PULSE1, GeneratorName.TRIANGLE]) - assert GeneratorName.PULSE1 in result - assert GeneratorName.TRIANGLE in result + result = get_generators_by_channels(config, [ChannelName.PULSE1, ChannelName.TRIANGLE]) + assert ChannelName.PULSE1 in result + assert ChannelName.TRIANGLE in result class TestGetGeneratorsMap: @@ -68,8 +68,8 @@ def test_generators_are_correct_types(self, config: Config) -> None: class TestGetRemainingGeneratorClasses: def test_maps_by_class_name(self, config: Config) -> None: named = { - GeneratorName.PULSE1: PulseGenerator(config, GeneratorName.PULSE1), - GeneratorName.NOISE: NoiseGenerator(config, GeneratorName.NOISE), + ChannelName.PULSE1: PulseGenerator(config, ChannelName.PULSE1), + ChannelName.NOISE: NoiseGenerator(config, ChannelName.NOISE), } result = get_remaining_generator_classes(named) assert GeneratorClassName.PULSE_GENERATOR in result diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py index bf55412ce..042d0ab7f 100644 --- a/tests/unit/sampletones_core/project/patterns/test_channel.py +++ b/tests/unit/sampletones_core/project/patterns/test_channel.py @@ -1,4 +1,4 @@ -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.row import Row @@ -7,7 +7,7 @@ def _channel() -> Channel: - return Channel.empty(GeneratorName.PULSE1, ROWS_PER_PATTERN) + return Channel.empty(ChannelName.PULSE1, ROWS_PER_PATTERN) class TestPatternPool: @@ -21,7 +21,7 @@ def test_clone_pattern_copies_rows_with_new_identity(self) -> None: channel = _channel() source = channel.patterns[0] source.rows[0] = Row( - instrument=Instrument(sample_id="abc", generator_name=GeneratorName.PULSE1), + instrument=Instrument(sample_id="abc", channel_name=ChannelName.PULSE1), volume=10, ) diff --git a/tests/unit/sampletones_core/project/patterns/test_pattern.py b/tests/unit/sampletones_core/project/patterns/test_pattern.py index 6f2815f88..0c79cbe33 100644 --- a/tests/unit/sampletones_core/project/patterns/test_pattern.py +++ b/tests/unit/sampletones_core/project/patterns/test_pattern.py @@ -1,6 +1,6 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row @@ -13,7 +13,7 @@ def _empty_pattern() -> Pattern: def _row_with_instrument() -> Row: - return Row(command=Instrument(sample_id="x", generator_name=GeneratorName.PULSE1)) + return Row(command=Instrument(sample_id="x", channel_name=ChannelName.PULSE1)) class TestRowIsEmpty: @@ -21,7 +21,7 @@ def test_default_row_is_empty(self) -> None: assert Row().is_empty() def test_row_with_instrument_is_not_empty(self) -> None: - assert not Row(command=Instrument(sample_id="x", generator_name=GeneratorName.PULSE1)).is_empty() + assert not Row(command=Instrument(sample_id="x", channel_name=ChannelName.PULSE1)).is_empty() def test_row_with_transpose_is_not_empty(self) -> None: assert not Row(transpose=0).is_empty() diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 22051afab..3dca432b6 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -5,7 +5,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.data import Metadata from sampletones_core.project.container import ProjectContainer from sampletones_core.project.instruments.instrument import Instrument @@ -55,23 +55,23 @@ def _populated_project( project.samples.extend([first, second]) song = project.song - channel = song[GeneratorName.PULSE1] + channel = song[ChannelName.PULSE1] pattern = channel.patterns[0] pattern.name = "intro" pattern.rows[0] = Row( transpose=0, command=Instrument( sample_id=first.id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), volume=15, ) extra_index = channel.add_pattern(song.rows_per_pattern, name="verse") song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, extra_index) + song.set_order_entry(1, ChannelName.PULSE1, extra_index) song.append_frame() - song.set_order_entry(2, GeneratorName.PULSE1, 0) + song.set_order_entry(2, ChannelName.PULSE1, 0) return project @@ -95,9 +95,9 @@ def test_full_round_trip( loaded_song = loaded.song assert loaded_song.order == project.song.order - pulse1_index_at_0 = loaded_song.order[0].get(GeneratorName.PULSE1) + pulse1_index_at_0 = loaded_song.order[0].get(ChannelName.PULSE1) first_pattern = loaded_song.pattern( - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse1_index_at_0, ) assert first_pattern.name == "intro" @@ -117,11 +117,11 @@ def test_references_resolve_after_load( loaded = ProjectContainer.load(path) loaded_song = loaded.song - channel = loaded_song[GeneratorName.PULSE1] - index_at_0 = loaded_song.order[0].get(GeneratorName.PULSE1) + channel = loaded_song[ChannelName.PULSE1] + index_at_0 = loaded_song.order[0].get(ChannelName.PULSE1) row = channel.pattern(index_at_0).rows[0] assert loaded.sample(row.command.sample_id) is loaded.samples[0] - index_at_2 = loaded_song.order[2].get(GeneratorName.PULSE1) + index_at_2 = loaded_song.order[2].get(ChannelName.PULSE1) assert channel.pattern(index_at_0) is channel.pattern(index_at_2) @@ -171,7 +171,7 @@ def test_document_is_plain_json( document = json.loads(archive.read(PROJECT_DOCUMENT_NAME).decode("utf-8")) assert document["format_version"] == SAMPLETONES_PROJECT_DATA_VERSION - assert set(document["song"]["channels"]) == {generator.value for generator in GeneratorName.items()} + assert set(document["song"]["channels"]) == {channel.value for channel in ChannelName.items()} class TestEmptyProject: @@ -183,7 +183,7 @@ def test_round_trip_without_instruments(self, tmp_path: Path) -> None: loaded = ProjectContainer.load(path) assert len(loaded.samples) == 0 - assert set(loaded.song.channels) == set(GeneratorName.items()) + assert set(loaded.song.channels) == set(ChannelName.items()) with zipfile.ZipFile(path, "r") as archive: assert all(not name.startswith(f"{RECONSTRUCTIONS_DIRECTORY}/") for name in archive.namelist()) diff --git a/tests/unit/sampletones_core/project/test_models.py b/tests/unit/sampletones_core/project/test_models.py index b48949bc4..40038932a 100644 --- a/tests/unit/sampletones_core/project/test_models.py +++ b/tests/unit/sampletones_core/project/test_models.py @@ -3,7 +3,7 @@ import pytest from pydantic import ValidationError -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row @@ -14,7 +14,7 @@ def _instrument() -> Instrument: return Instrument( sample_id="abc123", - generator_name=GeneratorName.TRIANGLE, + channel_name=ChannelName.TRIANGLE, ) @@ -31,8 +31,8 @@ def test_value_equality_and_hash(self) -> None: assert hash(first) == hash(second) def test_distinct_slices_differ(self) -> None: - triangle = Instrument(sample_id="abc", generator_name=GeneratorName.TRIANGLE) - noise = Instrument(sample_id="abc", generator_name=GeneratorName.NOISE) + triangle = Instrument(sample_id="abc", channel_name=ChannelName.TRIANGLE) + noise = Instrument(sample_id="abc", channel_name=ChannelName.NOISE) assert triangle != noise def test_round_trip(self) -> None: diff --git a/tests/unit/sampletones_core/project/test_serialization.py b/tests/unit/sampletones_core/project/test_serialization.py index 3883e2a88..b82424a05 100644 --- a/tests/unit/sampletones_core/project/test_serialization.py +++ b/tests/unit/sampletones_core/project/test_serialization.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern @@ -20,7 +20,7 @@ def _pattern_with_instrument() -> Pattern: volume=15, instrument=Instrument( sample_id="abc123", - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ), ) return pattern @@ -42,7 +42,7 @@ def test_dump_round_trips_identically(self) -> None: class TestChannelSerialization: def _channel(self) -> Channel: return Channel( - generator=GeneratorName.PULSE1, + name=ChannelName.PULSE1, patterns={0: Pattern.empty(4, name="a"), 1: Pattern.empty(4, name="b")}, ) @@ -76,12 +76,12 @@ def test_json_round_trip(self) -> None: def test_channels_preserved(self) -> None: song = Song.empty(rows_per_pattern=8) restored = Song.model_validate(song.model_dump()) - assert set(restored.channels) == set(GeneratorName.items()) + assert set(restored.channels) == set(ChannelName.items()) def test_order_preserved(self) -> None: song = Song.empty(rows_per_pattern=8) song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 0) + song.set_order_entry(1, ChannelName.PULSE1, 0) restored = Song.model_validate(song.model_dump()) assert restored.order == song.order assert restored.rows_per_pattern == song.rows_per_pattern diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index c6a38264b..a8e005947 100644 --- a/tests/unit/sampletones_core/project/test_song.py +++ b/tests/unit/sampletones_core/project/test_song.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song @@ -18,10 +18,10 @@ def _song(rows_per_pattern: int = _ROWS) -> Song: return Song.empty(rows_per_pattern) -def _place_instrument(song: Song, generator: GeneratorName, sample_id: str, row_index: int = 0) -> None: - pattern = song.pattern(generator, 0) +def _place_instrument(song: Song, channel: ChannelName, sample_id: str, row_index: int = 0) -> None: + pattern = song.pattern(channel, 0) assert pattern is not None - pattern.rows[row_index] = Row(command=Instrument(sample_id=sample_id, generator_name=generator)) + pattern.rows[row_index] = Row(command=Instrument(sample_id=sample_id, channel_name=channel)) class TestSongEmpty: @@ -32,13 +32,13 @@ def test_creates_one_frame_order(self) -> None: def test_first_frame_maps_all_channels_to_zero(self) -> None: song = _song() frame = song.order[0] - for generator in GeneratorName.items(): - assert frame[generator] == 0 + for channel in ChannelName.items(): + assert frame[channel] == 0 def test_all_channels_present(self) -> None: song = _song() - for generator in GeneratorName.items(): - assert generator in song.channels + for channel in ChannelName.items(): + assert channel in song.channels def test_rows_per_pattern_stored(self) -> None: song = Song.empty(16) @@ -48,20 +48,20 @@ def test_rows_per_pattern_stored(self) -> None: class TestSongGetItem: def test_getitem_returns_channel(self) -> None: song = _song() - channel = song[GeneratorName.PULSE1] - assert channel.generator == GeneratorName.PULSE1 + channel = song[ChannelName.PULSE1] + assert channel.name == ChannelName.PULSE1 class TestSongPattern: def test_pattern_returns_pattern_at_index(self) -> None: song = _song() - pattern = song.pattern(GeneratorName.PULSE1, 0) + pattern = song.pattern(ChannelName.PULSE1, 0) assert pattern is not None assert len(pattern.rows) == _ROWS def test_pattern_returns_none_for_missing_index(self) -> None: song = _song() - assert song.pattern(GeneratorName.PULSE1, 99) is None + assert song.pattern(ChannelName.PULSE1, 99) is None class TestSongOrderLength: @@ -76,8 +76,8 @@ def test_append_adds_frame_with_all_none(self) -> None: song = _song() song.append_frame() frame = song.order[1] - for generator in GeneratorName.items(): - assert frame[generator] is None + for channel in ChannelName.items(): + assert frame[channel] is None def test_append_increments_order_length(self) -> None: song = _song() @@ -91,16 +91,16 @@ def test_insert_at_zero_shifts_existing_frame(self) -> None: song = _song() song.insert_frame(0) assert song.order_length() == 2 - for generator in GeneratorName.items(): - assert song.order[0][generator] is None - assert song.order[1][generator] == 0 + for channel in ChannelName.items(): + assert song.order[0][channel] is None + assert song.order[1][channel] == 0 def test_insert_at_end_appends_none_frame(self) -> None: song = _song() song.insert_frame(1) assert song.order_length() == 2 - for generator in GeneratorName.items(): - assert song.order[1][generator] is None + for channel in ChannelName.items(): + assert song.order[1][channel] is None class TestSongRemoveFrame: @@ -113,9 +113,9 @@ def test_remove_frame_decrements_order_length(self) -> None: def test_remove_frame_at_zero_removes_first(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 3) + song.set_order_entry(1, ChannelName.PULSE1, 3) song.remove_frame(0) - assert song.order[0][GeneratorName.PULSE1] == 3 + assert song.order[0][ChannelName.PULSE1] == 3 def test_remove_only_frame_leaves_empty_order(self) -> None: song = _song() @@ -126,13 +126,13 @@ def test_remove_only_frame_leaves_empty_order(self) -> None: class TestSongSetOrderEntry: def test_set_order_entry_updates_value(self) -> None: song = _song() - song.set_order_entry(0, GeneratorName.TRIANGLE, 5) - assert song.order[0][GeneratorName.TRIANGLE] == 5 + song.set_order_entry(0, ChannelName.TRIANGLE, 5) + assert song.order[0][ChannelName.TRIANGLE] == 5 def test_set_order_entry_to_none(self) -> None: song = _song() - song.set_order_entry(0, GeneratorName.PULSE1, None) - assert song.order[0][GeneratorName.PULSE1] is None + song.set_order_entry(0, ChannelName.PULSE1, None) + assert song.order[0][ChannelName.PULSE1] is None class TestSongMoveFrame: @@ -140,60 +140,60 @@ def test_move_frame_reorders(self) -> None: song = _song() song.append_frame() song.append_frame() - song.set_order_entry(0, GeneratorName.PULSE1, 1) - song.set_order_entry(1, GeneratorName.PULSE1, 2) - song.set_order_entry(2, GeneratorName.PULSE1, 3) + song.set_order_entry(0, ChannelName.PULSE1, 1) + song.set_order_entry(1, ChannelName.PULSE1, 2) + song.set_order_entry(2, ChannelName.PULSE1, 3) song.move_frame(0, 2) - assert song.order[0][GeneratorName.PULSE1] == 2 - assert song.order[1][GeneratorName.PULSE1] == 3 - assert song.order[2][GeneratorName.PULSE1] == 1 + assert song.order[0][ChannelName.PULSE1] == 2 + assert song.order[1][ChannelName.PULSE1] == 3 + assert song.order[2][ChannelName.PULSE1] == 1 def test_move_frame_no_op_when_same_position(self) -> None: song = _song() - song.set_order_entry(0, GeneratorName.PULSE1, 7) + song.set_order_entry(0, ChannelName.PULSE1, 7) song.move_frame(0, 0) - assert song.order[0][GeneratorName.PULSE1] == 7 + assert song.order[0][ChannelName.PULSE1] == 7 class TestSongDuplicateFrame: def test_duplicate_inserts_frame_after_position(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 3) + song.set_order_entry(1, ChannelName.PULSE1, 3) song.duplicate_frame(0) assert song.order_length() == 3 - assert song.order[2][GeneratorName.PULSE1] == 3 + assert song.order[2][ChannelName.PULSE1] == 3 def test_duplicate_points_channels_at_the_same_patterns(self) -> None: song = _song() song.duplicate_frame(0) - source_index = song.order[0][GeneratorName.PULSE1] - duplicate_index = song.order[1][GeneratorName.PULSE1] + source_index = song.order[0][ChannelName.PULSE1] + duplicate_index = song.order[1][ChannelName.PULSE1] assert duplicate_index == source_index def test_duplicate_allocates_no_pattern(self) -> None: song = _song() - pattern_count = len(song[GeneratorName.PULSE1].patterns) + pattern_count = len(song[ChannelName.PULSE1].patterns) song.duplicate_frame(0) - assert len(song[GeneratorName.PULSE1].patterns) == pattern_count + assert len(song[ChannelName.PULSE1].patterns) == pattern_count def test_editing_a_shared_pattern_is_heard_in_both_frames(self) -> None: song = _song() song.duplicate_frame(0) - duplicate_index = song.order[1][GeneratorName.PULSE1] + duplicate_index = song.order[1][ChannelName.PULSE1] assert duplicate_index is not None - _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0) + _place_instrument(song, ChannelName.PULSE1, "sample-a", row_index=0) - shared_pattern = song.pattern(GeneratorName.PULSE1, duplicate_index) + shared_pattern = song.pattern(ChannelName.PULSE1, duplicate_index) assert shared_pattern is not None assert shared_pattern.rows[0].command is not None @@ -202,88 +202,88 @@ def test_repointing_one_frame_leaves_the_other_where_it_was(self) -> None: song = _song() song.duplicate_frame(0) - song.set_order_entry(1, GeneratorName.PULSE1, 9) + song.set_order_entry(1, ChannelName.PULSE1, 9) - assert song.order[0][GeneratorName.PULSE1] == 0 + assert song.order[0][ChannelName.PULSE1] == 0 def test_duplicate_carries_an_unmaterialised_index_across(self) -> None: song = _song() - song.set_order_entry(0, GeneratorName.PULSE1, 7) + song.set_order_entry(0, ChannelName.PULSE1, 7) song.duplicate_frame(0) - assert song.order[1][GeneratorName.PULSE1] == 7 - assert song.pattern(GeneratorName.PULSE1, 7) is None + assert song.order[1][ChannelName.PULSE1] == 7 + assert song.pattern(ChannelName.PULSE1, 7) is None class TestSongCloneFrame: def test_clone_inserts_frame_after_position(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 3) + song.set_order_entry(1, ChannelName.PULSE1, 3) song.clone_frame(0) assert song.order_length() == 3 - assert song.order[2][GeneratorName.PULSE1] == 3 + assert song.order[2][ChannelName.PULSE1] == 3 def test_clone_points_channels_at_fresh_patterns(self) -> None: song = _song() song.clone_frame(0) - source_index = song.order[0][GeneratorName.PULSE1] - clone_index = song.order[1][GeneratorName.PULSE1] + source_index = song.order[0][ChannelName.PULSE1] + clone_index = song.order[1][ChannelName.PULSE1] assert clone_index != source_index def test_clone_avoids_indices_referenced_by_other_frames(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 7) + song.set_order_entry(1, ChannelName.PULSE1, 7) song.clone_frame(0) - clone_index = song.order[1][GeneratorName.PULSE1] + clone_index = song.order[1][ChannelName.PULSE1] assert clone_index != 7 def test_editing_a_cloned_pattern_leaves_the_source_untouched(self) -> None: song = _song() - _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0) - source_index = song.order[0][GeneratorName.PULSE1] + _place_instrument(song, ChannelName.PULSE1, "sample-a", row_index=0) + source_index = song.order[0][ChannelName.PULSE1] song.clone_frame(0) - clone_index = song.order[1][GeneratorName.PULSE1] - song[GeneratorName.PULSE1].set_row(clone_index, 0, Row()) + clone_index = song.order[1][ChannelName.PULSE1] + song[ChannelName.PULSE1].set_row(clone_index, 0, Row()) - source_pattern = song.pattern(GeneratorName.PULSE1, source_index) + source_pattern = song.pattern(ChannelName.PULSE1, source_index) assert source_pattern is not None assert source_pattern.rows[0].command is not None def test_clone_keeps_a_silent_slot_silent(self) -> None: song = _song() - song.set_order_entry(0, GeneratorName.NOISE, None) + song.set_order_entry(0, ChannelName.NOISE, None) song.clone_frame(0) - assert song.order[1][GeneratorName.NOISE] is None + assert song.order[1][ChannelName.NOISE] is None class TestSongPatternAllocation: def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None: song = _song() - song.set_order_entry(0, GeneratorName.PULSE1, 4) + song.set_order_entry(0, ChannelName.PULSE1, 4) - index = song.add_pattern(GeneratorName.PULSE1) + index = song.add_pattern(ChannelName.PULSE1) assert index != 4 - assert index in song[GeneratorName.PULSE1].patterns + assert index in song[ChannelName.PULSE1].patterns def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 6) + song.set_order_entry(1, ChannelName.PULSE1, 6) - clone_index = song.clone_pattern(GeneratorName.PULSE1, 0) + clone_index = song.clone_pattern(ChannelName.PULSE1, 0) assert clone_index != 6 @@ -291,43 +291,43 @@ def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None: class TestSongClearFrame: def test_clear_frame_sets_all_channels_to_none(self) -> None: song = _song() - for generator in GeneratorName.items(): - song.set_order_entry(0, generator, 3) + for channel in ChannelName.items(): + song.set_order_entry(0, channel, 3) song.clear_frame(0) - assert all(song.order[0][generator] is None for generator in GeneratorName.items()) + assert all(song.order[0][channel] is None for channel in ChannelName.items()) def test_clear_frame_leaves_other_frames(self) -> None: song = _song() song.append_frame() - song.set_order_entry(0, GeneratorName.PULSE1, 1) - song.set_order_entry(1, GeneratorName.PULSE1, 2) + song.set_order_entry(0, ChannelName.PULSE1, 1) + song.set_order_entry(1, ChannelName.PULSE1, 2) song.clear_frame(0) - assert song.order[0][GeneratorName.PULSE1] is None - assert song.order[1][GeneratorName.PULSE1] == 2 + assert song.order[0][ChannelName.PULSE1] is None + assert song.order[1][ChannelName.PULSE1] == 2 class TestSongOrderedPatterns: def test_ordered_patterns_returns_pattern_objects(self) -> None: song = _song() - patterns = song.ordered_patterns(GeneratorName.PULSE1) + patterns = song.ordered_patterns(ChannelName.PULSE1) assert len(patterns) == 1 assert patterns[0] is not None def test_ordered_patterns_returns_none_for_none_slot(self) -> None: song = _song() song.append_frame() - patterns = song.ordered_patterns(GeneratorName.PULSE1) + patterns = song.ordered_patterns(ChannelName.PULSE1) assert patterns[1] is None def test_ordered_patterns_same_index_repeated_returns_same_object(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 0) - patterns = song.ordered_patterns(GeneratorName.PULSE1) + song.set_order_entry(1, ChannelName.PULSE1, 0) + patterns = song.ordered_patterns(ChannelName.PULSE1) assert patterns[0] is patterns[1] @@ -335,22 +335,22 @@ class TestSongRemovePattern: def test_remove_pattern_clears_order_references(self) -> None: song = _song() song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 0) + song.set_order_entry(1, ChannelName.PULSE1, 0) - song.remove_pattern(GeneratorName.PULSE1, 0) + song.remove_pattern(ChannelName.PULSE1, 0) - assert song.order[0][GeneratorName.PULSE1] is None - assert song.order[1][GeneratorName.PULSE1] is None + assert song.order[0][ChannelName.PULSE1] is None + assert song.order[1][ChannelName.PULSE1] is None def test_remove_pattern_does_not_affect_other_channels(self) -> None: song = _song() - song.remove_pattern(GeneratorName.PULSE1, 0) - assert song.order[0][GeneratorName.TRIANGLE] == 0 + song.remove_pattern(ChannelName.PULSE1, 0) + assert song.order[0][ChannelName.TRIANGLE] == 0 def test_remove_nonexistent_pattern_raises(self) -> None: song = _song() with pytest.raises(KeyError): - song.remove_pattern(GeneratorName.PULSE1, 99) + song.remove_pattern(ChannelName.PULSE1, 99) class TestSongReferencesSample: @@ -359,32 +359,32 @@ def test_false_when_no_row_references_any_sample(self) -> None: def test_true_when_a_row_references_the_sample(self) -> None: song = _song() - _place_instrument(song, GeneratorName.PULSE1, "abc") + _place_instrument(song, ChannelName.PULSE1, "abc") assert song.references_sample("abc") is True def test_false_for_a_different_sample_id(self) -> None: song = _song() - _place_instrument(song, GeneratorName.PULSE1, "abc") + _place_instrument(song, ChannelName.PULSE1, "abc") assert song.references_sample("xyz") is False class TestSongClearSampleReferences: def test_clears_only_rows_referencing_the_target(self) -> None: song = _song() - _place_instrument(song, GeneratorName.PULSE1, "abc", row_index=0) - _place_instrument(song, GeneratorName.PULSE1, "keep", row_index=1) + _place_instrument(song, ChannelName.PULSE1, "abc", row_index=0) + _place_instrument(song, ChannelName.PULSE1, "keep", row_index=1) song.clear_sample_references("abc") - pattern = song.pattern(GeneratorName.PULSE1, 0) + pattern = song.pattern(ChannelName.PULSE1, 0) assert pattern is not None assert pattern.rows[0].command is None assert pattern.rows[1].command is not None def test_clears_references_across_all_channels(self) -> None: song = _song() - _place_instrument(song, GeneratorName.PULSE1, "abc") - _place_instrument(song, GeneratorName.TRIANGLE, "abc") + _place_instrument(song, ChannelName.PULSE1, "abc") + _place_instrument(song, ChannelName.TRIANGLE, "abc") song.clear_sample_references("abc") @@ -392,11 +392,11 @@ def test_clears_references_across_all_channels(self) -> None: def test_leaves_rows_untouched_when_sample_absent(self) -> None: song = _song() - _place_instrument(song, GeneratorName.PULSE1, "abc") + _place_instrument(song, ChannelName.PULSE1, "abc") song.clear_sample_references("missing") - pattern = song.pattern(GeneratorName.PULSE1, 0) + pattern = song.pattern(ChannelName.PULSE1, 0) assert pattern is not None assert pattern.rows[0].command is not None diff --git a/tests/unit/sampletones_core/project/test_structure.py b/tests/unit/sampletones_core/project/test_structure.py index db7ffb9f0..6066a6b28 100644 --- a/tests/unit/sampletones_core/project/test_structure.py +++ b/tests/unit/sampletones_core/project/test_structure.py @@ -2,7 +2,7 @@ from typing import Dict from unittest.mock import Mock -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.channel import Channel @@ -27,26 +27,26 @@ def test_empty_pattern(self) -> None: class TestChannel: def test_empty_channel(self) -> None: - channel = Channel.empty(GeneratorName.PULSE1, rows_per_pattern=16) - assert channel.generator == GeneratorName.PULSE1 + channel = Channel.empty(ChannelName.PULSE1, rows_per_pattern=16) + assert channel.name == ChannelName.PULSE1 assert len(channel.patterns) == 1 assert 0 in channel.patterns def test_pattern_resolution(self) -> None: - channel = Channel.empty(GeneratorName.NOISE, rows_per_pattern=4) + channel = Channel.empty(ChannelName.NOISE, rows_per_pattern=4) assert channel.pattern(0) is channel.patterns[0] def test_unknown_pattern_returns_none(self) -> None: - channel = Channel.empty(GeneratorName.NOISE, rows_per_pattern=4) + channel = Channel.empty(ChannelName.NOISE, rows_per_pattern=4) assert channel.pattern(99) is None class TestSong: def test_empty_song_has_all_channels(self) -> None: song = Song.empty(rows_per_pattern=8) - assert set(song.channels) == set(GeneratorName.items()) - for generator in GeneratorName.items(): - assert song[generator].generator == generator + assert set(song.channels) == set(ChannelName.items()) + for channel in ChannelName.items(): + assert song[channel].name == channel class TestProject: @@ -54,7 +54,7 @@ def test_create(self) -> None: project = Project.create(title="Demo") assert project.info.title == "Demo" assert len(project.samples) == 0 - assert set(project.song.channels) == set(GeneratorName.items()) + assert set(project.song.channels) == set(ChannelName.items()) def test_instrument_resolution(self) -> None: project = Project.create() @@ -81,7 +81,7 @@ def _build_instrument_context(self) -> SampleContext: first = _sample("first") second = _sample("second") project.samples.extend([first, second]) - instrument = Instrument(sample_id=first.id, generator_name=GeneratorName.PULSE1) + instrument = Instrument(sample_id=first.id, channel_name=ChannelName.PULSE1) return SampleContext(project=project, sample=first, instrument=instrument) def test_instrument_survives_instrument_reorder(self) -> None: diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py index ce127850c..55af0c517 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py @@ -8,7 +8,7 @@ format_spectrum_method, format_transformation_gamma, ) -from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names +from sampletones_core.constants.enums import ChannelName, abbreviate_channel_names from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields HASH = "6edf7c948606917a78b45d153c7ca7e0" @@ -35,7 +35,7 @@ def test_same_config_produces_same_name(self, config: Config) -> None: def test_different_generator_sets_produce_different_names(self, config: Config) -> None: single_generator_config = config.model_copy( - update={"generation": config.generation.model_copy(update={"generators": [GeneratorName.PULSE1]})} + update={"generation": config.generation.model_copy(update={"channels": [ChannelName.PULSE1]})} ) assert ConfigDirectoryFields.generate_config_directory_name( @@ -58,7 +58,7 @@ def test_parses_components(self, config: Config) -> None: assert fields.nf == config.library.nes_frequency assert fields.sm == config.library.spectrum_method assert fields.tg == config.library.transformation_gamma - assert fields.generators == tuple(config.generation.generators) + assert fields.channels == tuple(config.generation.channels) def test_directory_name_embeds_field_keys(self, config: Config) -> None: name = ConfigDirectoryFields.generate_config_directory_name(config) @@ -90,5 +90,5 @@ def test_display_name_combines_formatted_parts(self, config: Config) -> None: assert format_nes_frequency(config.library.nes_frequency) in display assert format_spectrum_method(config.library.spectrum_method) in display assert format_transformation_gamma(config.library.transformation_gamma) in display - assert abbreviate_generator_names(list(config.generation.generators)) in display + assert abbreviate_channel_names(list(config.generation.channels)) in display assert DISPLAY_SEPARATOR in display diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index fe7f269e6..9e2481a93 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -7,7 +7,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.data import Metadata from sampletones_core.features import resting_reference from sampletones_core.instructions import PulseInstruction @@ -48,8 +48,8 @@ def _pulse(pitch: int) -> PulseInstruction: def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: return Reconstruction.create( approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), - approximations={GeneratorName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, - instructions={GeneratorName.PULSE1: instructions}, + approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={ChannelName.PULSE1: instructions}, config=Config(), coefficient=1.0, audio_filepath=Path("/dev/null"), @@ -64,7 +64,7 @@ def _saved_playing_channels_only(path: Path) -> Path: """ reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) reconstruction.instructions_data = [ - item for item in reconstruction.instructions_data if item.generator_name == GeneratorName.PULSE1 + item for item in reconstruction.instructions_data if item.channel_name == ChannelName.PULSE1 ] reconstruction.save(path) return path @@ -243,7 +243,7 @@ class TestInitialPitchReference: def test_create_anchors_each_generator_to_its_contour(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH), _pulse(_BASE_PITCH + _OCTAVE)]) - assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _CONTOUR_MIDPOINT + assert reconstruction.initial_pitches[ChannelName.PULSE1] == _CONTOUR_MIDPOINT def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None: """An arpeggiated channel exports offsets from the pitch it was anchored at. @@ -258,15 +258,15 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None _pulse(_BASE_PITCH), _pulse(_BASE_PITCH), ] - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, arpeggiated, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (), ) - features = reconstruction.export()[GeneratorName.PULSE1] + features = reconstruction.export()[ChannelName.PULSE1] assert features.initial_pitch == _BASE_PITCH assert features.arpeggio.tolist() == [_OCTAVE, 0] @@ -274,15 +274,15 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None def test_update_generator_data_replaces_the_reference(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [_pulse(_RESET_PITCH)], np.ones(_AUDIO_LENGTH, dtype=np.float32), _RESET_PITCH, (), ) - assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH + assert reconstruction.initial_pitches[ChannelName.PULSE1] == _RESET_PITCH def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH), _pulse(_BASE_PITCH + _OCTAVE)]) @@ -295,7 +295,7 @@ def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None class TestHeldFeatures: - """The dimensions each generator leaves to the channel travel with its instructions. + """The dimensions each channel leaves to the channel travel with its instructions. A frame states every dimension, so an export reads which of them the instrument itself wrote from the reconstruction rather than from the frames. @@ -304,35 +304,35 @@ class TestHeldFeatures: def test_a_fresh_reconstruction_writes_every_dimension(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.held_features[GeneratorName.PULSE1] == () + assert reconstruction.held_features[ChannelName.PULSE1] == () def test_a_held_dimension_exports_an_empty_envelope(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (FeatureKey.ARPEGGIO,), ) - features = reconstruction.export()[GeneratorName.PULSE1] + features = reconstruction.export()[ChannelName.PULSE1] assert features.arpeggio.size == 0 assert features.volume.size > 0 def test_the_written_dimensions_export_their_items(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (FeatureKey.ARPEGGIO,), ) - features = reconstruction.export()[GeneratorName.PULSE1] + features = reconstruction.export()[ChannelName.PULSE1] assert features.duty_cycle is not None assert features.duty_cycle.size > 0 @@ -344,8 +344,8 @@ def test_the_record_reads_back_off_the_exported_envelopes(self) -> None: """ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, @@ -354,17 +354,17 @@ def test_the_record_reads_back_off_the_exported_envelopes(self) -> None: exported = reconstruction.export() assert reconstruction.held_features == { - generator_name: features.held_features for generator_name, features in exported.items() + channel_name: features.held_features for channel_name, features in exported.items() } def test_a_channel_standing_by_leaves_every_dimension_it_offers(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.held_features[GeneratorName.TRIANGLE] == ( + assert reconstruction.held_features[ChannelName.TRIANGLE] == ( FeatureKey.VOLUME, FeatureKey.ARPEGGIO, ) - assert reconstruction.held_features[GeneratorName.NOISE] == ( + assert reconstruction.held_features[ChannelName.NOISE] == ( FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE, @@ -374,20 +374,20 @@ def test_clearing_the_last_frame_records_what_standing_by_records(self) -> None: """A channel edited out of play reads the same as one that never played.""" reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [], np.zeros(0, dtype=np.float32), - resting_reference(GeneratorName.PULSE1), + resting_reference(ChannelName.PULSE1), (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), ) - assert reconstruction.streams[GeneratorName.PULSE1] == InstructionsItem.resting(GeneratorName.PULSE1) + assert reconstruction.streams[ChannelName.PULSE1] == InstructionsItem.resting(ChannelName.PULSE1) def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, @@ -411,17 +411,17 @@ class TestChannelSet: def test_a_fresh_reconstruction_holds_every_channel(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert set(reconstruction.instructions) == set(GeneratorName.items()) - assert reconstruction.playing_generators == (GeneratorName.PULSE1,) + assert set(reconstruction.instructions) == set(ChannelName.items()) + assert reconstruction.playing_channels == (ChannelName.PULSE1,) def test_a_channel_standing_by_rests_at_the_shared_reference(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.initial_pitches[GeneratorName.TRIANGLE] == resting_reference(GeneratorName.TRIANGLE) - assert reconstruction.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE) + assert reconstruction.initial_pitches[ChannelName.TRIANGLE] == resting_reference(ChannelName.TRIANGLE) + assert reconstruction.initial_pitches[ChannelName.NOISE] == resting_reference(ChannelName.NOISE) def test_a_channel_standing_by_exports_empty_envelopes(self) -> None: - features = _reconstruction([_pulse(_BASE_PITCH)]).export()[GeneratorName.PULSE2] + features = _reconstruction([_pulse(_BASE_PITCH)]).export()[ChannelName.PULSE2] assert not features.has_frames assert features.volume.size == 0 @@ -430,45 +430,45 @@ def test_a_channel_standing_by_exports_empty_envelopes(self) -> None: def test_a_channel_standing_by_renders_no_audio(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert GeneratorName.PULSE2 not in reconstruction.approximations + assert ChannelName.PULSE2 not in reconstruction.approximations def test_clearing_every_frame_keeps_the_channel(self) -> None: """Taking a channel out of play leaves its stream in place, so the edit is reversible.""" reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [], np.zeros(0, dtype=np.float32), _BASE_PITCH, (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), ) - assert reconstruction.playing_generators == () - assert GeneratorName.PULSE1 in reconstruction.instructions - assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _BASE_PITCH - assert not reconstruction.export()[GeneratorName.PULSE1].has_frames + assert reconstruction.playing_channels == () + assert ChannelName.PULSE1 in reconstruction.instructions + assert reconstruction.initial_pitches[ChannelName.PULSE1] == _BASE_PITCH + assert not reconstruction.export()[ChannelName.PULSE1].has_frames def test_a_frame_puts_a_channel_standing_by_into_play(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - reconstruction.update_generator_data( - GeneratorName.PULSE2, + reconstruction.update_channel_data( + ChannelName.PULSE2, [_pulse(_BASE_PITCH)] * 2, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (), ) - assert reconstruction.playing_generators == (GeneratorName.PULSE1, GeneratorName.PULSE2) - assert reconstruction.export()[GeneratorName.PULSE2].has_frames - assert GeneratorName.PULSE2 in reconstruction.approximations + assert reconstruction.playing_channels == (ChannelName.PULSE1, ChannelName.PULSE2) + assert reconstruction.export()[ChannelName.PULSE2].has_frames + assert ChannelName.PULSE2 in reconstruction.approximations def test_a_reconstruction_of_channels_standing_by_stays_valid(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [], np.zeros(0, dtype=np.float32), _BASE_PITCH, @@ -485,30 +485,30 @@ def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) - reconstruction.save(path) loaded = Reconstruction.load(path) - assert set(loaded.instructions) == set(GeneratorName.items()) - assert loaded.playing_generators == reconstruction.playing_generators + assert set(loaded.instructions) == set(ChannelName.items()) + assert loaded.playing_channels == reconstruction.playing_channels assert loaded.initial_pitches == reconstruction.initial_pitches def test_a_file_storing_fewer_streams_reads_as_the_whole_channel_set(self, tmp_path: Path) -> None: loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn")) - assert set(loaded.instructions) == set(GeneratorName.items()) - assert loaded.playing_generators == (GeneratorName.PULSE1,) - assert loaded.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE) - assert not loaded.export()[GeneratorName.TRIANGLE].has_frames + assert set(loaded.instructions) == set(ChannelName.items()) + assert loaded.playing_channels == (ChannelName.PULSE1,) + assert loaded.initial_pitches[ChannelName.NOISE] == resting_reference(ChannelName.NOISE) + assert not loaded.export()[ChannelName.TRIANGLE].has_frames def test_editing_such_a_file_writes_the_whole_channel_set(self, tmp_path: Path) -> None: loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn")) - loaded.update_generator_data( - GeneratorName.PULSE2, + loaded.update_channel_data( + ChannelName.PULSE2, [_pulse(_BASE_PITCH)], np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (), ) - assert [item.generator_name for item in loaded.instructions_data] == list(GeneratorName.items()) + assert [item.channel_name for item in loaded.instructions_data] == list(ChannelName.items()) class TestWithNesFrequency: @@ -526,7 +526,7 @@ def test_resynthesizes_approximation_length(self, reconstruction_factory: Recons retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) faster = reconstruction.with_nes_frequency(_FASTER_FREQUENCY) - generator_approximation = retuned.approximations[GeneratorName.PULSE1] + generator_approximation = retuned.approximations[ChannelName.PULSE1] assert len(retuned.approximation) == len(generator_approximation) assert len(retuned.approximation) == retuned.config.frame_length assert len(faster.approximation) < len(retuned.approximation) @@ -558,15 +558,15 @@ def test_a_channel_standing_by_stays_standing_by( retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) - assert set(retuned.approximations) == {GeneratorName.PULSE1} - assert set(retuned.instructions) == set(GeneratorName.items()) - assert retuned.playing_generators == (GeneratorName.PULSE1,) + assert set(retuned.approximations) == {ChannelName.PULSE1} + assert set(retuned.instructions) == set(ChannelName.items()) + assert retuned.playing_channels == (ChannelName.PULSE1,) def test_a_reconstruction_of_channels_standing_by_retunes_to_silence(self) -> None: """Every channel standing by leaves nothing to render, and the retuned copy says so.""" reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction.update_channel_data( + ChannelName.PULSE1, [], np.zeros(0, dtype=np.float32), _BASE_PITCH, @@ -578,7 +578,7 @@ def test_a_reconstruction_of_channels_standing_by_retunes_to_silence(self) -> No assert retuned.config.nes_frequency == _RETUNED_FREQUENCY assert retuned.approximations == {} assert retuned.approximation.size == 0 - assert retuned.playing_generators == () + assert retuned.playing_channels == () def test_matching_rate_returns_self(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py index 5f58d61a6..6ea14faef 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py @@ -4,11 +4,11 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment, Window from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor from sampletones_core.fft.fragment.audio import FragmentedAudio -from sampletones_core.generators import GeneratorUnion, get_generators_by_names +from sampletones_core.generators import GeneratorUnion, get_generators_by_channels from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibraryData, InstructionLibraryFragment from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker @@ -33,18 +33,18 @@ def extractor(config: Config, window: Window) -> FeatureExtractor: @pytest.fixture(scope="module") -def generators(config: Config) -> Dict[GeneratorName, GeneratorUnion]: - return get_generators_by_names(config, config.generation.generators) +def channels(config: Config) -> Dict[ChannelName, GeneratorUnion]: + return get_generators_by_channels(config, config.generation.channels) @pytest.fixture(scope="module") def library_data( config: Config, extractor: FeatureExtractor, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], ) -> InstructionLibraryData: data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {} - for generator in generators.values(): + for generator in channels.values(): for instruction in list(generator.get_possible_instructions())[:INSTRUCTIONS_PER_GENERATOR_IN_TEST_LIBRARY]: data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor) @@ -55,13 +55,13 @@ def library_data( def worker( config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], library_data: InstructionLibraryData, ) -> ReconstructorWorker: return ReconstructorWorker( config=config, window=window, - generators=generators, + channels=channels, library_data=library_data, signal_length=WORKER_SIGNAL_LENGTH, ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py b/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py index b6b968386..a6e624827 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Window from sampletones_core.generators import GeneratorUnion from sampletones_core.instructions import PulseInstruction @@ -17,7 +17,7 @@ def _selector( config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], worker: ReconstructorWorker, **decoder_overrides: Any, ) -> ViterbiSelector: @@ -26,7 +26,7 @@ def _selector( return ViterbiSelector( updated_config, window, - generators, + channels, worker.scorer, worker.candidate_provider, worker.phase_aligner, @@ -59,10 +59,10 @@ def test_continuity_holds_a_steady_note_where_per_frame_choice_flickers( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], worker: ReconstructorWorker, ) -> None: - selector = _selector(config, window, generators, worker, pitch_weight=1.0) + selector = _selector(config, window, channels, worker, pitch_weight=1.0) frames = _flickering_frames() path = selector._decode(frames) @@ -75,13 +75,13 @@ def test_zero_transition_weights_reduce_to_per_frame_choice( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], worker: ReconstructorWorker, ) -> None: selector = _selector( config, window, - generators, + channels, worker, pitch_weight=0.0, volume_weight=0.0, @@ -101,20 +101,20 @@ def test_identical_instruction_has_no_cost( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], worker: ReconstructorWorker, ) -> None: - selector = _selector(config, window, generators, worker) + selector = _selector(config, window, channels, worker) assert selector._transition_cost(STEADY, STEADY) == 0.0 def test_larger_pitch_jump_costs_more( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], worker: ReconstructorWorker, ) -> None: - selector = _selector(config, window, generators, worker, pitch_weight=0.1) + selector = _selector(config, window, channels, worker, pitch_weight=0.1) near = PulseInstruction(on=True, pitch=61, volume=10, duty_cycle=0) far = PulseInstruction(on=True, pitch=84, volume=10, duty_cycle=0) assert selector._transition_cost(STEADY, near) < selector._transition_cost(STEADY, far) @@ -123,10 +123,10 @@ def test_toggling_on_off_costs_the_on_off_weight( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], worker: ReconstructorWorker, ) -> None: - selector = _selector(config, window, generators, worker, on_off_weight=0.25) + selector = _selector(config, window, channels, worker, on_off_weight=0.25) silence = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0) assert selector._transition_cost(STEADY, silence) == 0.25 @@ -141,5 +141,5 @@ def test_select_is_deterministic( first = worker(fragmented_audio, fragment_ids) second = worker(fragmented_audio, fragment_ids) for fragment_id in fragment_ids: - for generator_name in first[fragment_id]: - assert first[fragment_id][generator_name].instruction == second[fragment_id][generator_name].instruction + for channel_name in first[fragment_id]: + assert first[fragment_id][channel_name].instruction == second[fragment_id][channel_name].instruction diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index 50ec2fd16..5d74546c4 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -33,8 +33,8 @@ def test_generators_initialized_from_config( library_data: InstructionLibraryData, ) -> None: reconstructor = _make_reconstructor(config, library_data) - expected_names = set(config.generation.generators) - assert set(reconstructor.generators.keys()) == expected_names + expected_names = set(config.generation.channels) + assert set(reconstructor.channels.keys()) == expected_names def test_window_created_from_config( self, @@ -66,7 +66,7 @@ def test_uniform_audio_anchors_to_its_level( reconstructor = _make_reconstructor(config, library_data) audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5 coefficient = reconstructor.get_coefficient(audio) - total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.generators.values()) + total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.channels.values()) assert coefficient == pytest.approx(0.5 / total_mixer) def test_coefficient_is_robust_to_a_lone_transient( @@ -76,7 +76,7 @@ def test_coefficient_is_robust_to_a_lone_transient( ) -> None: reconstructor = _make_reconstructor(config, library_data) frame_length = config.library.frame_length - total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.generators.values()) + total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.channels.values()) audio = np.full(frame_length * 24, 0.05, dtype=np.float32) audio[:frame_length] = 1.0 coefficient = reconstructor.get_coefficient(audio) @@ -119,7 +119,7 @@ def test_each_fragment_audio_length_matches_frame_length( assert len(fragment.audio) == config.library.frame_length -class TestReconstructorResetGenerators: +class TestReconstructorResetChannels: def test_reset_clears_generator_states( self, config: Config, @@ -137,7 +137,7 @@ def test_reset_clears_generator_states( ) reconstructor = _make_reconstructor(config, library_data) - for generator in reconstructor.generators.values(): + for generator in reconstructor.channels.values(): if isinstance(generator, PulseGenerator): generator.save_state(True, PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0)) elif isinstance(generator, TriangleGenerator): @@ -145,9 +145,9 @@ def test_reset_clears_generator_states( elif isinstance(generator, NoiseGenerator): generator.save_state(True, NoiseInstruction(on=True, period=0, volume=10, short=False)) - assert all(gen.previous_instruction is not None for gen in reconstructor.generators.values()) + assert all(gen.previous_instruction is not None for gen in reconstructor.channels.values()) reconstructor.reset_generators() - assert all(gen.previous_instruction is None for gen in reconstructor.generators.values()) + assert all(gen.previous_instruction is None for gen in reconstructor.channels.values()) class TestReconstructorUpdateState: @@ -168,19 +168,19 @@ def _setup( } ) reconstructor = _make_reconstructor(updated_config, library_data) - generator_name = next(iter(reconstructor.generators)) + channel_name = next(iter(reconstructor.channels)) instruction = next( instrument for instrument, frag in library_data.data.items() - if frag.generator_class == reconstructor.generators[generator_name].class_name() and instrument.on + if frag.generator_class == reconstructor.channels[channel_name].class_name() and instrument.on ) approximation_data = ApproximationData( - generator_name=generator_name, + channel_name=channel_name, approximation=synthetic_fragment, instruction=instruction, ) - reconstructor.state = ReconstructionState.create(list(reconstructor.generators.keys())) - return reconstructor, generator_name, approximation_data + reconstructor.state = ReconstructionState.create(list(reconstructor.channels.keys())) + return reconstructor, channel_name, approximation_data def test_without_final_regeneration_stores_precomputed_audio_scaled_by_drive( self, @@ -188,7 +188,7 @@ def test_without_final_regeneration_stores_precomputed_audio_scaled_by_drive( library_data: InstructionLibraryData, synthetic_fragment: Fragment, ) -> None: - reconstructor, generator_name, approximation_data = self._setup( + reconstructor, channel_name, approximation_data = self._setup( config, library_data, synthetic_fragment, @@ -197,7 +197,7 @@ def test_without_final_regeneration_stores_precomputed_audio_scaled_by_drive( reconstructor.update_state(approximation_data) expected = np.asarray(synthetic_fragment.audio) * reconstructor.config.generation.drive np.testing.assert_array_almost_equal( - reconstructor.state.approximations[generator_name][0], + reconstructor.state.approximations[channel_name][0], expected, ) @@ -207,14 +207,14 @@ def test_without_final_regeneration_does_not_run_generator( library_data: InstructionLibraryData, synthetic_fragment: Fragment, ) -> None: - reconstructor, generator_name, approximation_data = self._setup( + reconstructor, channel_name, approximation_data = self._setup( config, library_data, synthetic_fragment, final_regeneration=False, ) reconstructor.update_state(approximation_data) - assert reconstructor.generators[generator_name].previous_instruction is None + assert reconstructor.channels[channel_name].previous_instruction is None def test_with_final_regeneration_reruns_generator( self, @@ -222,14 +222,14 @@ def test_with_final_regeneration_reruns_generator( library_data: InstructionLibraryData, synthetic_fragment: Fragment, ) -> None: - reconstructor, generator_name, approximation_data = self._setup( + reconstructor, channel_name, approximation_data = self._setup( config, library_data, synthetic_fragment, final_regeneration=True, ) reconstructor.update_state(approximation_data) - assert reconstructor.generators[generator_name].previous_instruction is approximation_data.instruction + assert reconstructor.channels[channel_name].previous_instruction is approximation_data.instruction class TestReconstructorReconstruct: @@ -240,12 +240,12 @@ def test_state_is_populated_for_each_fragment( fragmented_audio: FragmentedAudio, ) -> None: reconstructor = _make_reconstructor(config, library_data) - reconstructor.state = ReconstructionState.create(list(reconstructor.generators.keys())) + reconstructor.state = ReconstructionState.create(list(reconstructor.channels.keys())) reconstructor.reconstruct(fragmented_audio) fragment_count = len(fragmented_audio.fragments_ids) - for generator_name in reconstructor.generators: - assert len(reconstructor.state.instructions[generator_name]) == fragment_count - assert len(reconstructor.state.approximations[generator_name]) == fragment_count + for channel_name in reconstructor.channels: + assert len(reconstructor.state.instructions[channel_name]) == fragment_count + assert len(reconstructor.state.approximations[channel_name]) == fragment_count def test_approximations_have_correct_frame_length( self, @@ -254,10 +254,10 @@ def test_approximations_have_correct_frame_length( fragmented_audio: FragmentedAudio, ) -> None: reconstructor = _make_reconstructor(config, library_data) - reconstructor.state = ReconstructionState.create(list(reconstructor.generators.keys())) + reconstructor.state = ReconstructionState.create(list(reconstructor.channels.keys())) reconstructor.reconstruct(fragmented_audio) - for generator_name in reconstructor.generators: - for approximation in reconstructor.state.approximations[generator_name]: + for channel_name in reconstructor.channels: + for approximation in reconstructor.state.approximations[channel_name]: assert len(approximation) == config.library.frame_length diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py index ea33f4797..5b6280777 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py @@ -16,8 +16,8 @@ def _candidate_approximations( worker: ReconstructorWorker, ) -> Tuple[Tuple[InstructionUnion, ...], Fragment]: - remaining_generators = dict(worker.generators.items()) - remaining_generator_classes = worker.get_remaining_generator_classes(remaining_generators) + remaining_channels = dict(worker.channels.items()) + remaining_generator_classes = worker.get_remaining_generator_classes(remaining_channels) return worker.candidate_provider.candidates(remaining_generator_classes) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py index 5cd94e134..5b5e2dd30 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py @@ -15,8 +15,8 @@ def test_shortlist_is_ranked_by_aligned_cost_best_first( worker: ReconstructorWorker, synthetic_fragment: Fragment, ) -> None: - remaining_generators = dict(worker.generators.items()) - remaining_generator_classes = worker.get_remaining_generator_classes(remaining_generators) + remaining_channels = dict(worker.channels.items()) + remaining_generator_classes = worker.get_remaining_generator_classes(remaining_channels) scored = worker.selector._score_candidates(synthetic_fragment, remaining_generator_classes) assert 0 < len(scored) <= worker.selector.top_k @@ -41,8 +41,8 @@ def test_phase_shifted_target_selects_its_source_instruction_at_near_zero_cost( library_fragment = library_data[instruction] shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window) - remaining_generators = dict(worker.generators.items()) - remaining_generator_classes = worker.get_remaining_generator_classes(remaining_generators) + remaining_channels = dict(worker.channels.items()) + remaining_generator_classes = worker.get_remaining_generator_classes(remaining_channels) scored = worker.selector._score_candidates(shifted_target, remaining_generator_classes) assert scored[0].instruction == instruction diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py index acb462853..cd8ddff32 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py @@ -7,7 +7,7 @@ import numpy as np import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions.reconstructor.approximation import ( @@ -19,9 +19,9 @@ _FRAME_LENGTH = 16 -def _make_approximation_data(generator_name: GeneratorName) -> ApproximationData: +def _make_approximation_data(channel_name: ChannelName) -> ApproximationData: return ApproximationData( - generator_name=generator_name, + channel_name=channel_name, approximation=MagicMock(spec=Fragment), instruction=PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0), ) @@ -34,17 +34,17 @@ def _make_audio(value: float = 1.0) -> np.ndarray: @dataclass(frozen=True, kw_only=True) class NamesCase(BaseTestCase): label: str - names: List[GeneratorName] + names: List[ChannelName] NAMES_CASES = [ - NamesCase(label="single", names=[GeneratorName.PULSE1]), + NamesCase(label="single", names=[ChannelName.PULSE1]), NamesCase( label="multiple", names=[ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, ], ), ] @@ -62,7 +62,7 @@ def test_empty_list_creates_empty_approximation_dicts(self) -> None: @pytest.mark.parametrize("case", NAMES_CASES, ids=lambda c: c.label) def test_generator_names_stored(self, case: NamesCase) -> None: state = ReconstructionState.create(case.names) - assert state.generator_names == case.names + assert state.channel_names == case.names @pytest.mark.parametrize("case", NAMES_CASES, ids=lambda c: c.label) def test_each_generator_initializes_to_empty_list(self, case: NamesCase) -> None: @@ -86,30 +86,30 @@ class TestCase(BaseTestCase): @pytest.fixture def state(self) -> ReconstructionState: - return ReconstructionState.create([GeneratorName.PULSE1, GeneratorName.TRIANGLE]) + return ReconstructionState.create([ChannelName.PULSE1, ChannelName.TRIANGLE]) def test_instruction_added_for_correct_generator(self, state: ReconstructionState) -> None: - approximation_data = _make_approximation_data(GeneratorName.PULSE1) + approximation_data = _make_approximation_data(ChannelName.PULSE1) state.append(approximation_data, _make_audio()) - assert state.instructions[GeneratorName.PULSE1] == [approximation_data.instruction] + assert state.instructions[ChannelName.PULSE1] == [approximation_data.instruction] def test_approximation_added_for_correct_generator(self, state: ReconstructionState) -> None: audio = _make_audio(0.5) - state.append(_make_approximation_data(GeneratorName.PULSE1), audio) - assert len(state.approximations[GeneratorName.PULSE1]) == 1 - np.testing.assert_array_equal(state.approximations[GeneratorName.PULSE1][0], audio) + state.append(_make_approximation_data(ChannelName.PULSE1), audio) + assert len(state.approximations[ChannelName.PULSE1]) == 1 + np.testing.assert_array_equal(state.approximations[ChannelName.PULSE1][0], audio) @pytest.mark.parametrize("case", ACCUMULATE_CASES, ids=lambda c: c.label) def test_multiple_appends_accumulate_in_order(self, case: TestCase, state: ReconstructionState) -> None: for i in range(case.count): - state.append(_make_approximation_data(GeneratorName.PULSE1), _make_audio(float(i))) - assert len(state.instructions[GeneratorName.PULSE1]) == case.count - assert len(state.approximations[GeneratorName.PULSE1]) == case.count + state.append(_make_approximation_data(ChannelName.PULSE1), _make_audio(float(i))) + assert len(state.instructions[ChannelName.PULSE1]) == case.count + assert len(state.approximations[ChannelName.PULSE1]) == case.count def test_append_to_separate_generators_are_independent(self, state: ReconstructionState) -> None: - state.append(_make_approximation_data(GeneratorName.PULSE1), _make_audio(1.0)) - state.append(_make_approximation_data(GeneratorName.TRIANGLE), _make_audio(2.0)) - assert len(state.instructions[GeneratorName.PULSE1]) == 1 - assert len(state.approximations[GeneratorName.PULSE1]) == 1 - assert len(state.instructions[GeneratorName.TRIANGLE]) == 1 - assert len(state.approximations[GeneratorName.TRIANGLE]) == 1 + state.append(_make_approximation_data(ChannelName.PULSE1), _make_audio(1.0)) + state.append(_make_approximation_data(ChannelName.TRIANGLE), _make_audio(2.0)) + assert len(state.instructions[ChannelName.PULSE1]) == 1 + assert len(state.approximations[ChannelName.PULSE1]) == 1 + assert len(state.instructions[ChannelName.TRIANGLE]) == 1 + assert len(state.approximations[ChannelName.TRIANGLE]) == 1 diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py index 05c23f91a..29c9d47f6 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py @@ -5,7 +5,7 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment, Window from sampletones_core.generators import GeneratorUnion from sampletones_core.library import InstructionLibraryData @@ -17,11 +17,11 @@ class TestReconstructorWorkerHelpers: def test_get_remaining_generator_classes_maps_by_class_name( self, worker: ReconstructorWorker, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], ) -> None: - remaining = dict(worker.generators.items()) + remaining = dict(worker.channels.items()) by_class = worker.get_remaining_generator_classes(remaining) - expected_class_names = {gen.class_name() for gen in generators.values()} + expected_class_names = {gen.class_name() for gen in channels.values()} assert set(by_class.keys()) == expected_class_names @@ -39,21 +39,21 @@ def test_call_result_has_one_entry_per_generator( self, worker: ReconstructorWorker, fragmented_audio: Any, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], ) -> None: result = worker(fragmented_audio, [fragmented_audio.fragments_ids[0]]) per_fragment = next(iter(result.values())) - assert set(per_fragment.keys()) == set(generators.keys()) + assert set(per_fragment.keys()) == set(channels.keys()) def test_approximation_data_has_valid_generator_name( self, worker: ReconstructorWorker, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], synthetic_fragment: Fragment, ) -> None: result = worker.reconstruct(synthetic_fragment) - for generator_name in result: - assert generator_name in generators + for channel_name in result: + assert channel_name in channels def test_combined_approximation_is_not_all_zeros( self, @@ -82,7 +82,7 @@ def test_without_find_best_phase_produces_valid_approximation( self, config: Config, window: Window, - generators: Dict[GeneratorName, GeneratorUnion], + channels: Dict[ChannelName, GeneratorUnion], library_data: InstructionLibraryData, ) -> None: updated_config = config.model_copy( @@ -103,7 +103,7 @@ def test_without_find_best_phase_produces_valid_approximation( local_worker = ReconstructorWorker( config=updated_config, window=window, - generators=generators, + channels=channels, library_data=library_data, signal_length=1 << 20, ) @@ -125,7 +125,7 @@ def _call( fragmented_audio=fragmented_audio, config=worker.config, window=worker.window, - generators=worker.generators, + channels=worker.channels, library_data=library_data, ) @@ -158,8 +158,8 @@ def test_same_inputs_produce_same_instructions( fragment_ids = [fragmented_audio.fragments_ids[0]] result_a = self._call(worker, fragmented_audio, library_data, fragment_ids) result_b = self._call(worker, fragmented_audio, library_data, fragment_ids) - for generator_name in result_a[fragment_ids[0]]: + for channel_name in result_a[fragment_ids[0]]: assert ( - result_a[fragment_ids[0]][generator_name].instruction - == result_b[fragment_ids[0]][generator_name].instruction + result_a[fragment_ids[0]][channel_name].instruction + == result_b[fragment_ids[0]][channel_name].instruction ) diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index 4c9aa41fd..4bf4edeb4 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -7,7 +7,7 @@ import numpy as np import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings @@ -49,7 +49,7 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F def build_instrument(name: str, frames: int) -> InstrumentExport: return InstrumentExport( name=name, - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, features=build_features(frames), loop=False, nes_frequency=NES_FREQUENCY, diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 3f036b020..b17584be0 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.formats.famitracker.specification.sequences import ( @@ -34,7 +34,7 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F def build_instrument(name: str, frames: int) -> InstrumentExport: return InstrumentExport( name=name, - generator=GeneratorName.PULSE1, + channel=ChannelName.PULSE1, features=build_features(frames), loop=False, nes_frequency=NES_FREQUENCY, diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py index c342f7e13..d1470478c 100644 --- a/tests/unit/sampletones_core/utils/test_display.py +++ b/tests/unit/sampletones_core/utils/test_display.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.project import Project from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff @@ -97,7 +97,7 @@ def test_resolves_referenced_instrument(self) -> None: project, samples = _project_with_samples(2) instrument = Instrument( sample_id=samples[1].id, - generator_name=GeneratorName.PULSE1, + channel_name=ChannelName.PULSE1, ) assert ( display_command( diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 80da90062..9635caf1a 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -4,7 +4,7 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.song import ( @@ -34,7 +34,7 @@ NTSC_FREQUENCY: Final[int] = 60 HALF_RATE_FREQUENCY: Final[int] = 30 PROGRAM_AREA_BYTES: Final[int] = 0x8000 -UNBOUNDED_SPACE: Final[int] = MAX_STREAM_OFFSET * len(GeneratorName) +UNBOUNDED_SPACE: Final[int] = MAX_STREAM_OFFSET * len(ChannelName) SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) @@ -46,7 +46,7 @@ def read_word(data: bytes, offset: int) -> int: def stream_offsets(data: bytes) -> Tuple[int, ...]: - return tuple(read_word(data, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(GeneratorName))) + return tuple(read_word(data, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(ChannelName))) def two_tick_song(nes_frequency: int) -> Song: @@ -171,5 +171,5 @@ def test_a_song_filling_the_available_space_exactly_is_written(self) -> None: def test_a_song_reaching_past_the_offset_field_raises(self) -> None: ticks = MAX_STREAM_OFFSET // len(SOUNDING.values) + 1 song = player_song(resting_streams((SOUNDING,) * ticks), NTSC_FREQUENCY, loop_tick=None) - with pytest.raises(SongTooLargeError, match=GeneratorName.PULSE2.value): + with pytest.raises(SongTooLargeError, match=ChannelName.PULSE2.value): song_to_bytes(song, UNBOUNDED_SPACE) diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py index 167e4a90a..1a6b033f5 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/scripts/checks/test_unused_tags.py @@ -17,12 +17,12 @@ TAG_GLOBAL_WINDOW_MAIN = TagName(Page.GLOBAL, Panel.IMPLICIT, Widget.WINDOW, "main") SUF_BUTTON = "button" SUF_BUTTON_COPY = compose_tag(SUF_BUTTON, "copy") -PRE_RECONSTRUCTION_GENERATOR = "generator" +PRE_RECONSTRUCTION_CHANNEL = "channel" PANEL_WIDTH = 320 """ PANEL_SOURCE: Final[str] = """ -from tags.general import PRE_RECONSTRUCTION_GENERATOR, SUF_BUTTON_COPY, TAG_GLOBAL_WINDOW_MAIN +from tags.general import PRE_RECONSTRUCTION_CHANNEL, SUF_BUTTON_COPY, TAG_GLOBAL_WINDOW_MAIN def build() -> None: show(TAG_GLOBAL_WINDOW_MAIN, SUF_BUTTON_COPY) @@ -52,7 +52,7 @@ def test_a_suffix_is_a_fragment(self) -> None: assert {"SUF_BUTTON", "SUF_BUTTON_COPY"}.issubset(fragment_names(TAGS_SOURCE)) def test_a_prefix_is_a_fragment(self) -> None: - assert "PRE_RECONSTRUCTION_GENERATOR" in fragment_names(TAGS_SOURCE) + assert "PRE_RECONSTRUCTION_CHANNEL" in fragment_names(TAGS_SOURCE) def test_a_constant_of_another_kind_is_no_fragment(self) -> None: assert "PANEL_WIDTH" not in fragment_names(TAGS_SOURCE) @@ -69,12 +69,12 @@ def test_a_read_raises_the_count(self) -> None: def test_an_import_alone_raises_no_count(self) -> None: counts: Dict[str, int] = check_unused_tags.reference_counts([module(PANEL_MODULE, PANEL_SOURCE)]) - assert "PRE_RECONSTRUCTION_GENERATOR" not in counts + assert "PRE_RECONSTRUCTION_CHANNEL" not in counts class TestUnreadFragments: def test_a_fragment_nobody_reads_is_reported(self) -> None: - assert unread(TAGS_SOURCE, PANEL_SOURCE) == ["PRE_RECONSTRUCTION_GENERATOR"] + assert unread(TAGS_SOURCE, PANEL_SOURCE) == ["PRE_RECONSTRUCTION_CHANNEL"] def test_a_fragment_feeding_another_fragment_counts_as_read(self) -> None: assert "SUF_BUTTON" not in unread(TAGS_SOURCE, PANEL_SOURCE) @@ -83,7 +83,7 @@ def test_a_fragment_read_in_the_sources_counts_as_read(self) -> None: assert "SUF_BUTTON_COPY" not in unread(TAGS_SOURCE, PANEL_SOURCE) def test_a_tree_reading_every_fragment_reports_nothing(self) -> None: - panel = PANEL_SOURCE.replace("SUF_BUTTON_COPY)", "SUF_BUTTON_COPY, PRE_RECONSTRUCTION_GENERATOR)") + panel = PANEL_SOURCE.replace("SUF_BUTTON_COPY)", "SUF_BUTTON_COPY, PRE_RECONSTRUCTION_CHANNEL)") assert unread(TAGS_SOURCE, panel) == [] From c7da7d12a72290fc48ac402b287483d70a3e15f6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 16:16:44 +0200 Subject: [PATCH 013/142] Renamed: LibraryGeneratorName to GeneratorName --- docs/formats/instruction-libraries.md | 4 +-- docs/formats/projects.md | 2 +- .../coordinators/tabs/instructions.py | 4 +-- .../logic/instruction/library.py | 8 +++--- .../logic/instruction/library_manager.py | 4 +-- .../ui/panels/instruction/library.py | 4 +-- .../reconstruction/instruments/config.py | 14 +++++----- .../reconstruction/instruments/instruments.py | 4 +-- src/sampletones_config/palettes/dark.yaml | 2 +- src/sampletones_config/palettes/light.yaml | 2 +- src/sampletones_config/palettes/studio.yaml | 2 +- .../theme/nodes/library/generator.yaml | 2 +- src/sampletones_core/compatibility/fields.py | 1 + .../compatibility/project/v1_1.py | 5 ++-- src/sampletones_core/constants/enums.py | 2 +- src/sampletones_core/features/spec.py | 28 +++++++++---------- src/sampletones_core/generators/__init__.py | 4 +-- src/sampletones_core/generators/maps.py | 10 +++---- src/sampletones_core/structures/tree/node.py | 4 +-- .../compatibility/project/test_v1_1.py | 4 +-- .../compatibility/test_json.py | 2 +- .../sampletones_core/features/test_spec.py | 26 ++++++++--------- .../formats/famitracker/conftest.py | 8 +++--- .../structures/tree/test_node.py | 12 ++++---- 24 files changed, 80 insertions(+), 78 deletions(-) diff --git a/docs/formats/instruction-libraries.md b/docs/formats/instruction-libraries.md index 6cc8eb725..f695bcfc5 100644 --- a/docs/formats/instruction-libraries.md +++ b/docs/formats/instruction-libraries.md @@ -19,8 +19,8 @@ instruction. Each entry contains: -* **metadata** — the generator class (`pulse` / `triangle` / `noise`) and the - instruction values below; +* **metadata** — the generator class (`PulseGenerator` / `TriangleGenerator` / + `NoiseGenerator`) and the instruction values below; * **instruction values** — the channel command: * **on** (0–1) — whether the channel sounds; * **pitch** (33–119) for pulse and triangle, or **period** (0–15) for noise; diff --git a/docs/formats/projects.md b/docs/formats/projects.md index c0eeaa8a0..42547f686 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -57,5 +57,5 @@ deserialization (see within a matching version are ignored, which leaves room for the format to grow. The current format version is 1.1. Version 1.1 renamed each channel pool's -`generator` key to `channel_name` and a row instrument's `generator_name` key to +`generator` key to `name` and a row instrument's `generator_name` key to `channel_name`; the channel values stored inside never changed. diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index fb6d0b4fa..9864e16e2 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -67,7 +67,7 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import GeneratorName from sampletones_core.library import InstructionLibraryKey from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError @@ -241,7 +241,7 @@ def _request_generate_library(self) -> None: def _on_generator_selected( self, library_key: InstructionLibraryKey, - generator_name: LibraryGeneratorName, + generator_name: GeneratorName, ) -> None: self._library_logic.load_library_and_set_current(library_key) self._library_logic.load_generator(generator_name) diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 23439f211..271f878cb 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -10,11 +10,11 @@ from sampletones_application.view_model.instruction.library import ( LibraryPanelViewModel, ) -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import GeneratorName from sampletones_core.generators import ( GENERATOR_CLASS_MAP, + GENERATOR_TO_CLASS_NAME_MAP, GENERATOR_TO_INSTRUCTION_MAP, - LIBRARY_GENERATOR_CLASS_MAP, ) from sampletones_core.instructions import InstructionUnion from sampletones_core.library import ( @@ -185,11 +185,11 @@ def load_library_file(self, filepath: Path) -> None: self.load_library_and_set_current(library_key) self.update_status() - def load_generator(self, library_generator_name: LibraryGeneratorName) -> None: + def load_generator(self, generator_name: GeneratorName) -> None: if self._is_locked: return - generator_class = GENERATOR_CLASS_MAP[LIBRARY_GENERATOR_CLASS_MAP[library_generator_name]] + generator_class = GENERATOR_CLASS_MAP[GENERATOR_TO_CLASS_NAME_MAP[generator_name]] instruction_class = GENERATOR_TO_INSTRUCTION_MAP[generator_class] instruction = instruction_class.default_instruction() self.load_instruction(instruction) diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index def2e3973..291f42516 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -5,7 +5,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.view_model.instruction.data import InstructionPanelData from sampletones_core.configs import Config -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import GeneratorName from sampletones_core.fft import Window from sampletones_core.instructions.types import InstructionUnion from sampletones_core.library import ( @@ -295,7 +295,7 @@ def _build_library_node( return library_node def _build_generator_nodes(self, parent: TreeNode) -> None: - for generator_name in LibraryGeneratorName: + for generator_name in GeneratorName: GeneratorNode( generator_name.value.capitalize(), generator_name=generator_name, diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index b1fac84de..7b7602f28 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -39,7 +39,7 @@ from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip from sampletones_application.view_model.instruction.library import LibraryPanelViewModel -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import GeneratorName from sampletones_core.library import InstructionLibraryKey from sampletones_core.structures.tree import ( GeneratorNode, @@ -115,7 +115,7 @@ def __init__( self.on_generate_requested: Optional[VoidCallback] = None self.on_cancel_generation: Optional[VoidCallback] = None self.on_library_selected: Optional[Callable[[InstructionLibraryKey], None]] = None - self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, LibraryGeneratorName], None]] = None + self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, GeneratorName], None]] = None self.on_library_remove_requested: Optional[Callable[[InstructionLibraryKey], None]] = None super().__init__( diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py index 76c6e6528..9a056a094 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py @@ -4,7 +4,7 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_core.constants.enums import FeatureKey, LibraryGeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.features import feature_range, supported_features @@ -31,7 +31,7 @@ class FeaturePlotConfig: def make_feature_plot_configs( feature_colors: FeatureColors, language_manager: LanguageManager, -) -> Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]]: +) -> Dict[GeneratorName, Dict[FeatureKey, FeaturePlotConfig]]: labels = _feature_labels(language_manager) colors = _feature_colors(feature_colors) return _build_plot_configs(labels, colors) @@ -66,16 +66,16 @@ def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, BaseColor def _build_plot_configs( labels: Dict[FeatureKey, str], colors: Dict[FeatureKey, BaseColor], -) -> Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]]: - configs: Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]] = {} - for kind in LibraryGeneratorName: +) -> Dict[GeneratorName, Dict[FeatureKey, FeaturePlotConfig]]: + configs: Dict[GeneratorName, Dict[FeatureKey, FeaturePlotConfig]] = {} + for kind in GeneratorName: configs[kind] = _build_kind_plot_configs(kind, labels, colors) return configs def _build_kind_plot_configs( - kind: LibraryGeneratorName, + kind: GeneratorName, labels: Dict[FeatureKey, str], colors: Dict[FeatureKey, BaseColor], ) -> Dict[FeatureKey, FeaturePlotConfig]: @@ -92,7 +92,7 @@ def _build_kind_plot_configs( def _build_plot_config( - kind: LibraryGeneratorName, + kind: GeneratorName, feature_key: FeatureKey, labels: Dict[FeatureKey, str], colors: Dict[FeatureKey, BaseColor], diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 021c48b5a..a2288f673 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -71,7 +71,7 @@ from sampletones_core.constants.enums import ( ChannelName, FeatureKey, - LibraryGeneratorName, + GeneratorName, ) from sampletones_core.exporters import Features from sampletones_core.features import CHANNEL_GENERATOR_KIND, resting_reference, supported_features @@ -286,7 +286,7 @@ def _create_tabs_for_generators(self) -> None: def _generator_kind( self, channel_name: ChannelName, - ) -> LibraryGeneratorName: + ) -> GeneratorName: return CHANNEL_GENERATOR_KIND[channel_name] def _generator_features( diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 1bf4156f0..8734723e1 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -114,7 +114,7 @@ colors: favorite: "#ffd76e" favorite_child: "#ddd2ac" - library_generator: "#cbe6cb" + generator: "#cbe6cb" library_group: "#c8e4e6" library_instruction: "#cccccf" library_root: "#dadade" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index d8a97bca2..1bb2f7b72 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -114,7 +114,7 @@ colors: favorite: "#8a6000" favorite_child: "#75663c" - library_generator: "#194c26" + generator: "#194c26" library_group: "#125a60" library_instruction: "#3d434d" library_root: "#20252c" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 371f5ee4e..0539a6ef7 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -114,7 +114,7 @@ colors: favorite: "#ffd76e" favorite_child: "#e7dbb7" - library_generator: "#d2e8d2" + generator: "#d2e8d2" library_group: "#d2e8e8" library_instruction: "#d2d2d2" library_root: "#dcdcdc" diff --git a/src/sampletones_config/theme/nodes/library/generator.yaml b/src/sampletones_config/theme/nodes/library/generator.yaml index 511dcd470..fd438d318 100644 --- a/src/sampletones_config/theme/nodes/library/generator.yaml +++ b/src/sampletones_config/theme/nodes/library/generator.yaml @@ -6,4 +6,4 @@ components: entries: - type: color key: Text - value: .library_generator + value: .generator diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index 2ad32df20..42d348144 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -6,6 +6,7 @@ CHANNELS: Final = "channels" GENERATOR: Final = "generator" +NAME: Final = "name" SONG: Final = "song" PATTERNS: Final = "patterns" diff --git a/src/sampletones_core/compatibility/project/v1_1.py b/src/sampletones_core/compatibility/project/v1_1.py index f53a657fe..30c063905 100644 --- a/src/sampletones_core/compatibility/project/v1_1.py +++ b/src/sampletones_core/compatibility/project/v1_1.py @@ -6,6 +6,7 @@ COMMAND, GENERATOR, GENERATOR_NAME, + NAME, PATTERNS, ROWS, SONG, @@ -21,7 +22,7 @@ def update(data: SerializedData) -> SerializedData: Project format 1.0 stored a channel pool's channel under ``generator`` and a row instrument's channel under ``generator_name``. Project format 1.1 names - both ``channel_name``. + them ``name`` and ``channel_name``. """ updated = dict(data) song = data.get(SONG) @@ -51,7 +52,7 @@ def update(data: SerializedData) -> SerializedData: def _renamed_pool(channel: SerializedData) -> SerializedData: renamed = dict(channel) if GENERATOR in renamed: - renamed[CHANNEL_NAME] = renamed.pop(GENERATOR) + renamed[NAME] = renamed.pop(GENERATOR) patterns = channel.get(PATTERNS) if isinstance(patterns, dict): diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index 3649a4acf..8ea10904d 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -5,7 +5,7 @@ from typing import Dict, Final, List, Literal -class LibraryGeneratorName(StrEnum): +class GeneratorName(StrEnum): PULSE = "pulse" TRIANGLE = "triangle" NOISE = "noise" diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index 68872eef9..d87654d61 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Dict, Final, List, Tuple -from sampletones_core.constants.enums import ChannelName, FeatureKey, LibraryGeneratorName +from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName from sampletones_core.constants.general import ( ARPEGGIO_MAX, ARPEGGIO_MIN, @@ -41,17 +41,17 @@ class FeatureRange: RESTING_REFERENCE_PERIOD: Final[int] = NUM_PERIODS // 2 -GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = { - LibraryGeneratorName.PULSE: { +GENERATOR_FEATURE_RANGES: Final[Dict[GeneratorName, Dict[FeatureKey, FeatureRange]]] = { + GeneratorName.PULSE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), FeatureKey.ARPEGGIO: FeatureRange(ARPEGGIO_MIN, ARPEGGIO_MAX), FeatureKey.DUTY_CYCLE: FeatureRange(0, MAX_DUTY_CYCLE), }, - LibraryGeneratorName.TRIANGLE: { + GeneratorName.TRIANGLE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), FeatureKey.ARPEGGIO: FeatureRange(ARPEGGIO_MIN, ARPEGGIO_MAX), }, - LibraryGeneratorName.NOISE: { + GeneratorName.NOISE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), FeatureKey.ARPEGGIO: FeatureRange(0, MAX_PERIOD), FeatureKey.DUTY_CYCLE: FeatureRange(0, MAX_NOISE_MODE), @@ -59,11 +59,11 @@ class FeatureRange: } -CHANNEL_GENERATOR_KIND: Final[Dict[ChannelName, LibraryGeneratorName]] = { - ChannelName.PULSE1: LibraryGeneratorName.PULSE, - ChannelName.PULSE2: LibraryGeneratorName.PULSE, - ChannelName.TRIANGLE: LibraryGeneratorName.TRIANGLE, - ChannelName.NOISE: LibraryGeneratorName.NOISE, +CHANNEL_GENERATOR_KIND: Final[Dict[ChannelName, GeneratorName]] = { + ChannelName.PULSE1: GeneratorName.PULSE, + ChannelName.PULSE2: GeneratorName.PULSE, + ChannelName.TRIANGLE: GeneratorName.TRIANGLE, + ChannelName.NOISE: GeneratorName.NOISE, } @@ -81,7 +81,7 @@ def resting_reference(channel_name: ChannelName) -> int: int: The pitch a tonal channel rests at, or the period the noise channel rests at. """ match CHANNEL_GENERATOR_KIND[channel_name]: - case LibraryGeneratorName.NOISE: + case GeneratorName.NOISE: return RESTING_REFERENCE_PERIOD case _: return RESTING_REFERENCE_PITCH @@ -106,18 +106,18 @@ def resting_held_features( def supported_features( - kind: LibraryGeneratorName, + kind: GeneratorName, ) -> List[FeatureKey]: ranges = GENERATOR_FEATURE_RANGES[kind] return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges] def feature_range( - kind: LibraryGeneratorName, + kind: GeneratorName, feature: FeatureKey, ) -> FeatureRange: return GENERATOR_FEATURE_RANGES[kind][feature] -def supports(kind: LibraryGeneratorName, feature: FeatureKey) -> bool: +def supports(kind: GeneratorName, feature: FeatureKey) -> bool: return feature in GENERATOR_FEATURE_RANGES[kind] diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py index dc45839ff..d2b8c22ea 100644 --- a/src/sampletones_core/generators/__init__.py +++ b/src/sampletones_core/generators/__init__.py @@ -5,9 +5,9 @@ from .maps import ( CHANNEL_CLASSES, GENERATOR_CLASS_MAP, + GENERATOR_TO_CLASS_NAME_MAP, GENERATOR_TO_INSTRUCTION_MAP, INSTRUCTION_TO_GENERATOR_MAP, - LIBRARY_GENERATOR_CLASS_MAP, MIXER_LEVELS, ) from .types import ( @@ -29,7 +29,7 @@ "GENERATOR_CLASS_MAP", "GENERATOR_TO_INSTRUCTION_MAP", "INSTRUCTION_TO_GENERATOR_MAP", - "LIBRARY_GENERATOR_CLASS_MAP", + "GENERATOR_TO_CLASS_NAME_MAP", "MIXER_LEVELS", "Generator", "GeneratorClass", diff --git a/src/sampletones_core/generators/maps.py b/src/sampletones_core/generators/maps.py index 60d33e973..a91cf5435 100644 --- a/src/sampletones_core/generators/maps.py +++ b/src/sampletones_core/generators/maps.py @@ -3,7 +3,7 @@ from sampletones_core.constants.enums import ( ChannelName, GeneratorClassName, - LibraryGeneratorName, + GeneratorName, ) from sampletones_core.constants.general import ( MIXER_NOISE, @@ -22,10 +22,10 @@ from .implementation.triangle import TriangleGenerator from .types import GeneratorTypeUnion -LIBRARY_GENERATOR_CLASS_MAP: Final[Dict[LibraryGeneratorName, GeneratorClassName]] = { - LibraryGeneratorName.PULSE: GeneratorClassName.PULSE_GENERATOR, - LibraryGeneratorName.TRIANGLE: GeneratorClassName.TRIANGLE_GENERATOR, - LibraryGeneratorName.NOISE: GeneratorClassName.NOISE_GENERATOR, +GENERATOR_TO_CLASS_NAME_MAP: Final[Dict[GeneratorName, GeneratorClassName]] = { + GeneratorName.PULSE: GeneratorClassName.PULSE_GENERATOR, + GeneratorName.TRIANGLE: GeneratorClassName.TRIANGLE_GENERATOR, + GeneratorName.NOISE: GeneratorClassName.NOISE_GENERATOR, } diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index 6176c4d2d..b4c8c4289 100644 --- a/src/sampletones_core/structures/tree/node.py +++ b/src/sampletones_core/structures/tree/node.py @@ -5,7 +5,7 @@ from anytree import Node -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import GeneratorName from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -118,7 +118,7 @@ class GeneratorNode(TreeNode): def __init__( self, name: str, - generator_name: LibraryGeneratorName, + generator_name: GeneratorName, node_type: NodeType = NodeType.GENERATOR, parent: Optional[TreeNode] = None, ) -> None: diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py index e6f8d8749..824869f6e 100644 --- a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py +++ b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py @@ -1,6 +1,6 @@ from typing import Any, Dict -from sampletones_core.compatibility.fields import CHANNEL_NAME +from sampletones_core.compatibility.fields import CHANNEL_NAME, NAME from sampletones_core.compatibility.project.v1_1 import update @@ -14,7 +14,7 @@ def test_renames_channel_pool_field(self) -> None: upgraded = update(data) - assert upgraded["song"]["channels"]["pulse1"][CHANNEL_NAME] == "pulse1" + assert upgraded["song"]["channels"]["pulse1"][NAME] == "pulse1" assert "generator" not in upgraded["song"]["channels"]["pulse1"] def test_renames_instrument_command_channel(self) -> None: diff --git a/tests/unit/sampletones_core/compatibility/test_json.py b/tests/unit/sampletones_core/compatibility/test_json.py index 14300b795..a586e423b 100644 --- a/tests/unit/sampletones_core/compatibility/test_json.py +++ b/tests/unit/sampletones_core/compatibility/test_json.py @@ -52,7 +52,7 @@ def test_project_upgrade_renames_channel_fields_and_stamps(self) -> None: assert data["format_version"] == SAMPLETONES_PROJECT_DATA_VERSION channel = data["song"]["channels"]["pulse1"] - assert channel["channel_name"] == "pulse1" + assert channel["name"] == "pulse1" assert "generator" not in channel command = channel["patterns"]["0"]["rows"]["0"]["command"] assert command["channel_name"] == "pulse1" diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 53addb9df..9ae125215 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -1,7 +1,7 @@ from sampletones_core.constants.enums import ( ChannelName, FeatureKey, - LibraryGeneratorName, + GeneratorName, ) from sampletones_core.exporters.implementation.noise import NoiseExporter from sampletones_core.exporters.implementation.pulse import PulseExporter @@ -20,16 +20,16 @@ def test_supported_features_follow_dimension_order() -> None: - assert supported_features(LibraryGeneratorName.PULSE) == [ + assert supported_features(GeneratorName.PULSE) == [ FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE, ] - assert supported_features(LibraryGeneratorName.TRIANGLE) == [ + assert supported_features(GeneratorName.TRIANGLE) == [ FeatureKey.VOLUME, FeatureKey.ARPEGGIO, ] - assert supported_features(LibraryGeneratorName.NOISE) == [ + assert supported_features(GeneratorName.NOISE) == [ FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE, @@ -37,24 +37,24 @@ def test_supported_features_follow_dimension_order() -> None: def test_feature_ranges_match_expected_channel_domains() -> None: - assert feature_range(LibraryGeneratorName.PULSE, FeatureKey.DUTY_CYCLE) == feature_range( + assert feature_range(GeneratorName.PULSE, FeatureKey.DUTY_CYCLE) == feature_range( CHANNEL_GENERATOR_KIND[ChannelName.PULSE1], FeatureKey.DUTY_CYCLE, ) - assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.DUTY_CYCLE).maximum == 1 - assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.ARPEGGIO).minimum == 0 - assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.ARPEGGIO).maximum == 15 + assert feature_range(GeneratorName.NOISE, FeatureKey.DUTY_CYCLE).maximum == 1 + assert feature_range(GeneratorName.NOISE, FeatureKey.ARPEGGIO).minimum == 0 + assert feature_range(GeneratorName.NOISE, FeatureKey.ARPEGGIO).maximum == 15 def test_supports_reports_triangle_lacks_duty_cycle() -> None: - assert supports(LibraryGeneratorName.TRIANGLE, FeatureKey.VOLUME) - assert not supports(LibraryGeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE) + assert supports(GeneratorName.TRIANGLE, FeatureKey.VOLUME) + assert not supports(GeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE) def test_supported_features_match_exporter_attribute_maps() -> None: - assert tuple(supported_features(LibraryGeneratorName.PULSE)) == tuple(PulseExporter._ATTRIBUTE_MAP) - assert tuple(supported_features(LibraryGeneratorName.TRIANGLE)) == tuple(TriangleExporter._ATTRIBUTE_MAP) - assert tuple(supported_features(LibraryGeneratorName.NOISE)) == tuple(NoiseExporter._ATTRIBUTE_MAP) + assert tuple(supported_features(GeneratorName.PULSE)) == tuple(PulseExporter._ATTRIBUTE_MAP) + assert tuple(supported_features(GeneratorName.TRIANGLE)) == tuple(TriangleExporter._ATTRIBUTE_MAP) + assert tuple(supported_features(GeneratorName.NOISE)) == tuple(NoiseExporter._ATTRIBUTE_MAP) def test_feature_dimension_order_matches_famitracker_sequence_slots() -> None: diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index 3315dc074..93883be2a 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -105,10 +105,10 @@ def project_fixture() -> ProjectFixture: ) channels = { - ChannelName.PULSE1: Channel(channel_name=ChannelName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), - ChannelName.PULSE2: Channel(channel_name=ChannelName.PULSE2, patterns={}), - ChannelName.TRIANGLE: Channel(channel_name=ChannelName.TRIANGLE, patterns={}), - ChannelName.NOISE: Channel(channel_name=ChannelName.NOISE, patterns={0: Pattern(rows=noise_rows)}), + ChannelName.PULSE1: Channel(name=ChannelName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}), + ChannelName.PULSE2: Channel(name=ChannelName.PULSE2, patterns={}), + ChannelName.TRIANGLE: Channel(name=ChannelName.TRIANGLE, patterns={}), + ChannelName.NOISE: Channel(name=ChannelName.NOISE, patterns={0: Pattern(rows=noise_rows)}), } order = [ { diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py index 15482d80f..880bd9926 100644 --- a/tests/unit/sampletones_core/structures/tree/test_node.py +++ b/tests/unit/sampletones_core/structures/tree/test_node.py @@ -1,7 +1,7 @@ from pathlib import Path from sampletones_core.configs import Config -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import GeneratorName from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( @@ -131,14 +131,14 @@ def test_default_node_type_is_library(self) -> None: class TestGeneratorNode: def test_generator_name_is_stored(self) -> None: - node = GeneratorNode("gen", generator_name=LibraryGeneratorName.PULSE) - assert node.generator_name == LibraryGeneratorName.PULSE + node = GeneratorNode("gen", generator_name=GeneratorName.PULSE) + assert node.generator_name == GeneratorName.PULSE def test_copy_preserves_generator_name(self) -> None: - node = GeneratorNode("gen", generator_name=LibraryGeneratorName.PULSE) + node = GeneratorNode("gen", generator_name=GeneratorName.PULSE) copied = node.copy() - assert copied.generator_name == LibraryGeneratorName.PULSE + assert copied.generator_name == GeneratorName.PULSE def test_default_node_type_is_generator(self) -> None: - node = GeneratorNode("gen", generator_name=LibraryGeneratorName.PULSE) + node = GeneratorNode("gen", generator_name=GeneratorName.PULSE) assert node.node_type == NodeType.GENERATOR From 4df1611be914e1280a277a8e9f6cfe3eb9359a7b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 16:32:53 +0200 Subject: [PATCH 014/142] Fixed: reconstruction upgrade --- src/sampletones_core/compatibility/fields.py | 5 ++ .../compatibility/reconstruction/v2_2.py | 54 ++++++++++++++----- .../compatibility/reconstruction/test_v2_2.py | 7 +++ .../compatibility/test_binary.py | 6 ++- .../reconstruction/test_reconstruction.py | 35 ++++++++++++ 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index 42d348144..ce13d8947 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -1,9 +1,14 @@ from typing import Final CONFIG: Final = "config" +METADATA: Final = "metadata" GENERATION: Final = "generation" GENERATORS: Final = "generators" +INSTRUCTIONS_DATA: Final = "instructions_data" +APPROXIMATIONS_DATA: Final = "approximations_data" +RECONSTRUCTION_DATA_VERSION: Final = "reconstruction_data_version" + CHANNELS: Final = "channels" GENERATOR: Final = "generator" NAME: Final = "name" diff --git a/src/sampletones_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py index ddc18a137..c89c2b1bb 100644 --- a/src/sampletones_core/compatibility/reconstruction/v2_2.py +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -1,12 +1,26 @@ from typing import Final -from sampletones_core.compatibility.fields import CHANNEL_NAME, CHANNELS, CONFIG, GENERATION, GENERATOR_NAME, GENERATORS +from sampletones_core.compatibility.fields import ( + APPROXIMATIONS_DATA, + CHANNEL_NAME, + CHANNELS, + CONFIG, + GENERATION, + GENERATOR_NAME, + GENERATORS, + INSTRUCTIONS_DATA, + METADATA, + RECONSTRUCTION_DATA_VERSION, +) from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.compatibility.update import VersionUpdate from sampletones_core.compatibility.utils import renamed from sampletones_shared.deployment.version import Version from sampletones_shared.types.data import SerializedData +SOURCE_DATA_VERSION: Final[str] = "2.1" +TARGET_DATA_VERSION: Final[str] = "2.2" + def update(data: SerializedData) -> SerializedData: """Names each stored stream and approximation by its channel. @@ -14,12 +28,14 @@ def update(data: SerializedData) -> SerializedData: Data version 2.1 stored a channel's stream and approximation under the key ``generator_name`` and the channel selection under ``config.generation.generators``. Data version 2.2 names them ``channel_name`` - and ``config.generation.channels``. + and ``config.generation.channels``, and stamps the embedded config's metadata + with the new data version, since the load contract holds every metadata block + to it. """ updated = dict(data) - approximations = data.get("approximations_data") + approximations = data.get(APPROXIMATIONS_DATA) if isinstance(approximations, list): - updated["approximations_data"] = [ + updated[APPROXIMATIONS_DATA] = [ renamed( item, GENERATOR_NAME, @@ -28,9 +44,9 @@ def update(data: SerializedData) -> SerializedData: for item in approximations ] - instructions = data.get("instructions_data") + instructions = data.get(INSTRUCTIONS_DATA) if isinstance(instructions, list): - updated["instructions_data"] = [ + updated[INSTRUCTIONS_DATA] = [ renamed( item, GENERATOR_NAME, @@ -41,19 +57,33 @@ def update(data: SerializedData) -> SerializedData: config = data.get(CONFIG) if isinstance(config, dict): + updated_config = dict(config) + metadata = config.get(METADATA) + if isinstance(metadata, dict) and isinstance( + metadata.get(RECONSTRUCTION_DATA_VERSION), + str, + ): + updated_config[METADATA] = { + **metadata, + RECONSTRUCTION_DATA_VERSION: TARGET_DATA_VERSION, + } + generation = config.get(GENERATION) if isinstance(generation, dict): - updated["config"] = { - **config, - GENERATION: renamed(generation, GENERATORS, CHANNELS), - } + updated_config[GENERATION] = renamed( + generation, + GENERATORS, + CHANNELS, + ) + + updated[CONFIG] = updated_config return updated V2_2: Final[VersionUpdate] = VersionUpdate( kind=ObjectKind.RECONSTRUCTION, - base=Version.model_validate("2.1"), - target=Version.model_validate("2.2"), + base=Version.model_validate(SOURCE_DATA_VERSION), + target=Version.model_validate(TARGET_DATA_VERSION), apply=update, ) diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py index 28a069755..c1705c70e 100644 --- a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -33,6 +33,13 @@ def test_renames_embedded_channel_selection(self) -> None: assert upgraded["config"]["generation"]["channels"] == ["pulse1", "noise"] assert "generators" not in upgraded["config"]["generation"] + def test_stamps_the_embedded_config_metadata(self) -> None: + data = {"config": {"metadata": {"reconstruction_data_version": "2.1"}}} + + upgraded = update(data) + + assert upgraded["config"]["metadata"]["reconstruction_data_version"] == "2.2" + def test_leaves_the_input_untouched(self) -> None: data = { "approximations_data": [{GENERATOR_NAME: "pulse1"}], diff --git a/tests/unit/sampletones_core/compatibility/test_binary.py b/tests/unit/sampletones_core/compatibility/test_binary.py index 5c77ca53e..d77d3f262 100644 --- a/tests/unit/sampletones_core/compatibility/test_binary.py +++ b/tests/unit/sampletones_core/compatibility/test_binary.py @@ -46,7 +46,10 @@ def test_reconstruction_upgrade_renames_channels_and_stamps(self) -> None: "metadata": {"reconstruction_data_version": "2.1"}, "approximations_data": [{"generator_name": "pulse1", "approximation": [1.0, 2.0]}], "instructions_data": [], - "config": {"generation": {"generators": ["pulse1", "noise"]}}, + "config": { + "metadata": {"reconstruction_data_version": "2.1"}, + "generation": {"generators": ["pulse1", "noise"]}, + }, }, use_bin_type=True, ) @@ -57,4 +60,5 @@ def test_reconstruction_upgrade_renames_channels_and_stamps(self) -> None: assert data["approximations_data"][0]["channel_name"] == "pulse1" assert "generator_name" not in data["approximations_data"][0] assert data["config"]["generation"]["channels"] == ["pulse1", "noise"] + assert data["config"]["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION assert data["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 9e2481a93..e08badd43 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -3,6 +3,7 @@ from typing import Callable, Final, List from unittest.mock import patch +import msgpack import numpy as np import pytest @@ -200,6 +201,40 @@ def test_foreign_application_name_propagates( Reconstruction.load(path) +class TestVersionUpgradeOnLoad: + def test_a_2_1_file_loads_through_the_upgrade( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + path = tmp_path / "old.stn" + reconstruction.save(path) + + binary = path.read_bytes() + data = msgpack.unpackb(binary, raw=False) + data["metadata"]["reconstruction_data_version"] = "2.1" + for item in data["approximations_data"]: + item["generator_name"] = item.pop("channel_name") + + for item in data["instructions_data"]: + item["generator_name"] = item.pop("channel_name") + + generation = data["config"]["generation"] + generation["generators"] = generation.pop("channels") + config_metadata = data["config"].get("metadata") + if isinstance(config_metadata, dict): + config_metadata["reconstruction_data_version"] = "2.1" + + path.write_bytes(msgpack.packb(data, use_bin_type=True)) + + loaded = Reconstruction.load(path) + + assert loaded.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION + assert loaded.config.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION + assert set(loaded.approximations) == set(reconstruction.approximations) + + class TestDeserializeDataWrapping(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): From 4460a887bbbd4110d07a8eda19a337cb81047d08 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 17:06:05 +0200 Subject: [PATCH 015/142] Updated: changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc0c47df..aa3cdbd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v0.3.2 * Added NSF player and export. +* Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`. ## v0.3.1 [2026-08-18] From 3aefe6e418390a17895b342f85554ca6980f0e66 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 20:59:24 +0200 Subject: [PATCH 016/142] Resolved: same-kind channels score through the lowest free channel --- docs/concepts/reconstruction.md | 9 ++-- src/sampletones_core/features/spec.py | 8 +++- src/sampletones_core/generators/utils.py | 14 +++++- .../reconstructor/selector/base.py | 46 +++++++++++++++---- .../sampletones_core/generators/test_utils.py | 25 ++++++++++ .../collection/test_bidirectional.py | 6 +-- .../structures/tree/test_visibility.py | 4 +- 7 files changed, 94 insertions(+), 18 deletions(-) diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 05ebc6027..ff553a5b1 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -196,9 +196,12 @@ while remaining: ``` It assigns each channel exactly once per frame, always letting the channel that fits -the residual best go first. It is simple and fast, but it has **no memory between -frames**: nothing discourages the instruction streams from jumping around frame to -frame, which can sound jittery even when each individual frame is well matched. +the residual best go first. When several channels share one generator kind, the +lowest remaining channel of that kind represents it during scoring, so successive +picks over one kind land on the lowest free channel. It is simple and fast, but it +has **no memory between frames**: nothing discourages the instruction streams from +jumping around frame to frame, which can sound jittery even when each individual +frame is well matched. ### 5.2 Viterbi (continuity-aware) diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index d87654d61..ef01a8e9b 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Final, List, Tuple +from typing import Dict, Final, FrozenSet, List, Tuple from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName from sampletones_core.constants.general import ( @@ -66,6 +66,12 @@ class FeatureRange: ChannelName.NOISE: GeneratorName.NOISE, } +GENERATOR_CHANNEL_KINDS: Final[Dict[GeneratorName, FrozenSet[ChannelName]]] = { + GeneratorName.PULSE: frozenset((ChannelName.PULSE1, ChannelName.PULSE2)), + GeneratorName.TRIANGLE: frozenset((ChannelName.TRIANGLE,)), + GeneratorName.NOISE: frozenset((ChannelName.NOISE,)), +} + def resting_reference(channel_name: ChannelName) -> int: """The reference an arpeggio envelope is measured against while a channel describes no frame. diff --git a/src/sampletones_core/generators/utils.py b/src/sampletones_core/generators/utils.py index 9262c87e7..d68c2a10c 100644 --- a/src/sampletones_core/generators/utils.py +++ b/src/sampletones_core/generators/utils.py @@ -36,7 +36,19 @@ def get_generators_map( def get_remaining_generator_classes( remaining_channels: Dict[ChannelName, GeneratorUnion], ) -> Dict[GeneratorClassName, GeneratorUnion]: - return {generator.class_name(): generator for generator in reversed(remaining_channels.values())} + """ + Maps each remaining generator class to its representative channel generator. + + Channels of one kind share a candidate catalogue, so one channel stands for the + kind while its candidates are scored. The lowest remaining channel of a kind is + its representative, which resolves successive picks over same-kind channels to + the lowest free channel deterministically. + """ + return { + remaining_channels[name].class_name(): remaining_channels[name] + for name in reversed(ChannelName.items()) + if name in remaining_channels + } def get_generator_by_instruction( diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/base.py b/src/sampletones_core/reconstructions/reconstructor/selector/base.py index f33cacba4..e4f62962e 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/base.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/base.py @@ -53,7 +53,10 @@ def select( fragment_ids: List[int], ) -> Dict[int, Dict[ChannelName, ApproximationData]]: ... - def reconstruct_fragment(self, fragment: Fragment) -> Dict[ChannelName, ApproximationData]: + def reconstruct_fragment( + self, + fragment: Fragment, + ) -> Dict[ChannelName, ApproximationData]: approximations: Dict[ChannelName, ApproximationData] = {} remaining_channels = dict(self.channels.items()) while remaining_channels: @@ -96,16 +99,36 @@ def _score_candidates( The shortlisted candidates with their aligned costs, best first. """ valid_instructions, candidate_approximations = self.candidate_provider.candidates(remaining_generator_classes) - spectral_costs = self.scorer.spectral_costs(fragment, candidate_approximations) + spectral_costs = self.scorer.spectral_costs( + fragment, + candidate_approximations, + ) shortlist = Scorer.top_k(spectral_costs, self.top_k) scored: List[ScoredCandidate] = [] for index in shortlist: instruction = valid_instructions[index] - generator = get_generator_by_instruction(instruction, remaining_generator_classes) - approximation = self._build_approximation(fragment, instruction, generator) - cost = self.scorer.aligned_cost(fragment, float(spectral_costs[index]), approximation) - scored.append(ScoredCandidate(instruction=instruction, cost=cost, approximation=approximation)) + generator = get_generator_by_instruction( + instruction, + remaining_generator_classes, + ) + approximation = self._build_approximation( + fragment, + instruction, + generator, + ) + cost = self.scorer.aligned_cost( + fragment, + float(spectral_costs[index]), + approximation, + ) + scored.append( + ScoredCandidate( + instruction=instruction, + cost=cost, + approximation=approximation, + ) + ) scored.sort(key=lambda candidate: candidate.cost) return scored @@ -116,7 +139,10 @@ def _find_best_approximation( remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], ) -> ApproximationData: best = self._score_candidates(fragment, remaining_generator_classes)[0] - generator = get_generator_by_instruction(best.instruction, remaining_generator_classes) + generator = get_generator_by_instruction( + best.instruction, + remaining_generator_classes, + ) return ApproximationData( channel_name=ChannelName(generator.name), @@ -132,4 +158,8 @@ def _build_approximation( ) -> Fragment: if self.config.generation.calculation.find_best_phase: return self.phase_aligner.align(fragment, instruction) - return self.candidate_provider.get_approximation(instruction, generator) + + return self.candidate_provider.get_approximation( + instruction, + generator, + ) diff --git a/tests/unit/sampletones_core/generators/test_utils.py b/tests/unit/sampletones_core/generators/test_utils.py index d320ba5b0..4f40e8fa6 100644 --- a/tests/unit/sampletones_core/generators/test_utils.py +++ b/tests/unit/sampletones_core/generators/test_utils.py @@ -75,6 +75,31 @@ def test_maps_by_class_name(self, config: Config) -> None: assert GeneratorClassName.PULSE_GENERATOR in result assert GeneratorClassName.NOISE_GENERATOR in result + def test_lowest_pulse_channel_is_representative_when_both_remain(self, config: Config) -> None: + named = { + ChannelName.PULSE2: PulseGenerator(config, ChannelName.PULSE2), + ChannelName.PULSE1: PulseGenerator(config, ChannelName.PULSE1), + } + result = get_remaining_generator_classes(named) + assert result[GeneratorClassName.PULSE_GENERATOR] is named[ChannelName.PULSE1] + + def test_pulse2_represents_pulse_kind_after_pulse1_is_consumed(self, config: Config) -> None: + named = { + ChannelName.PULSE2: PulseGenerator(config, ChannelName.PULSE2), + } + result = get_remaining_generator_classes(named) + assert result[GeneratorClassName.PULSE_GENERATOR] is named[ChannelName.PULSE2] + + def test_single_channel_kinds_keep_their_own_generator(self, config: Config) -> None: + named = { + ChannelName.PULSE1: PulseGenerator(config, ChannelName.PULSE1), + ChannelName.TRIANGLE: TriangleGenerator(config, ChannelName.TRIANGLE), + ChannelName.NOISE: NoiseGenerator(config, ChannelName.NOISE), + } + result = get_remaining_generator_classes(named) + assert result[GeneratorClassName.TRIANGLE_GENERATOR] is named[ChannelName.TRIANGLE] + assert result[GeneratorClassName.NOISE_GENERATOR] is named[ChannelName.NOISE] + class TestGetGeneratorByInstruction: def test_pulse_instruction_returns_pulse_generator(self, all_generators: Dict) -> None: diff --git a/tests/unit/sampletones_core/structures/collection/test_bidirectional.py b/tests/unit/sampletones_core/structures/collection/test_bidirectional.py index 664c1be28..f62f5403d 100644 --- a/tests/unit/sampletones_core/structures/collection/test_bidirectional.py +++ b/tests/unit/sampletones_core/structures/collection/test_bidirectional.py @@ -1,4 +1,4 @@ -from typing import Any, Tuple, Union +from typing import Any, FrozenSet, Tuple, Union import pytest @@ -561,7 +561,7 @@ def test_tuple_as_value(self) -> None: assert bidirectional[(1, 2, 3)] == "point" def test_frozenset_as_value(self) -> None: - bidirectional = BidirectionalHashMap[frozenset[str]]() + bidirectional = BidirectionalHashMap[FrozenSet[str]]() value = frozenset({"a", "b", "c"}) bidirectional["set1"] = value assert bidirectional["set1"] == value @@ -873,7 +873,7 @@ def test_eq_with_different_bidirectional(self) -> None: assert bidi1 != bidi2 def test_eq_with_frozenset(self) -> None: - bidirectional = BidirectionalHashMap[frozenset[int]]() + bidirectional = BidirectionalHashMap[FrozenSet[int]]() set1 = frozenset([1, 2, 3]) set2 = frozenset([3, 2, 1]) diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py index abd278307..944fe0f34 100644 --- a/tests/unit/sampletones_core/structures/tree/test_visibility.py +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List +from typing import Dict, FrozenSet, List import pytest @@ -42,7 +42,7 @@ class TestVisibleRows: class TestCase(BaseTestCase): label: str matched_names: List[str] - expected_visible_names: frozenset[str] + expected_visible_names: FrozenSet[str] test_cases = ( TestCase( From 26ab1cdf2c38a6aa89c736e4559b2c12639c5c70 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 21:26:55 +0200 Subject: [PATCH 017/142] Refactored: candidate scoring into a shared frame matcher --- .../reconstructor/selector/__init__.py | 3 +- .../reconstructor/selector/base.py | 104 ++----------- .../reconstructor/selector/matching.py | 143 ++++++++++++++++++ .../reconstructor/selector/viterbi.py | 3 +- 4 files changed, 156 insertions(+), 97 deletions(-) create mode 100644 src/sampletones_core/reconstructions/reconstructor/selector/matching.py diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py index e28386b83..1d55e19f6 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py @@ -2,8 +2,9 @@ from sampletones_core.constants.enums import SelectorName -from .base import ScoredCandidate, Selector +from .base import Selector from .greedy import GreedySelector +from .matching import ScoredCandidate from .viterbi import ViterbiSelector SELECTORS: Dict[SelectorName, Type[Selector]] = { diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/base.py b/src/sampletones_core/reconstructions/reconstructor/selector/base.py index e4f62962e..09f753993 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/base.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/base.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from dataclasses import dataclass from typing import Dict, List from sampletones_core.configs import Config @@ -8,22 +7,14 @@ from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import ( GeneratorUnion, - get_generator_by_instruction, get_remaining_generator_classes, ) -from sampletones_core.instructions import InstructionUnion from ..approximation import ApproximationData from ..candidates import CandidateProvider from ..phase import PhaseAligner from ..scorer import Scorer - - -@dataclass(frozen=True) -class ScoredCandidate: - instruction: InstructionUnion - cost: float - approximation: Fragment +from .matching import FrameMatcher, ScoredCandidate class Selector(ABC): @@ -45,6 +36,12 @@ def __init__( self.phase_aligner = phase_aligner self.feature_extractor = feature_extractor self.top_k = config.generation.decoder.top_k + self.matcher = FrameMatcher( + config=config, + candidate_provider=candidate_provider, + scorer=scorer, + phase_aligner=phase_aligner, + ) @abstractmethod def select( @@ -61,7 +58,7 @@ def reconstruct_fragment( remaining_channels = dict(self.channels.items()) while remaining_channels: remaining_generator_classes = get_remaining_generator_classes(remaining_channels) - approximation_data = self._find_best_approximation( + approximation_data = self.matcher.best_approximation( fragment, remaining_generator_classes, ) @@ -79,87 +76,4 @@ def _score_candidates( fragment: Fragment, remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], ) -> List[ScoredCandidate]: - """ - Score candidates in two stages: a phase-independent spectral shortlist, then a - full ranking with the temporal term evaluated on each phase-aligned candidate. - - The shortlist ranks every candidate by the spectral term alone, which compares - phase-averaged features and is therefore immune to how the candidate waveform - happens to be phased. Each of the ``top_k`` shortlisted candidates is then built - at its best phase against the target and receives the full criterion cost, so - the temporal term measures waveform shape at the aligned phase. The aligned - phase stands in for the rendered phase, which keeps oscillator continuity - across frames. - - Args: - fragment: Target fragment to match. - remaining_generator_classes: Generators still available for this fragment. - - Returns: - The shortlisted candidates with their aligned costs, best first. - """ - valid_instructions, candidate_approximations = self.candidate_provider.candidates(remaining_generator_classes) - spectral_costs = self.scorer.spectral_costs( - fragment, - candidate_approximations, - ) - shortlist = Scorer.top_k(spectral_costs, self.top_k) - - scored: List[ScoredCandidate] = [] - for index in shortlist: - instruction = valid_instructions[index] - generator = get_generator_by_instruction( - instruction, - remaining_generator_classes, - ) - approximation = self._build_approximation( - fragment, - instruction, - generator, - ) - cost = self.scorer.aligned_cost( - fragment, - float(spectral_costs[index]), - approximation, - ) - scored.append( - ScoredCandidate( - instruction=instruction, - cost=cost, - approximation=approximation, - ) - ) - - scored.sort(key=lambda candidate: candidate.cost) - return scored - - def _find_best_approximation( - self, - fragment: Fragment, - remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], - ) -> ApproximationData: - best = self._score_candidates(fragment, remaining_generator_classes)[0] - generator = get_generator_by_instruction( - best.instruction, - remaining_generator_classes, - ) - - return ApproximationData( - channel_name=ChannelName(generator.name), - approximation=best.approximation, - instruction=best.instruction, - ) - - def _build_approximation( - self, - fragment: Fragment, - instruction: InstructionUnion, - generator: GeneratorUnion, - ) -> Fragment: - if self.config.generation.calculation.find_best_phase: - return self.phase_aligner.align(fragment, instruction) - - return self.candidate_provider.get_approximation( - instruction, - generator, - ) + return self.matcher.score_candidates(fragment, remaining_generator_classes) diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/matching.py b/src/sampletones_core/reconstructions/reconstructor/selector/matching.py new file mode 100644 index 000000000..26529255e --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/selector/matching.py @@ -0,0 +1,143 @@ +from dataclasses import dataclass +from typing import Dict, List + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.fft import Fragment +from sampletones_core.generators import ( + GeneratorUnion, + get_generator_by_instruction, +) +from sampletones_core.instructions import InstructionUnion + +from ..approximation import ApproximationData +from ..candidates import CandidateProvider +from ..phase import PhaseAligner +from ..scorer import Scorer + + +@dataclass(frozen=True) +class ScoredCandidate: + instruction: InstructionUnion + cost: float + approximation: Fragment + + +@dataclass(frozen=True) +class FrameMatcher: + """ + Matches one target fragment against candidates of given generator classes. + + Carries the matching machinery the selectors and the stems assignment share: the + two-stage criterion scoring, the winning channel's approximation, and the + per-candidate approximation build. + """ + + config: Config + candidate_provider: CandidateProvider + scorer: Scorer + phase_aligner: PhaseAligner + + @property + def top_k(self) -> int: + return self.config.generation.decoder.top_k + + def score_candidates( + self, + fragment: Fragment, + remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], + ) -> List[ScoredCandidate]: + """ + Score candidates in two stages: a phase-independent spectral shortlist, then a + full ranking with the temporal term evaluated on each phase-aligned candidate. + + The shortlist ranks every candidate by the spectral term alone, which compares + phase-averaged features and is therefore immune to how the candidate waveform + happens to be phased. Each of the ``top_k`` shortlisted candidates is then built + at its best phase against the target and receives the full criterion cost, so + the temporal term measures waveform shape at the aligned phase. The aligned + phase stands in for the rendered phase, which keeps oscillator continuity + across frames. + + Args: + fragment: Target fragment to match. + remaining_generator_classes: Generators still available for this fragment. + + Returns: + The shortlisted candidates with their aligned costs, best first. + """ + valid_instructions, candidate_approximations = self.candidate_provider.candidates(remaining_generator_classes) + spectral_costs = self.scorer.spectral_costs( + fragment, + candidate_approximations, + ) + shortlist = Scorer.top_k(spectral_costs, self.top_k) + + scored: List[ScoredCandidate] = [] + for index in shortlist: + instruction = valid_instructions[index] + generator = get_generator_by_instruction( + instruction, + remaining_generator_classes, + ) + approximation = self.build_approximation( + fragment, + instruction, + generator, + ) + cost = self.scorer.aligned_cost( + fragment, + float(spectral_costs[index]), + approximation, + ) + scored.append( + ScoredCandidate( + instruction=instruction, + cost=cost, + approximation=approximation, + ) + ) + + scored.sort(key=lambda candidate: candidate.cost) + return scored + + def best_approximation( + self, + fragment: Fragment, + remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], + ) -> ApproximationData: + """ + The winning channel's attribution, approximation, and instruction. + + Scores the candidates of the given generator classes and returns the best one + as the channel it belongs to, its rendered approximation, and its instruction. + """ + best = self.score_candidates(fragment, remaining_generator_classes)[0] + generator = get_generator_by_instruction( + best.instruction, + remaining_generator_classes, + ) + + return ApproximationData( + channel_name=ChannelName(generator.name), + approximation=best.approximation, + instruction=best.instruction, + ) + + def build_approximation( + self, + fragment: Fragment, + instruction: InstructionUnion, + generator: GeneratorUnion, + ) -> Fragment: + """ + Builds one candidate fragment for scoring: the phase-aligned waveform when + best-phase search is enabled, or the generator's library approximation. + """ + if self.config.generation.calculation.find_best_phase: + return self.phase_aligner.align(fragment, instruction) + + return self.candidate_provider.get_approximation( + instruction, + generator, + ) diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py index 4cfb1b00e..f109a21fa 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py +++ b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py @@ -14,7 +14,8 @@ from ..candidates import CandidateProvider from ..phase import PhaseAligner from ..scorer import Scorer -from .base import ScoredCandidate, Selector +from .base import Selector +from .matching import ScoredCandidate ChannelLattice = List[List[ScoredCandidate]] FrameCandidates = Dict[ChannelName, List[ScoredCandidate]] From f95faf1434a21df0f0876a9b405290bd4e69e2e8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 22:21:13 +0200 Subject: [PATCH 018/142] Added: stems frame assignment --- .../reconstructor/stems/__init__.py | 17 ++ .../reconstructor/stems/frame.py | 194 ++++++++++++++++++ .../reconstructor/stems/models.py | 50 +++++ 3 files changed, 261 insertions(+) create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/frame.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models.py diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py new file mode 100644 index 000000000..96bbabec1 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py @@ -0,0 +1,17 @@ +from .frame import assign_frame +from .models import ( + HierarchyMode, + Stem, + StemChoice, + StemFrameAssignment, + StemHierarchy, +) + +__all__ = [ + "HierarchyMode", + "Stem", + "StemChoice", + "StemFrameAssignment", + "StemHierarchy", + "assign_frame", +] diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/frame.py new file mode 100644 index 000000000..071bb5b69 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/frame.py @@ -0,0 +1,194 @@ +from typing import Dict, List, Optional, Sequence, Set, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import ( + GeneratorUnion, + get_generator_by_instruction, + get_remaining_generator_classes, +) + +from ..selector.matching import FrameMatcher +from .models import ( + HierarchyMode, + Stem, + StemChoice, + StemFrameAssignment, + StemHierarchy, +) + + +def assign_frame( + fragment: Fragment, + stems: Dict[int, Stem], + hierarchy: StemHierarchy, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + channel_cap: int, +) -> StemFrameAssignment: + """ + Assigns one target frame's channels to stems, one pick at a time. + + Every pick scores each eligible stem's candidates against the current residual, + takes the cheapest choice across the active level, subtracts its approximation + from the residual, and consumes its channel. Levels pick in the hierarchy's + mode: round-based gives every level's stems one channel per round in level + order, strict exhausts each level before the next. Each stem holds at most + ``channel_cap`` channels per frame. + + Args: + fragment: The frame to assign, matching the matcher and extractor feature + space. + stems: The competing stems keyed by their id. + hierarchy: The precedence levels and their picking mode. + channels: The enabled channels with their generators. + matcher: The candidate scoring machinery. + extractor: The feature extractor whose subtraction forms the residual. + channel_cap: The maximum number of channels one stem holds per frame. + + Returns: + The picks in the order they were made, with the final channel mapping. + + Raises: + ValueError: If ``channel_cap`` is below 1, a stem allows a channel the + enabled channels lack, a stem id disagrees with its key, or the + hierarchy names every stem exactly once. + """ + _validate(stems, hierarchy, channels, channel_cap) + session = _AssignmentSession( + fragment, + stems, + hierarchy, + channels, + matcher, + extractor, + channel_cap, + ) + return session.run() + + +def _validate( + stems: Dict[int, Stem], + hierarchy: StemHierarchy, + channels: Dict[ChannelName, GeneratorUnion], + channel_cap: int, +) -> None: + if channel_cap < 1: + raise ValueError("channel_cap must be at least 1") + + enabled = set(channels) + for stem_id, stem in stems.items(): + if stem.id != stem_id: + raise ValueError(f"Stem {stem.id} is keyed as {stem_id}") + + foreign = set(stem.channels) - enabled + if foreign: + raise ValueError(f"Stem {stem.id} allows channels the configuration lacks: {sorted(foreign)}") + + referenced = [stem_id for level in hierarchy.levels for stem_id in level] + if set(referenced) != set(stems) or len(set(referenced)) != len(referenced): + raise ValueError("Hierarchy levels must name every stem exactly once") + + +class _AssignmentSession: + """ + Carries one frame assignment's mutable progress: the residual, the free + channels, and the per-stem channel counts. + """ + + def __init__( + self, + fragment: Fragment, + stems: Dict[int, Stem], + hierarchy: StemHierarchy, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + channel_cap: int, + ) -> None: + self.stems = stems + self.hierarchy = hierarchy + self.channels = channels + self.matcher = matcher + self.extractor = extractor + self.channel_cap = channel_cap + self.residual = fragment + reachable = {channel for stem in stems.values() for channel in stem.channels} + self.free_channels = [name for name in ChannelName.items() if name in reachable] + self.used_channels: Dict[int, int] = {stem_id: 0 for stem_id in stems} + self.choices: List[StemChoice] = [] + + def run(self) -> StemFrameAssignment: + match self.hierarchy.mode: + case HierarchyMode.ROUND_ROBIN: + self._round_robin() + case HierarchyMode.STRICT: + self._strict() + return StemFrameAssignment(tuple(self.choices)) + + def _round_robin(self) -> None: + for _ in range(self.channel_cap): + for level in self.hierarchy.levels: + if not self.free_channels: + return + self._pick_from_level(level, repeat=False) + + def _strict(self) -> None: + for level in self.hierarchy.levels: + if not self.free_channels: + return + self._pick_from_level(level, repeat=True) + + def _pick_from_level(self, level: Tuple[int, ...], *, repeat: bool) -> None: + picked_this_visit: Set[int] = set() + while True: + eligible = [ + stem_id + for stem_id in level + if self.used_channels[stem_id] < self.channel_cap and (repeat or stem_id not in picked_this_visit) + ] + if not eligible or not self.free_channels: + return + + choice = self._best_choice(eligible) + if choice is None: + return + + self.choices.append(choice) + self.used_channels[choice.stem_id] += 1 + self.free_channels.remove(choice.channel_name) + self.residual = self.extractor.subtract(self.residual, choice.approximation) + picked_this_visit.add(choice.stem_id) + + def _best_choice(self, stem_ids: Sequence[int]) -> Optional[StemChoice]: + best: Optional[StemChoice] = None + for stem_id in stem_ids: + remaining_channels = self._remaining_channels(stem_id) + if not remaining_channels: + continue + + remaining_generator_classes = get_remaining_generator_classes(remaining_channels) + scored = self.matcher.score_candidates(self.residual, remaining_generator_classes) + candidate = scored[0] + generator = get_generator_by_instruction( + candidate.instruction, + remaining_generator_classes, + ) + choice = StemChoice( + stem_id=stem_id, + channel_name=ChannelName(generator.name), + instruction=candidate.instruction, + approximation=candidate.approximation, + cost=candidate.cost, + ) + if best is None or choice.cost < best.cost: + best = choice + return best + + def _remaining_channels( + self, + stem_id: int, + ) -> Dict[ChannelName, GeneratorUnion]: + return {name: self.channels[name] for name in self.free_channels if name in self.stems[stem_id].channels} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models.py b/src/sampletones_core/reconstructions/reconstructor/stems/models.py new file mode 100644 index 000000000..76eb161de --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, FrozenSet, NamedTuple, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment +from sampletones_core.instructions import InstructionUnion + + +class HierarchyMode(StrEnum): + ROUND_ROBIN = "round_robin" + STRICT = "strict" + + +@dataclass(frozen=True) +class Stem: + """ + One audio source competing for channels in a reconstruction, identified by its + id and restricted to the channels it may occupy. + """ + + id: int + channels: FrozenSet[ChannelName] + + +@dataclass(frozen=True) +class StemHierarchy: + """ + The precedence structure of a stems assignment: stems grouped into levels that + pick in order, with a mode choosing how picks alternate between levels. + """ + + levels: Tuple[Tuple[int, ...], ...] + mode: HierarchyMode + + +class StemChoice(NamedTuple): + stem_id: int + channel_name: ChannelName + instruction: InstructionUnion + approximation: Fragment + cost: float + + +class StemFrameAssignment(NamedTuple): + choices: Tuple[StemChoice, ...] + + @property + def by_channel(self) -> Dict[ChannelName, StemChoice]: + return {choice.channel_name: choice for choice in self.choices} From 20af57cfb3817a91c6d884189fcb0d822dbb5762 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 19 Aug 2026 23:30:07 +0200 Subject: [PATCH 019/142] Added: stems assignment tests --- .../reconstructor/stems/conftest.py | 53 ++++ .../reconstructor/stems/test_equivalence.py | 269 ++++++++++++++++++ .../reconstructor/stems/test_frame.py | 255 +++++++++++++++++ .../reconstructor/stems/test_models.py | 37 +++ 4 files changed, 614 insertions(+) create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py new file mode 100644 index 000000000..27f953ab1 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py @@ -0,0 +1,53 @@ +from typing import Dict + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators import GeneratorUnion, get_generators_by_channels +from sampletones_core.reconstructions.reconstructor.selector.greedy import GreedySelector +from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker + + +@pytest.fixture(scope="module") +def matcher(worker: ReconstructorWorker) -> FrameMatcher: + return FrameMatcher( + config=worker.config, + candidate_provider=worker.candidate_provider, + scorer=worker.scorer, + phase_aligner=worker.phase_aligner, + ) + + +@pytest.fixture(scope="module") +def all_channels(config: Config) -> Dict[ChannelName, GeneratorUnion]: + return get_generators_by_channels(config, ChannelName.items()) + + +@pytest.fixture(scope="module") +def greedy_selector(worker: ReconstructorWorker) -> GreedySelector: + return _build_greedy_selector(worker, worker.channels) + + +@pytest.fixture(scope="module") +def all_channels_selector( + worker: ReconstructorWorker, + all_channels: Dict[ChannelName, GeneratorUnion], +) -> GreedySelector: + return _build_greedy_selector(worker, all_channels) + + +def _build_greedy_selector( + worker: ReconstructorWorker, + channels: Dict[ChannelName, GeneratorUnion], +) -> GreedySelector: + return GreedySelector( + config=worker.config, + window=worker.window, + channels=channels, + scorer=worker.scorer, + candidate_provider=worker.candidate_provider, + phase_aligner=worker.phase_aligner, + feature_extractor=worker.feature_extractor, + ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py new file mode 100644 index 000000000..d3940f155 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -0,0 +1,269 @@ +from typing import Dict, Final, Tuple + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment, Window +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import GeneratorUnion +from sampletones_core.library import InstructionLibraryData +from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData +from sampletones_core.reconstructions.reconstructor.selector.greedy import GreedySelector +from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.stems import ( + HierarchyMode, + Stem, + StemChoice, + StemFrameAssignment, + StemHierarchy, + assign_frame, +) +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker + +RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197) + + +class TestSingleStemEquivalence: + def test_matches_the_greedy_baseline_exactly( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + greedy_selector: GreedySelector, + ) -> None: + stems = {0: Stem(id=0, channels=frozenset(channels))} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + + assignment = assign_frame( + synthetic_fragment, + stems, + hierarchy, + channels, + matcher, + extractor, + len(channels), + ) + baseline = greedy_selector.reconstruct_fragment(synthetic_fragment) + + assert len(assignment.choices) == len(channels) + assert len(baseline) == len(channels) + _assert_same_picks(assignment, baseline) + + def test_matches_the_baseline_with_all_four_channels( + self, + synthetic_fragment: Fragment, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + all_channels_selector: GreedySelector, + ) -> None: + stems = {0: Stem(id=0, channels=frozenset(all_channels))} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + + assignment = assign_frame( + synthetic_fragment, + stems, + hierarchy, + all_channels, + matcher, + extractor, + len(all_channels), + ) + baseline = all_channels_selector.reconstruct_fragment(synthetic_fragment) + + assert len(assignment.choices) == len(all_channels) + assert len(baseline) == len(all_channels) + _assert_same_picks(assignment, baseline) + + +class TestStrictDisjointStems: + def test_matches_sequential_per_subset_baselines( + self, + worker: ReconstructorWorker, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + subset_pulse_triangle = { + ChannelName.PULSE1: channels[ChannelName.PULSE1], + ChannelName.TRIANGLE: channels[ChannelName.TRIANGLE], + } + subset_noise = {ChannelName.NOISE: channels[ChannelName.NOISE]} + + baseline_first = _restricted_selector(worker, subset_pulse_triangle).reconstruct_fragment(synthetic_fragment) + residual = synthetic_fragment + for approximation_data in baseline_first.values(): + residual = extractor.subtract(residual, approximation_data.approximation) + baseline_second = _restricted_selector(worker, subset_noise).reconstruct_fragment(residual) + + expected = dict(baseline_first) + expected.update(baseline_second) + + stems = { + 0: Stem(id=0, channels=frozenset(subset_pulse_triangle)), + 1: Stem(id=1, channels=frozenset(subset_noise)), + } + hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.STRICT) + + assignment = assign_frame( + synthetic_fragment, + stems, + hierarchy, + channels, + matcher, + extractor, + len(channels), + ) + + assert len(assignment.choices) == len(channels) + _assert_same_picks(assignment, expected) + + +class TestRandomizedDifferential: + @pytest.mark.parametrize("random_seed", RANDOM_SEEDS) + def test_invariants_and_determinism( + self, + random_seed: int, + config: Config, + window: Window, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + rng = np.random.default_rng(random_seed) + fragment = _random_target_fragment(rng, config, window, extractor, library_data) + stems, hierarchy, channel_cap = _random_setup(rng, tuple(all_channels)) + + assignment = assign_frame( + fragment, + stems, + hierarchy, + all_channels, + matcher, + extractor, + channel_cap, + ) + repeat = assign_frame( + fragment, + stems, + hierarchy, + all_channels, + matcher, + extractor, + channel_cap, + ) + + assert _choice_keys(assignment.choices) == _choice_keys(repeat.choices) + + channels_assigned = [choice.channel_name for choice in assignment.choices] + assert len(channels_assigned) == len(set(channels_assigned)) + assert set(channels_assigned) <= set(all_channels) + + counts: Dict[int, int] = {} + for choice in assignment.choices: + counts[choice.stem_id] = counts.get(choice.stem_id, 0) + 1 + assert choice.channel_name in stems[choice.stem_id].channels + assert all(count <= channel_cap for count in counts.values()) + + if hierarchy.mode == HierarchyMode.STRICT: + _assert_strict_ordering(assignment, hierarchy) + + +def _assert_same_picks( + assignment: StemFrameAssignment, + baseline: Dict[ChannelName, ApproximationData], +) -> None: + assert [choice.channel_name for choice in assignment.choices] == list(baseline.keys()) + assert set(assignment.by_channel) == set(baseline) + + for channel_name, approximation_data in baseline.items(): + choice = assignment.by_channel[channel_name] + assert choice.instruction == approximation_data.instruction + _assert_same_fragment(choice.approximation, approximation_data.approximation) + + +def _assert_same_fragment(left: Fragment, right: Fragment) -> None: + np.testing.assert_array_equal(np.asarray(left.audio), np.asarray(right.audio)) + np.testing.assert_array_equal( + np.asarray(left.windowed_audio), + np.asarray(right.windowed_audio), + ) + np.testing.assert_array_equal( + np.asarray(left.feature.values), + np.asarray(right.feature.values), + ) + + +def _assert_strict_ordering( + assignment: StemFrameAssignment, + hierarchy: StemHierarchy, +) -> None: + first_positions = [ + index for index, choice in enumerate(assignment.choices) if choice.stem_id in hierarchy.levels[0] + ] + second_positions = [ + index for index, choice in enumerate(assignment.choices) if choice.stem_id in hierarchy.levels[1] + ] + if first_positions and second_positions: + assert max(first_positions) < min(second_positions) + + +def _choice_keys(choices: Tuple[StemChoice, ...]) -> Tuple[Tuple[int, ChannelName], ...]: + return tuple((choice.stem_id, choice.channel_name) for choice in choices) + + +def _restricted_selector( + worker: ReconstructorWorker, + channels: Dict[ChannelName, GeneratorUnion], +) -> GreedySelector: + return GreedySelector( + config=worker.config, + window=worker.window, + channels=channels, + scorer=worker.scorer, + candidate_provider=worker.candidate_provider, + phase_aligner=worker.phase_aligner, + feature_extractor=worker.feature_extractor, + ) + + +def _random_setup( + rng: np.random.Generator, + channel_names: Tuple[ChannelName, ...], +) -> Tuple[Dict[int, Stem], StemHierarchy, int]: + shuffled = list(channel_names) + rng.shuffle(shuffled) + split = int(rng.integers(1, len(shuffled))) + + stems = { + 0: Stem(id=0, channels=frozenset(shuffled[:split])), + 1: Stem(id=1, channels=frozenset(shuffled[split:])), + } + mode = HierarchyMode.ROUND_ROBIN if bool(rng.integers(2)) else HierarchyMode.STRICT + hierarchy = StemHierarchy(levels=((0,), (1,)), mode=mode) + channel_cap = int(rng.integers(1, len(channel_names) + 1)) + return stems, hierarchy, channel_cap + + +def _random_target_fragment( + rng: np.random.Generator, + config: Config, + window: Window, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, +) -> Fragment: + instructions = [instruction for instruction in library_data.keys() if library_data[instruction].length > 0] + audio = np.zeros(window.frame_length, dtype=np.float64) + for _ in range(int(rng.integers(1, 5))): + instruction = instructions[int(rng.integers(0, len(instructions)))] + library_fragment = library_data[instruction] + shift = int(rng.integers(0, library_fragment.length)) + contribution = library_fragment.get_fragment(shift, config, window) + audio += np.asarray(contribution.audio) * float(rng.uniform(0.2, 1.0)) + + return extractor.extract(audio)[0] diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py new file mode 100644 index 000000000..298466d23 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -0,0 +1,255 @@ +from typing import Dict, FrozenSet, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import GeneratorUnion +from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.stems import ( + HierarchyMode, + Stem, + StemChoice, + StemFrameAssignment, + StemHierarchy, + assign_frame, +) + +DEFAULT_CHANNELS: FrozenSet[ChannelName] = frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE)) + + +def _assign( + fragment: Fragment, + stems: Dict[int, Stem], + hierarchy: StemHierarchy, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + channel_cap: int, +) -> StemFrameAssignment: + return assign_frame( + fragment, + stems, + hierarchy, + channels, + matcher, + extractor, + channel_cap, + ) + + +class TestAssignFrameValidation: + def test_zero_cap_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + with pytest.raises(ValueError, match="channel_cap"): + _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 0) + + def test_stem_id_disagreeing_with_its_key_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=1, channels=DEFAULT_CHANNELS)} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + with pytest.raises(ValueError, match="keyed"): + _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + + def test_channel_outside_enabled_channels_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=0, channels=frozenset((ChannelName.PULSE2,)))} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + with pytest.raises(ValueError, match="configuration lacks"): + _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + + def test_hierarchy_duplicating_a_stem_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} + hierarchy = StemHierarchy(levels=((0,), (0,)), mode=HierarchyMode.STRICT) + with pytest.raises(ValueError, match="exactly once"): + _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + + def test_hierarchy_leaving_a_stem_out_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = { + 0: Stem(id=0, channels=DEFAULT_CHANNELS), + 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), + } + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + with pytest.raises(ValueError, match="exactly once"): + _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + + def test_hierarchy_naming_an_unknown_stem_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} + hierarchy = StemHierarchy(levels=((0,), (5,)), mode=HierarchyMode.STRICT) + with pytest.raises(ValueError, match="exactly once"): + _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + + +class TestChannelCap: + def test_strict_mode_respects_the_cap( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + + for cap, expected_count in ((1, 1), (2, 2), (5, 3)): + assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, cap) + assert len(assignment.choices) == expected_count + assert {choice.stem_id for choice in assignment.choices} == {0} + + def test_round_robin_mode_respects_the_cap( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} + hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.ROUND_ROBIN) + + assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + assert len(assignment.choices) == 2 + + +class TestTieBreakDeterminism: + def test_equal_cost_choices_go_to_the_first_stem_in_level_order( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + pulse_only = frozenset((ChannelName.PULSE1,)) + stems = { + 0: Stem(id=0, channels=pulse_only), + 1: Stem(id=1, channels=pulse_only), + } + + first = _assign( + synthetic_fragment, + stems, + StemHierarchy(levels=((0, 1),), mode=HierarchyMode.STRICT), + channels, + matcher, + extractor, + 1, + ) + assert [(choice.stem_id, choice.channel_name) for choice in first.choices] == [(0, ChannelName.PULSE1)] + + swapped = _assign( + synthetic_fragment, + stems, + StemHierarchy(levels=((1, 0),), mode=HierarchyMode.STRICT), + channels, + matcher, + extractor, + 1, + ) + assert [(choice.stem_id, choice.channel_name) for choice in swapped.choices] == [(1, ChannelName.PULSE1)] + + def test_repeated_runs_give_identical_choices( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = { + 0: Stem(id=0, channels=frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE))), + 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), + } + hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.STRICT) + + first = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + second = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + assert _choice_keys(first.choices) == _choice_keys(second.choices) + + +class TestHierarchyOrdering: + def test_strict_mode_exhausts_the_first_level_before_the_next( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = { + 0: Stem(id=0, channels=frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE))), + 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), + } + hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.STRICT) + + assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + + assert [choice.stem_id for choice in assignment.choices] == [0, 0, 1] + assert {choice.channel_name for choice in assignment.choices[:2]} == { + ChannelName.PULSE1, + ChannelName.TRIANGLE, + } + assert assignment.choices[2].channel_name == ChannelName.NOISE + + def test_round_robin_mode_alternates_levels_each_round( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems = { + 0: Stem(id=0, channels=frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE))), + 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), + } + hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.ROUND_ROBIN) + + assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + + assert [choice.stem_id for choice in assignment.choices] == [0, 1, 0] + assert assignment.choices[1].channel_name == ChannelName.NOISE + assert {assignment.choices[0].channel_name, assignment.choices[2].channel_name} == { + ChannelName.PULSE1, + ChannelName.TRIANGLE, + } + assert assignment.by_channel.keys() == { + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, + } + + +def _choice_keys(choices: Sequence[StemChoice]) -> Tuple[Tuple[int, ChannelName], ...]: + return tuple((choice.stem_id, choice.channel_name) for choice in choices) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py new file mode 100644 index 000000000..1787c2394 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py @@ -0,0 +1,37 @@ +from typing import Final, FrozenSet + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems import ( + HierarchyMode, + Stem, + StemHierarchy, +) + +PULSE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset((ChannelName.PULSE1, ChannelName.PULSE2)) + + +class TestStem: + def test_is_frozen_and_hashable(self) -> None: + stem = Stem(id=0, channels=PULSE_CHANNELS) + assert stem == Stem(id=0, channels=PULSE_CHANNELS) + assert hash(stem) == hash(Stem(id=0, channels=PULSE_CHANNELS)) + + def test_fields(self) -> None: + stem = Stem(id=0, channels=PULSE_CHANNELS) + assert stem.id == 0 + assert stem.channels == PULSE_CHANNELS + + +class TestStemHierarchy: + def test_fields(self) -> None: + hierarchy = StemHierarchy( + levels=((0,), (1, 2)), + mode=HierarchyMode.ROUND_ROBIN, + ) + assert hierarchy.levels == ((0,), (1, 2)) + assert hierarchy.mode == HierarchyMode.ROUND_ROBIN + + +class TestHierarchyMode: + def test_values(self) -> None: + assert tuple(HierarchyMode) == (HierarchyMode.ROUND_ROBIN, HierarchyMode.STRICT) From 90e3211e775bed868ca1d397384022466c544c2a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 11:55:25 +0200 Subject: [PATCH 020/142] Partially implemented: stems --- docs/concepts/reconstruction.md | 4 +- docs/concepts/stems.md | 77 ++++++++++ docs/formats/reconstructions.md | 15 +- docs/index.md | 1 + pyproject.toml | 8 + .../coordinators/original_audio.py | 3 + .../coordinators/reconstruction.py | 2 +- .../logic/reconstruction/audio_location.py | 4 +- .../logic/reconstruction/data.py | 7 +- .../logic/reconstruction/manager.py | 6 +- .../logic/reconstruction/reconstruction.py | 14 +- src/sampletones_core/configs/config.py | 7 +- src/sampletones_core/constants/algorithm.py | 7 +- src/sampletones_core/constants/enums.py | 5 + src/sampletones_core/data/model.py | 34 +++++ .../reconstruction/reconstruction.py | 34 +++-- .../reconstruction/stems/__init__.py | 0 .../stems/channel_assignment.py | 19 +++ .../reconstruction/stems/data.py | 27 ++++ .../reconstructor/reconstructor.py | 139 +++++++++++++++++- .../reconstructor/stems/__init__.py | 17 --- .../reconstructor/stems/configs/__init__.py | 0 .../reconstructor/stems/configs/config.py | 36 +++++ .../reconstructor/stems/configs/entry.py | 19 +++ .../reconstructor/stems/configs/hierarchy.py | 22 +++ .../reconstructor/stems/configs/stem.py | 15 ++ .../reconstructor/stems/frame.py | 27 ++-- .../reconstructor/stems/models.py | 50 ------- .../reconstructor/stems/models/__init__.py | 0 .../reconstructor/stems/models/choice.py | 13 ++ .../stems/models/frame_assignment.py | 12 ++ .../reconstructor/stems/models/hierarchy.py | 15 ++ tests/integration/reconstruction/__init__.py | 0 .../test_stems_reconstruction.py | 87 +++++++++++ .../reconstruction/test_reconstruction.py | 70 ++++++++- .../reconstructor/stems/test_config.py | 48 ++++++ .../reconstructor/stems/test_equivalence.py | 15 +- .../reconstructor/stems/test_frame.py | 15 +- .../reconstructor/stems/test_models.py | 9 +- 39 files changed, 741 insertions(+), 142 deletions(-) create mode 100644 docs/concepts/stems.md create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/data.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py delete mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py create mode 100644 tests/integration/reconstruction/__init__.py create mode 100644 tests/integration/reconstruction/test_stems_reconstruction.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index ff553a5b1..b069c1a30 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -174,7 +174,9 @@ on machines with a GPU, runs on the array backend in `sampletones_shared`. Both selectors live in `sampletones_core.reconstructions.reconstructor.selector` and are chosen with `generation.decoder.selector`. They share the criterion and the -library; they differ only in how they search. +library; they differ only in how they search. The same candidate scoring drives +the stems assignment, which hands channels to several stems per frame +(see [Stems reconstruction](stems.md)). Both score candidates in two stages: every candidate is first ranked by the phase-independent spectral term, and the best `top_k` are then re-scored with the diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md new file mode 100644 index 000000000..e7ff82bc2 --- /dev/null +++ b/docs/concepts/stems.md @@ -0,0 +1,77 @@ +# Stems reconstruction + +This document explains how one reconstruction is assigned across several stems. +Consult it when changing the stems assignment algorithm, its configuration, or +the per-stem record a reconstruction carries. The single-sample pipeline this +builds on is described in [Reconstruction](reconstruction.md), and the stored +record in [Reconstructions](../formats/reconstructions.md). + +A stems reconstruction converts several audio stems at once. The stems are +mixed and the mix is matched against the instruction library; within each frame, +the channels are handed to the stems one pick at a time, following a precedence +hierarchy. The result is one reconstruction whose `stems_data` records, per +channel and frame, which stem's stream plays — the record multisample playback +will read in the future. + +## Principles + +### 1. The mix is the target + +Every stem is loaded and normalized on its own, padded to the longest stem's +length, and summed. Frames and residuals come from the mix; a stem's own audio +takes no separate part in matching. The working-level coefficient is computed +from the mix, exactly as for a single file. + +### 2. One greedy pick at a time + +A pick scores each eligible stem's candidates against the current residual with +the same two-stage criterion the single-sample pipeline uses (`FrameMatcher`), +takes the cheapest choice across the active level, subtracts its approximation +from the residual, and consumes the channel. Picks continue until every stem +channel is assigned, or caps and free channels are exhausted. Matching against +the residual is what keeps later picks from re-approximating content earlier +picks already cover. + +### 3. Precedence orders, mode alternates + +The hierarchy groups stem ids into levels that pick in the listed order. In +`strict` mode a level exhausts its stems' channel caps before the next level +picks; in `round_robin` mode the levels take turns, granting every level's stems +one channel per round. Both modes let every stem hold at most `channel_cap` +channels per frame. + +### 4. Ties resolve deterministically + +Equal-cost choices go to the stem earlier in level order. Channels of one kind +resolve to the lowest free channel, so successive picks over one kind land on +the lowest free channel and a rerun assigns the same way every time. + +### 5. The single-sample case stays exact + +One stem covering every enabled channel, with a cap at the channel count, +reproduces the greedy baseline pick for pick. Property tests hold the two paths +to identical choices, instructions, and approximations, so the stems assignment +generalizes the existing pipeline without changing it. + +## Mechanics + +The assignment lives in `sampletones_core.reconstructions.reconstructor.stems`: + +- `Stem` names a competing source and the channels it may occupy; +- `StemHierarchy` carries the precedence levels and the mode; +- `assign_frame` runs one frame's picks against a residual, using the shared + `FrameMatcher` and `FeatureExtractor` of the pipeline; +- `Reconstructor.reconstruct_stems` loads the stems, mixes them, runs + `assign_frame` per frame, and records the outcome. + +The record stored in a reconstruction (`stems_data`) holds the stems setup the +assignment was made under — the entries, the precedence hierarchy, and the +channel cap — and, per channel, the stem id holding each frame, parallel to the +instruction streams. The record is an optional field, so files written before it +existed load without one. + +The stems setup is built per process from the inputs and the user's choices, and +handed to `Reconstructor.reconstruct_stems` together with the stem paths; it is +part of the process rather than of the standard configuration. Per-frame +assignment is greedy for now; Viterbi continuity and playback that decides per +frame on the recorded streams are future work. diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 1874aeaee..9f557ba75 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -13,8 +13,9 @@ A `.stn` file holds: * **metadata** — the application name and version, and the reconstruction data-version used to check compatibility on load (see [Versioning](#versioning)); * **id** — a unique identifier for the reconstruction; -* **source audio** — the path to the original recording, or empty when the - reconstruction is [detached](#detached-reconstructions); +* **source audio** — the path to the original recording, the stem paths when the + reconstruction was built from several stems, or empty when the reconstruction + is [detached](#detached-reconstructions); * **configuration** — a frozen snapshot of the [generation configuration](../guide/configuration.md) used, so the file records exactly how it was made: sample rate, NES frequency, enabled channels, spectrum @@ -44,7 +45,11 @@ A `.stn` file holds: is what says which of them the instrument itself writes; the rest are the channel's, and the player keeps the value it already holds for them. A channel in play writes them all as it is built, and clearing an envelope in the - instruments panel adds that dimension here. + instruments panel adds that dimension here; +* **stems assignment** — present when the reconstruction was built from several + stems: the stems setup the assignment was made under and, per channel, the + stem holding each frame (`stems_data`). A reconstruction from a single file + carries none. A channel standing by rests at a reference pitch of its own, so the first envelope written into it sounds on a mid-range note, and it leaves every dimension it offers @@ -73,7 +78,9 @@ is stored alongside the data version, for reference. The current data version is 2.2. Version 2.2 renamed the per-channel stream and approximation keys from `generator_name` to `channel_name` and the channel selection under the embedded config from `generators` to `channels`; the enum -values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. +values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. A +reconstruction built from several stems also carries the optional `stems_data` +record; a file written before the record existed reads without one. ## Storage and export diff --git a/docs/index.md b/docs/index.md index 38d5204c0..dd12a7496 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,7 @@ The [**concepts**](concepts/) section explains the ideas behind the reconstruction. It is written to be read without the source code. - [Reconstruction algorithms](concepts/reconstruction.md) — how a sample becomes a stream of NES instructions. +- [Stems reconstruction](concepts/stems.md) — how one reconstruction is assigned across several stems. - [Instruction library](concepts/instruction-library.md) — the catalogue of NES sounds the search draws from. - [Project](concepts/project.md) — a whole composition: a song and the reconstructions it is built from. - [Calibration](concepts/calibration.md) — how the reconstruction's settings are tuned by experiment. diff --git a/pyproject.toml b/pyproject.toml index 44ade8cc3..ed92e15c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,6 +168,14 @@ strict = true fail-under = 9.9 ignore-paths = "^tests/.*$" load-plugins = ["pylint_pydantic"] +fail-on = [ + "fatal", + "fixme", + "redefined-outer-name", + "used-before-assignment", + "unused-import", + "unused-variable", +] [tool.pylint.messages_control] disable = [ diff --git a/src/sampletones_application/coordinators/original_audio.py b/src/sampletones_application/coordinators/original_audio.py index 03c98c731..69f2e5eb5 100644 --- a/src/sampletones_application/coordinators/original_audio.py +++ b/src/sampletones_application/coordinators/original_audio.py @@ -37,6 +37,9 @@ def locate(self, filepath: Path) -> None: if audio_filepath is None: return + if not isinstance(audio_filepath, Path): + return + if not audio_filepath.exists(): self._dialogs.show_file_not_found( audio_filepath, diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 28175ef6b..d3ac86b88 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -292,7 +292,7 @@ def on_reconstruction_loaded(self) -> None: self._audio_device_manager.stop() audio_filepath = reconstruction_data.reconstruction.audio_filepath - if audio_filepath is not None and not audio_filepath.exists(): + if isinstance(audio_filepath, Path) and not audio_filepath.exists(): self._dialogs.show_file_not_found( audio_filepath, self._language_manager["reconstructions.browser.message.audio_file_not_found"], diff --git a/src/sampletones_application/logic/reconstruction/audio_location.py b/src/sampletones_application/logic/reconstruction/audio_location.py index cb93a10ed..c587c7f75 100644 --- a/src/sampletones_application/logic/reconstruction/audio_location.py +++ b/src/sampletones_application/logic/reconstruction/audio_location.py @@ -1,10 +1,10 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Tuple, Union from sampletones_core.reconstructions import Reconstruction -def resolve_original_audio(filepath: Path) -> Optional[Path]: +def resolve_original_audio(filepath: Path) -> Optional[Union[Path, Tuple[Path, ...]]]: """Reads a browsed reconstruction to recover the original audio location it records.""" reconstruction = Reconstruction.load(filepath) return reconstruction.audio_filepath diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 0ea6cf8c3..7c60ba312 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -107,7 +107,10 @@ def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str: reconstruction (no source audio) falls back to the ``.stn`` filename. """ audio_filepath = reconstruction.audio_filepath - return audio_filepath.stem if audio_filepath is not None else filepath.stem + if isinstance(audio_filepath, Path): + return audio_filepath.stem + + return filepath.stem @staticmethod def _load_original_audio( @@ -120,7 +123,7 @@ def _load_original_audio( cases yield ``None``; the approximation then stands on its own in playback and the display. """ audio_filepath = reconstruction.audio_filepath - if audio_filepath is None: + if not isinstance(audio_filepath, Path): return None config = reconstruction.config diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index d88078b7a..073666b80 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Tuple, Union from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.data import ReconstructionData @@ -173,7 +173,7 @@ def close_reconstruction(self) -> None: def locate_original_audio(self) -> None: original_audio_path = self.audio_filepath - if not original_audio_path: + if not isinstance(original_audio_path, Path): # to do: support multiple paths return if not original_audio_path.exists(): @@ -208,7 +208,7 @@ def is_file_backed(self) -> bool: return self.filepath is not None @property - def audio_filepath(self) -> Optional[Path]: + def audio_filepath(self) -> Optional[Union[Path, Tuple[Path, ...]]]: if self._current_reconstruction is None: return None diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 673a9d25b..bf6ec1dbd 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple +from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple, Union import numpy as np @@ -398,7 +398,7 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: def handle_locate_original_audio(self) -> None: path = self._reconstruction_manager.audio_filepath - if path is None: + if not isinstance(path, Path): return try: @@ -478,7 +478,7 @@ def _build_file_path_view_model( @staticmethod def _build_audio_path_view_model( - audio_filepath: Optional[Path], + audio_filepath: Optional[Union[Path, Tuple[Path, ...]]], original_audio: Optional[np.ndarray], ) -> ReconstructionPathViewModel: """Reports the original-audio location, treating a recorded path with unusable content @@ -495,9 +495,15 @@ def _build_audio_path_view_model( path="", ) + if isinstance(audio_filepath, Path): + return ReconstructionPathViewModel( + state=ReconstructionPathState.AVAILABLE, + path=str(audio_filepath), + ) + return ReconstructionPathViewModel( state=ReconstructionPathState.AVAILABLE, - path=str(audio_filepath), + path=", ".join(str(path) for path in audio_filepath), ) @property diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index 5ed7c6a67..1be319733 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -5,6 +5,9 @@ from pydantic import ConfigDict, Field +from sampletones_core.configs.general import GeneralConfig +from sampletones_core.configs.generation import GenerationConfig +from sampletones_core.configs.library import InstructionsLibraryConfig from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.data.metadata import Metadata @@ -14,10 +17,6 @@ from sampletones_shared.utils.system.paths import to_path from sampletones_shared.utils.validation import validate_with_recovery -from .general import GeneralConfig -from .generation import GenerationConfig -from .library import InstructionsLibraryConfig - class Config(DataModel): model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", frozen=True) diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index 0808ec5df..19f8d1e85 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -1,6 +1,6 @@ from typing import Final -from .enums import PhaseAlignerName, SelectorName, SpectralDistance +from .enums import HierarchyMode, PhaseAlignerName, SelectorName, SpectralDistance from .general import MAX_VOLUME, MIN_VOLUME # Matching floors @@ -57,6 +57,11 @@ DRIVE: Final[float] = 1.0 MAX_DRIVE: Final[float] = 5.0 +# Stems assignment + +DEFAULT_STEMS_CHANNEL_CAP: Final[int] = 1 +DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN + # Execution MAX_WORKERS: Final[int] = 6 diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index 8ea10904d..a586a05c5 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -73,6 +73,11 @@ class SelectorName(StrEnum): VITERBI = "viterbi" +class HierarchyMode(StrEnum): + ROUND_ROBIN = "round_robin" + STRICT = "strict" + + class SpectrumMethod(StrEnum): FFT = "fft" LOG_SPACED_FFT = "logfft" diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index 82ca27112..916775577 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -139,6 +139,12 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: return self._pack_value(value, optional_inner, field_name) + if isinstance(value, Path): + return str(value) + + if isinstance(value, tuple): + return [str(path) for path in value] + return self._pack_union(value) if isinstance(annotation, TypeVar): @@ -190,6 +196,12 @@ def _unpack_value( fast, ) + if isinstance(raw, list): + return tuple(Path(item) for item in raw) + + if isinstance(raw, str): + return Path(raw) + return cls._unpack_union(raw) if isinstance(annotation, TypeVar): @@ -233,6 +245,12 @@ def _pack_list( if all(isinstance(model, DataModel) for model in collection): return [model.serialize_inner() for model in collection] + if all(isinstance(value, (int, float, bool)) for value in collection): + return list(collection) + + if all(isinstance(value, list) for value in collection): + return [self._pack_list(value, field_name) for value in collection] + raise SerializationError( f"Unsupported list element type {type(collection[0])} or mixed types for field '{field_name}'" ) @@ -247,6 +265,19 @@ def _unpack_list( fast: bool = True, ) -> List[Any]: origin = get_origin(element_class) + if origin is list: + nested_element_class = get_args(element_class)[0] + return [ + cls._unpack_list( + item, + field_name, + nested_element_class, + validation, + fast, + ) + for item in raw_list + ] + if origin is not None: raise DeserializationError(f"Generics are not supported for field '{field_name}'") @@ -266,6 +297,9 @@ def _unpack_list( if issubclass(element_class, (str, StrEnum)): return [cls._deserialize_string(item, element_class) for item in raw_list] + if issubclass(element_class, (int, float, bool)): + return [element_class(item) for item in raw_list] + raise DeserializationError(f"Unsupported vector element type: {element_class} for field '{field_name}'") def _pack_array(self, array: Array, field_name: str) -> bytes: diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 517d75611..a0eb1ace5 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -14,6 +14,7 @@ Self, Sequence, Tuple, + Union, ) from uuid import uuid4 @@ -34,6 +35,10 @@ ) from sampletones_core.generators.maps import CHANNEL_CLASSES from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions.reconstruction.approximations import ApproximationsItem +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.state import ReconstructionState from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION from sampletones_shared.exceptions import ( IncompatibleReconstructionVersionError, @@ -48,10 +53,6 @@ from sampletones_shared.utils.arrays import pad from sampletones_shared.utils.serialization import load_binary, serialize_array -from ..reconstructor.state import ReconstructionState -from .approximations import ApproximationsItem -from .instructions import InstructionsItem - RECONSTRUCTION_DATA_CONTRACT: Final[MetadataContract] = MetadataContract( label="Reconstruction data", expected_version=SAMPLETONES_RECONSTRUCTION_DATA_VERSION, @@ -70,9 +71,9 @@ class Reconstruction(DataModel): ..., description="Unique identifier for the reconstruction", ) - audio_filepath: Optional[Path] = Field( + audio_filepath: Optional[Union[Path, Tuple[Path, ...]]] = Field( ..., - description="Location of the source audio; None marks a reconstruction detached from its local origin", + description="Location of the source audio: one path for a single source, the stem paths for a stems reconstruction, and None once detached from the local origin", ) config: Config = Field( ..., @@ -91,6 +92,10 @@ class Reconstruction(DataModel): ..., description="Instructions per channel", ) + stems_data: Optional[StemsData] = Field( + None, + description="Stems assignment recorded when built from several stems", + ) coefficient: float = Field( ..., description="Normalization coefficient used during reconstruction", @@ -184,7 +189,8 @@ def create( instructions: Mapping[ChannelName, Sequence[InstructionUnion]], config: Config, coefficient: float, - audio_filepath: Path, + audio_filepath: Union[Path, Tuple[Path, ...]], + stems_data: Optional[StemsData] = None, ) -> Self: approximation = np.nan_to_num(approximation, nan=0.0) approximations_data: List[ApproximationsItem] = [ @@ -217,6 +223,7 @@ def create( approximation=approximation, approximations_data=approximations_data, instructions_data=instructions_data, + stems_data=stems_data, config=config, coefficient=coefficient, audio_filepath=audio_filepath, @@ -228,7 +235,8 @@ def from_state( state: ReconstructionState, config: Config, coefficient: float, - path: Path, + path: Union[Path, Tuple[Path, ...]], + stems_data: Optional[StemsData] = None, ) -> Optional[Self]: if any(len(approximation) == 0 for approximation in state.approximations.values()): logger.warning(f"Reconstruction for file: {path} is empty") @@ -244,6 +252,7 @@ def from_state( config=config, coefficient=coefficient, audio_filepath=path, + stems_data=stems_data, ) def update_channel_data( @@ -496,10 +505,13 @@ def _serialize_approximation( @field_serializer("audio_filepath") def _serialize_audio_filepath( self, - audio_filepath: Optional[Path], + audio_filepath: Optional[Union[Path, Tuple[Path, ...]]], _info: Any, - ) -> Optional[str]: + ) -> Optional[Union[str, List[str]]]: if audio_filepath is None: return None - return str(audio_filepath) + if isinstance(audio_filepath, Path): + return str(audio_filepath) + + return [str(path) for path in audio_filepath] diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/__init__.py b/src/sampletones_core/reconstructions/reconstruction/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py b/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py new file mode 100644 index 000000000..2d1acab32 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py @@ -0,0 +1,19 @@ +from typing import List + +from pydantic import ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.data import DataModel + + +class ChannelAssignment(DataModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + channel_name: ChannelName = Field( + ..., + description="The channel whose frames are assigned", + ) + stem_ids: List[int] = Field( + ..., + description="The stem holding the channel in each frame", + ) diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py new file mode 100644 index 000000000..208e0d9f2 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -0,0 +1,27 @@ +from functools import cached_property +from typing import Dict, List + +from pydantic import ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.data import DataModel +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +class StemsData(DataModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + config: StemsConfig = Field( + ..., + description="The stems setup the assignment was made under", + ) + assignments: List[ChannelAssignment] = Field( + ..., + description="Per channel, the stem holding each frame", + ) + + @cached_property + def assignments_by_channel(self) -> Dict[ChannelName, List[int]]: + """The per-frame stem ids each channel carries, keyed by channel.""" + return {item.channel_name: item.stem_ids for item in self.assignments} diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 62e6043b9..9c09af7ae 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Sequence import numpy as np @@ -14,15 +14,22 @@ get_generators_by_channels, ) from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData +from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.state import ReconstructionState +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem +from sampletones_core.reconstructions.reconstructor.stems.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker from sampletones_shared.exceptions import NoLibraryDataError from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.arrays import pad from sampletones_shared.utils.system.paths import to_path -from ..reconstruction.reconstruction import Reconstruction -from .approximation import ApproximationData -from .state import ReconstructionState -from .worker import ReconstructorWorker - def reconstruct( fragments_ids: List[int], @@ -121,6 +128,126 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: self.reconstruct(fragmented_audio) return Reconstruction.from_state(self.state, self.config, coefficient, path) + # TODO: refactor: split into steps so this function reads as prose + # each step should be a separate function that has a single concrete objective + def reconstruct_stems( + self, + paths: Sequence[Pathlike], + stems_config: StemsConfig, + ) -> Optional[Reconstruction]: + """Reconstructs the mix of several stem audio files into one reconstruction. + + Loads and normalizes every stem, matches the frames of the stems' mix against + the library, and assigns each frame's channels to the stems following the + configured hierarchy and channel cap. The per-frame assignment is recorded in + the reconstruction's stems data. + + Args: + paths: Paths to the stem audio files, one per stems entry. + stems_config: The stems setup built for this process from the inputs: + the entries with their channels, the precedence hierarchy, and the + per-stem channel cap. + + Returns: + Optional[Reconstruction]: The reconstruction built from the mix. + + Raises: + ValueError: If the entries count differently than ``paths``. + TypeError: If a path is not a string or ``Path``. + """ + if len(paths) != len(stems_config.entries): + raise ValueError(f"Expected {len(stems_config.entries)} stem paths, got {len(paths)}") + + checked_paths: List[Path] = [] + for path in paths: + if not isinstance(path, (str, Path)): + raise TypeError("Input must be a path to an audio file") + checked_paths.append(to_path(path)) + + audios = [self.load_audio(path) for path in checked_paths] + mix = self._mix_audios(audios) + coefficient = self.get_coefficient(mix) + self.reset_generators() + covered = {channel for entry in stems_config.entries for channel in entry.channels} + self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered]) + fragmented_audio = self.get_fragments(mix / coefficient) + + worker = ReconstructorWorker( + config=self.config, + window=self.window, + channels=self.channels, + library_data=self.library_data, + signal_length=mix.shape[0], + ) + matcher = FrameMatcher( + config=worker.config, + candidate_provider=worker.candidate_provider, + scorer=worker.scorer, + phase_aligner=worker.phase_aligner, + ) + stems = { + entry.id: Stem( + id=entry.id, + channels=frozenset(entry.channels), + ) + for entry in stems_config.entries + } + hierarchy = StemHierarchy( + levels=tuple(tuple(level) for level in stems_config.hierarchy.levels), + mode=stems_config.hierarchy.mode, + ) + + assignments: Dict[ChannelName, List[int]] = {} + for fragment_id in fragmented_audio.fragments_ids: + fragment = fragmented_audio[fragment_id] + frame_assignment = assign_frame( + fragment, + stems, + hierarchy, + self.channels, + matcher, + worker.feature_extractor, + stems_config.channel_cap, + ) + for choice in frame_assignment.choices: + self.update_state( + ApproximationData( + channel_name=choice.channel_name, + approximation=choice.approximation, + instruction=choice.instruction, + ) + ) + assignments.setdefault(choice.channel_name, []).append(choice.stem_id) + + stems_data = StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=channel, + stem_ids=stem_ids, + ) + for channel, stem_ids in assignments.items() + ], + ) + return Reconstruction.from_state( + self.state, + self.config, + coefficient, + tuple(checked_paths), + stems_data=stems_data, + ) + + def _mix_audios(self, audios: List[np.ndarray]) -> np.ndarray: + """Sums the stem audios, each padded to the longest stem's length.""" + if not audios: + raise ValueError("At least one stem audio is required") + + max_length = max(audio.shape[0] for audio in audios) + return sum( + (pad(audio, 0, max_length) for audio in audios), + np.zeros(max_length, dtype=np.float64), + ) + def load_audio(self, path: Path) -> np.ndarray: """Loads and preconditions the audio at ``path`` for reconstruction. diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py index 96bbabec1..e69de29bb 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py @@ -1,17 +0,0 @@ -from .frame import assign_frame -from .models import ( - HierarchyMode, - Stem, - StemChoice, - StemFrameAssignment, - StemHierarchy, -) - -__all__ = [ - "HierarchyMode", - "Stem", - "StemChoice", - "StemFrameAssignment", - "StemHierarchy", - "assign_frame", -] diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py new file mode 100644 index 000000000..1fef36ffe --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -0,0 +1,36 @@ +from typing import List, Self + +from pydantic import ConfigDict, Field, model_validator + +from sampletones_core.constants.algorithm import ( + DEFAULT_STEMS_CHANNEL_CAP, +) +from sampletones_core.data import DataModel +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy + + +class StemsConfig(DataModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + entries: List[StemEntry] = Field( + default_factory=list, + description="The competing stems and the channels each may occupy", + ) + hierarchy: StemsHierarchy = Field( + default_factory=StemsHierarchy, + description="The precedence structure of the stems assignment", + ) + channel_cap: int = Field( + default=DEFAULT_STEMS_CHANNEL_CAP, + ge=1, + description="The most channels one stem holds per frame", + ) + + @model_validator(mode="after") + def _validate_unique_entry_ids(self) -> Self: + ids = [entry.id for entry in self.entries] + if len(set(ids)) != len(ids): + raise ValueError("Stem entries must have unique ids") + + return self diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py new file mode 100644 index 000000000..f9760fe51 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py @@ -0,0 +1,19 @@ +from typing import List + +from pydantic import ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.data import DataModel + + +class StemEntry(DataModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + id: int = Field( + ..., + description="Identifier of the stem the hierarchy references", + ) + channels: List[ChannelName] = Field( + ..., + description="The channels the stem may occupy", + ) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py new file mode 100644 index 000000000..842e8d8ac --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py @@ -0,0 +1,22 @@ +from typing import List + +from pydantic import ConfigDict, Field + +from sampletones_core.constants.algorithm import ( + DEFAULT_STEMS_HIERARCHY_MODE, +) +from sampletones_core.constants.enums import HierarchyMode +from sampletones_core.data import DataModel + + +class StemsHierarchy(DataModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + levels: List[List[int]] = Field( + default_factory=list, + description="Stem id levels, picked in the order listed", + ) + mode: HierarchyMode = Field( + default=DEFAULT_STEMS_HIERARCHY_MODE, + description="Whether levels alternate per round or exhaust in order", + ) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py new file mode 100644 index 000000000..afe96e67a --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import FrozenSet + +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class Stem: + """ + One audio source competing for channels in a reconstruction, identified by its + id and restricted to the channels it may occupy. + """ + + id: int + channels: FrozenSet[ChannelName] diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/frame.py index 071bb5b69..8689956e3 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/frame.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/frame.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional, Sequence, Set, Tuple -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import ( @@ -8,15 +8,11 @@ get_generator_by_instruction, get_remaining_generator_classes, ) - -from ..selector.matching import FrameMatcher -from .models import ( - HierarchyMode, - Stem, - StemChoice, - StemFrameAssignment, - StemHierarchy, -) +from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy def assign_frame( @@ -126,6 +122,7 @@ def run(self) -> StemFrameAssignment: self._round_robin() case HierarchyMode.STRICT: self._strict() + return StemFrameAssignment(tuple(self.choices)) def _round_robin(self) -> None: @@ -133,15 +130,22 @@ def _round_robin(self) -> None: for level in self.hierarchy.levels: if not self.free_channels: return + self._pick_from_level(level, repeat=False) def _strict(self) -> None: for level in self.hierarchy.levels: if not self.free_channels: return + self._pick_from_level(level, repeat=True) - def _pick_from_level(self, level: Tuple[int, ...], *, repeat: bool) -> None: + def _pick_from_level( + self, + level: Tuple[int, ...], + *, + repeat: bool, + ) -> None: picked_this_visit: Set[int] = set() while True: eligible = [ @@ -185,6 +189,7 @@ def _best_choice(self, stem_ids: Sequence[int]) -> Optional[StemChoice]: ) if best is None or choice.cost < best.cost: best = choice + return best def _remaining_channels( diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models.py b/src/sampletones_core/reconstructions/reconstructor/stems/models.py deleted file mode 100644 index 76eb161de..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/stems/models.py +++ /dev/null @@ -1,50 +0,0 @@ -from dataclasses import dataclass -from enum import StrEnum -from typing import Dict, FrozenSet, NamedTuple, Tuple - -from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import Fragment -from sampletones_core.instructions import InstructionUnion - - -class HierarchyMode(StrEnum): - ROUND_ROBIN = "round_robin" - STRICT = "strict" - - -@dataclass(frozen=True) -class Stem: - """ - One audio source competing for channels in a reconstruction, identified by its - id and restricted to the channels it may occupy. - """ - - id: int - channels: FrozenSet[ChannelName] - - -@dataclass(frozen=True) -class StemHierarchy: - """ - The precedence structure of a stems assignment: stems grouped into levels that - pick in order, with a mode choosing how picks alternate between levels. - """ - - levels: Tuple[Tuple[int, ...], ...] - mode: HierarchyMode - - -class StemChoice(NamedTuple): - stem_id: int - channel_name: ChannelName - instruction: InstructionUnion - approximation: Fragment - cost: float - - -class StemFrameAssignment(NamedTuple): - choices: Tuple[StemChoice, ...] - - @property - def by_channel(self) -> Dict[ChannelName, StemChoice]: - return {choice.channel_name: choice for choice in self.choices} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py new file mode 100644 index 000000000..a27133597 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py @@ -0,0 +1,13 @@ +from typing import NamedTuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment +from sampletones_core.instructions import InstructionUnion + + +class StemChoice(NamedTuple): + stem_id: int + channel_name: ChannelName + instruction: InstructionUnion + approximation: Fragment + cost: float diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py new file mode 100644 index 000000000..aaceee4b7 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py @@ -0,0 +1,12 @@ +from typing import Dict, NamedTuple, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice + + +class StemFrameAssignment(NamedTuple): + choices: Tuple[StemChoice, ...] + + @property + def by_channel(self) -> Dict[ChannelName, StemChoice]: + return {choice.channel_name: choice for choice in self.choices} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py new file mode 100644 index 000000000..b93ea04db --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import Tuple + +from sampletones_core.constants.enums import HierarchyMode + + +@dataclass(frozen=True) +class StemHierarchy: + """ + The precedence structure of a stems assignment: stems grouped into levels that + pick in order, with a mode choosing how picks alternate between levels. + """ + + levels: Tuple[Tuple[int, ...], ...] + mode: HierarchyMode diff --git a/tests/integration/reconstruction/__init__.py b/tests/integration/reconstruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py new file mode 100644 index 000000000..44dd36bc4 --- /dev/null +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -0,0 +1,87 @@ +from pathlib import Path +from typing import Final + +import numpy as np +import pytest + +from sampletones_core.audio import write_wave +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions import Reconstructor +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from tests.integration.assets.reconstruction import build_mini_library + +_TONE_FREQUENCY: Final[float] = 440.0 +_DURATION_SECONDS: Final[float] = 0.5 + + +def _stems_config() -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.NOISE]), + ], + hierarchy=StemsHierarchy( + levels=[[0], [1]], + mode=HierarchyMode.STRICT, + ), + channel_cap=1, + ) + + +class TestReconstructStems: + def test_assigns_disjoint_stems_to_their_channels(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + + sample_rate = config.library.sample_rate + count = int(sample_rate * _DURATION_SECONDS) + time = np.arange(count) / sample_rate + tone = 0.5 * np.sin(2 * np.pi * _TONE_FREQUENCY * time) + rng = np.random.default_rng(93) + noise = rng.uniform(-0.3, 0.3, count) + + tone_path = tmp_path / "tone.wav" + noise_path = tmp_path / "noise.wav" + write_wave(tone_path, sample_rate, tone) + write_wave(noise_path, sample_rate, noise) + + reconstruction = reconstructor.reconstruct_stems( + [tone_path, noise_path], + _stems_config(), + ) + + assert reconstruction is not None + assert reconstruction.audio_filepath == (tone_path, noise_path) + assert reconstruction.stems_data is not None + stems_data = reconstruction.stems_data + assert stems_data.config == _stems_config() + assert {entry.id for entry in stems_data.config.entries} == {0, 1} + + assignments = stems_data.assignments_by_channel + assert set(assignments) == { + ChannelName.PULSE1, + ChannelName.NOISE, + } + assert set(assignments[ChannelName.PULSE1]) == {0} + assert set(assignments[ChannelName.NOISE]) == {1} + + frame_count = count // config.library.frame_length + assert len(assignments[ChannelName.PULSE1]) == frame_count + assert len(assignments[ChannelName.NOISE]) == frame_count + assert len(reconstruction.instructions[ChannelName.PULSE1]) == frame_count + assert len(reconstruction.instructions[ChannelName.NOISE]) == frame_count + + def test_requires_one_path_per_entry(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + + with pytest.raises(ValueError, match="stem paths"): + reconstructor.reconstruct_stems( + [tmp_path / "only_one.wav"], + _stems_config(), + ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index e08badd43..f9fdc5e62 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -8,12 +8,19 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ChannelName, FeatureKey, HierarchyMode from sampletones_core.data import Metadata from sampletones_core.features import resting_reference from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.stems.data import ( + ChannelAssignment, + StemsData, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) @@ -71,6 +78,67 @@ def _saved_playing_channels_only(path: Path) -> Path: return path +class TestStemsDataRoundTrip: + def test_stems_data_survives_save_and_load(self, tmp_path: Path) -> None: + stems_config = StemsConfig( + entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])], + hierarchy=StemsHierarchy(levels=[[0]], mode=HierarchyMode.STRICT), + channel_cap=1, + ) + stems_data = StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0, 0], + ) + ], + ) + reconstruction = Reconstruction.create( + approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), + approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH), _pulse(_BASE_PITCH)]}, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + stems_data=stems_data, + ) + path = tmp_path / "stems.stn" + reconstruction.save(path) + + loaded = Reconstruction.load(path) + + assert loaded.stems_data == stems_data + + def test_load_without_stems_data_keeps_none(self, tmp_path: Path) -> None: + path = tmp_path / "plain.stn" + _reconstruction([_pulse(_BASE_PITCH)]).save(path) + + loaded = Reconstruction.load(path) + + assert loaded.stems_data is None + + def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> None: + stem_paths = ( + Path("/dev/null/stem_a.wav"), + Path("/dev/null/stem_b.wav"), + ) + reconstruction = Reconstruction.create( + approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), + approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH)]}, + config=Config(), + coefficient=1.0, + audio_filepath=stem_paths, + ) + path = tmp_path / "stems_paths.stn" + reconstruction.save(path) + + loaded = Reconstruction.load(path) + + assert loaded.audio_filepath == stem_paths + + class TestRoundTrip: def test_save_load_round_trip( self, diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py new file mode 100644 index 000000000..290ea6ebd --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py @@ -0,0 +1,48 @@ +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy + + +def _stems_config() -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.NOISE]), + ], + hierarchy=StemsHierarchy(levels=[[0], [1]], mode=HierarchyMode.STRICT), + channel_cap=DEFAULT_STEMS_CHANNEL_CAP, + ) + + +class TestStemsConfig: + def test_defaults_to_an_empty_assignment(self) -> None: + stems = StemsConfig() + assert stems.entries == [] + assert stems.hierarchy.levels == [] + assert stems.hierarchy.mode == HierarchyMode.ROUND_ROBIN + assert stems.channel_cap == DEFAULT_STEMS_CHANNEL_CAP + + def test_duplicate_entry_ids_raise(self) -> None: + with pytest.raises(ValidationError): + StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=0, channels=[ChannelName.NOISE]), + ], + ) + + def test_channel_cap_below_one_raises(self) -> None: + with pytest.raises(ValidationError): + StemsConfig(channel_cap=0) + + def test_fields(self) -> None: + stems = _stems_config() + assert [entry.id for entry in stems.entries] == [0, 1] + assert stems.hierarchy.levels == [[0], [1]] + assert stems.hierarchy.mode == HierarchyMode.STRICT + assert stems.channel_cap == DEFAULT_STEMS_CHANNEL_CAP diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py index d3940f155..669f57b77 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -4,7 +4,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.fft import Fragment, Window from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion @@ -12,14 +12,11 @@ from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData from sampletones_core.reconstructions.reconstructor.selector.greedy import GreedySelector from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher -from sampletones_core.reconstructions.reconstructor.stems import ( - HierarchyMode, - Stem, - StemChoice, - StemFrameAssignment, - StemHierarchy, - assign_frame, -) +from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem +from sampletones_core.reconstructions.reconstructor.stems.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py index 298466d23..9a730f629 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -2,19 +2,16 @@ import pytest -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher -from sampletones_core.reconstructions.reconstructor.stems import ( - HierarchyMode, - Stem, - StemChoice, - StemFrameAssignment, - StemHierarchy, - assign_frame, -) +from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem +from sampletones_core.reconstructions.reconstructor.stems.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy DEFAULT_CHANNELS: FrozenSet[ChannelName] = frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE)) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py index 1787c2394..bcb985338 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py @@ -1,11 +1,8 @@ from typing import Final, FrozenSet -from sampletones_core.constants.enums import ChannelName -from sampletones_core.reconstructions.reconstructor.stems import ( - HierarchyMode, - Stem, - StemHierarchy, -) +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem +from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy PULSE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset((ChannelName.PULSE1, ChannelName.PULSE2)) From 57dada86f0356c2913fa1b48eccd7bce599a0cc9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 14:00:00 +0200 Subject: [PATCH 021/142] Added: the NSF file export --- Makefile | 7 +- docs/development/packages.md | 3 +- scripts/checks/import_boundary.py | 1 + src/sampletones_player/builder.py | 129 +++++++++++++ src/sampletones_player/nsf/file.py | 48 +++++ src/sampletones_player/nsf/header.py | 72 +++++++ src/sampletones_player/nsf/information.py | 22 +++ .../specification/binary.py | 3 + src/sampletones_player/specification/nsf.py | 40 ++++ src/sampletones_player/specification/song.py | 3 +- src/sampletones_shared/paths/extensions.py | 1 + tests/integration/nsf/__init__.py | 0 tests/integration/nsf/conftest.py | 48 +++++ tests/integration/nsf/test_nsf_pipeline.py | 155 +++++++++++++++ tests/integration/paths.py | 2 + tests/suite/player.py | 33 +++- .../unit/sampletones_player/nsf/test_file.py | 104 ++++++++++ .../sampletones_player/nsf/test_header.py | 179 ++++++++++++++++++ .../unit/sampletones_player/nsf/test_song.py | 2 +- tests/unit/sampletones_player/test_builder.py | 131 +++++++++++++ 20 files changed, 976 insertions(+), 7 deletions(-) create mode 100644 src/sampletones_player/builder.py create mode 100644 src/sampletones_player/nsf/file.py create mode 100644 src/sampletones_player/nsf/header.py create mode 100644 src/sampletones_player/nsf/information.py create mode 100644 src/sampletones_player/specification/binary.py create mode 100644 tests/integration/nsf/__init__.py create mode 100644 tests/integration/nsf/conftest.py create mode 100644 tests/integration/nsf/test_nsf_pipeline.py create mode 100644 tests/unit/sampletones_player/nsf/test_file.py create mode 100644 tests/unit/sampletones_player/nsf/test_header.py create mode 100644 tests/unit/sampletones_player/test_builder.py diff --git a/Makefile b/Makefile index 6ae9b288a..86825df4a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples icons player check-import-boundary check-tag-names check-unused-tags \ + ftm-samples nsf-samples icons player check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -70,6 +70,7 @@ help: @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) + @echo $(Q) make nsf-samples - Emit example .nsf files to build/nsf via the integration suite$(Q) @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make player - Assemble the NES player driver with cc65$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @@ -112,6 +113,10 @@ ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: uv run python -m pytest tests/integration/famitracker +nsf-samples: export SAMPLETONES_NSF_OUTPUT_DIR := build/nsf +nsf-samples: + uv run python -m pytest tests/integration/nsf + icons: uv run --group assets python scripts/assets/icons.py diff --git a/docs/development/packages.md b/docs/development/packages.md index ff0cc33f9..565a3de94 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -76,8 +76,9 @@ them. | `clock/` | `PlaySchedule` and `FixedPointStep` — the engine ticks one play call advances a stream by | `specification/` | | `registers/` | The per-tick register values each channel plays, and the four streams together | `specification/` | | `song.py` | `Song` — the streams, the schedule and the loop point as one value | `clock/`, `registers/` | +| `builder.py` | The song a reconstruction plays as, its instructions encoded and its rate scheduled | `song.py`, `registers/`, `clock/` | | `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` | -| `nsf/` | The song block and the NSF file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | +| `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | | `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` | diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index a80d41e54..7eb4af750 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -70,6 +70,7 @@ "clock": ("specification",), "registers": ("specification",), "song.py": ("clock", "registers"), + "builder.py": ("song.py", "registers", "clock"), "trace": ("song.py", "specification"), "nsf": ("song.py", "registers", "specification", "driver"), "driver": ("specification",), diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py new file mode 100644 index 000000000..82bfa3324 --- /dev/null +++ b/src/sampletones_player/builder.py @@ -0,0 +1,129 @@ +from typing import Dict, List, Mapping, Optional, Sequence, Type + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.instructions import ( + InstructionT, + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.registers.triangle import TriangleRegisters +from sampletones_player.song import Song + + +def channel_instructions( + instructions: Sequence[InstructionUnion], + instruction_type: Type[InstructionT], +) -> List[InstructionT]: + """One channel's stream, read as the instruction type that channel sounds. + + A reconstruction holds a stream for every channel, and a channel standing by holds one + describing no frame. Such a channel reaches the player resting for a single tick, which is + the shortest stream a song lays its records out from. + + Args: + instructions: The channel's stream, as the reconstruction holds it. + instruction_type: The instruction type the channel's encoder reads. + + Returns: + List[InstructionT]: The stream, covering at least one tick. + + Raises: + ValueError: If the stream holds an instruction another channel sounds. + """ + typed: List[InstructionT] = [] + for instruction in instructions: + if not isinstance(instruction, instruction_type): + raise TypeError( + f"a {instruction_type.__name__} stream holds {type(instruction).__name__} " + f"{instruction.name}, which another channel sounds" + ) + + typed.append(instruction) + + if typed: + return typed + + resting: InstructionT = instruction_type.null_instruction() + return [resting] + + +def streams_from_instructions( + instructions: Mapping[GeneratorName, Sequence[InstructionUnion]], + timer_table: Dict[int, int], +) -> ChannelStreams: + """Encodes every channel's instructions into the register values its ticks write. + + The song covers all four channels, so a channel the mapping leaves out rests through it, + the same as one whose stream describes no frame. + + Args: + instructions: The stream each channel carries. + timer_table: The timer register value each pitch sounds at. + + Returns: + ChannelStreams: The four streams the driver plays. + + Raises: + ValueError: If a channel's stream holds an instruction another channel sounds. + """ + pulse1 = channel_instructions( + instructions.get(GeneratorName.PULSE1, ()), + PulseInstruction, + ) + pulse2 = channel_instructions( + instructions.get(GeneratorName.PULSE2, ()), + PulseInstruction, + ) + triangle = channel_instructions( + instructions.get(GeneratorName.TRIANGLE, ()), + TriangleInstruction, + ) + noise = channel_instructions( + instructions.get(GeneratorName.NOISE, ()), + NoiseInstruction, + ) + + return ChannelStreams( + pulse1=tuple(PulseRegisters.from_instructions(pulse1, timer_table)), + pulse2=tuple(PulseRegisters.from_instructions(pulse2, timer_table)), + triangle=tuple(TriangleRegisters.from_instructions(triangle, timer_table)), + noise=tuple(NoiseRegisters.from_instructions(noise)), + ) + + +def song_from_reconstruction( + reconstruction: Reconstruction, + loop_tick: Optional[int], +) -> Song: + """Builds the song the console plays a reconstruction as. + + The reconstruction's own configuration carries both halves of the answer: the pitches it was + built against become timers through the very table its generators render from, and the rate + it was built at becomes the schedule the driver re-clocks the streams by. + + Args: + reconstruction: The reconstruction to play. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + + Returns: + Song: The streams, the clock and the loop point as the player holds them. + + Raises: + ValueError: If ``loop_tick`` lies outside the song's ticks. + """ + return Song( + streams=streams_from_instructions( + reconstruction.instructions, + get_timer_table(reconstruction.config), + ), + schedule=PlaySchedule.from_parameters(reconstruction.config.nes_frequency), + loop_tick=loop_tick, + ) diff --git a/src/sampletones_player/nsf/file.py b/src/sampletones_player/nsf/file.py new file mode 100644 index 000000000..b9b597be6 --- /dev/null +++ b/src/sampletones_player/nsf/file.py @@ -0,0 +1,48 @@ +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.header import header_to_bytes +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.nsf import PROGRAM_SIZE +from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.serialization import save_binary + + +def nsf_to_bytes(song: Song, information: NSFInformation) -> bytes: + """Builds the bytes of a playable NSF: the header, the driver and the song it plays. + + The three parts sit in the order the console loads them, the song following the driver at + the address the image reports, so the space the song has to fit in is what the program area + leaves behind the code. + + Args: + song: The streams, the clock and the loop point to play. + information: The text fields the file is listed under. + + Returns: + bytes: The whole file. + + Raises: + SongTooLargeError: If the song takes more room than the driver leaves it. + ValueError: If the committed driver lays out something other than the addresses it is + built to answer at. + """ + image = DriverImage.load() + data = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) + + return header_to_bytes(information, image.addresses) + image.code + data + + +def write_nsf(filepath: Pathlike, song: Song, information: NSFInformation) -> None: + """Exports a song to a playable ``.nsf`` file. + + Args: + filepath: The file to write. + song: The streams, the clock and the loop point to play. + information: The text fields the file is listed under. + + Raises: + SongTooLargeError: If the song takes more room than the driver leaves it. + OSError: If the destination cannot be written. + """ + save_binary(filepath, nsf_to_bytes(song, information)) diff --git a/src/sampletones_player/nsf/header.py b/src/sampletones_player/nsf/header.py new file mode 100644 index 000000000..79b82b5ae --- /dev/null +++ b/src/sampletones_player/nsf/header.py @@ -0,0 +1,72 @@ +from sampletones_core.formats.binary import BinaryWriter +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.specification.clock import PLAY_PERIOD_MICROSECONDS +from sampletones_player.specification.nsf import ( + FIRST_SONG, + NO_BANKSWITCHING, + NO_EXPANSION_CHIPS, + NO_NSF2_FEATURES, + NSF2_LENGTH_UNSTATED, + NSF_MAGIC, + NSF_VERSION, + NTSC_REGION, + PAL_PLAY_PERIOD_MICROSECONDS, + SONG_COUNT, + STRING_FIELD_SIZE, +) + + +def _write_identity(writer: BinaryWriter) -> None: + writer.write_bytes(NSF_MAGIC) + writer.write_uint8(NSF_VERSION) + writer.write_uint8(SONG_COUNT) + writer.write_uint8(FIRST_SONG) + + +def _write_routines(writer: BinaryWriter, addresses: DriverAddresses) -> None: + writer.write_uint16(addresses.load) + writer.write_uint16(addresses.init) + writer.write_uint16(addresses.play) + + +def _write_strings(writer: BinaryWriter, information: NSFInformation) -> None: + writer.write_fixed_string(information.title, STRING_FIELD_SIZE) + writer.write_fixed_string(information.artist, STRING_FIELD_SIZE) + writer.write_fixed_string(information.copyright, STRING_FIELD_SIZE) + + +def _write_playback(writer: BinaryWriter) -> None: + writer.write_uint16(PLAY_PERIOD_MICROSECONDS) + writer.write_bytes(NO_BANKSWITCHING) + writer.write_uint16(PAL_PLAY_PERIOD_MICROSECONDS) + writer.write_uint8(NTSC_REGION) + writer.write_uint8(NO_EXPANSION_CHIPS) + writer.write_uint8(NO_NSF2_FEATURES) + writer.write_bytes(NSF2_LENGTH_UNSTATED) + + +def header_to_bytes( + information: NSFInformation, + addresses: DriverAddresses, +) -> bytes: + """Serializes the 128-byte header a console's NSF player reads a file through. + + The header states where the image loads and which routines start and drive it, so the whole + of what the console needs to run the driver is named here. The song is one tune played on + the 2A03 alone, loaded whole at a fixed address and driven at the NTSC rate the schedule + counts in. + + Args: + information: The text fields the file is listed under. + addresses: Where the driver loads and which addresses its routines answer at. + + Returns: + bytes: The header, ready for the image and the song to follow it. + """ + writer = BinaryWriter() + _write_identity(writer) + _write_routines(writer, addresses) + _write_strings(writer, information) + _write_playback(writer) + return writer.data diff --git a/src/sampletones_player/nsf/information.py b/src/sampletones_player/nsf/information.py new file mode 100644 index 000000000..c89ff8c1f --- /dev/null +++ b/src/sampletones_player/nsf/information.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict + +from sampletones_player.specification.nsf import DEFAULT_COPYRIGHT + + +class NSFInformation(BaseModel): + """The three text fields an NSF header carries, shown by the players that read them. + + Each field reaches the file as a fixed 32-byte string, so text longer than the field holds + is written as much of itself as fits. + + Attributes: + title: Name the song is listed under. + artist: Who the song is credited to. + copyright: Who holds the rights to it. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + title: str + artist: str + copyright: str = DEFAULT_COPYRIGHT diff --git a/src/sampletones_player/specification/binary.py b/src/sampletones_player/specification/binary.py new file mode 100644 index 000000000..19a0c4961 --- /dev/null +++ b/src/sampletones_player/specification/binary.py @@ -0,0 +1,3 @@ +from typing import Final + +WORD_SIZE: Final[int] = 2 diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py index e770f79f7..808032f7e 100644 --- a/src/sampletones_player/specification/nsf.py +++ b/src/sampletones_player/specification/nsf.py @@ -1,4 +1,44 @@ from typing import Final +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_shared.application import SAMPLETONES_NAME + PROGRAM_START: Final[int] = 0x8000 PROGRAM_SIZE: Final[int] = 0x8000 + +NSF_MAGIC: Final[bytes] = b"NESM\x1a" +NSF_VERSION: Final[int] = 1 +SONG_COUNT: Final[int] = 1 +FIRST_SONG: Final[int] = 1 + +STRING_FIELD_SIZE: Final[int] = 32 +BANKSWITCH_SIZE: Final[int] = 8 +NSF2_LENGTH_SIZE: Final[int] = 3 + +MAGIC_OFFSET: Final[int] = 0 +VERSION_OFFSET: Final[int] = MAGIC_OFFSET + len(NSF_MAGIC) +SONG_COUNT_OFFSET: Final[int] = VERSION_OFFSET + 1 +FIRST_SONG_OFFSET: Final[int] = SONG_COUNT_OFFSET + 1 +LOAD_ADDRESS_OFFSET: Final[int] = FIRST_SONG_OFFSET + 1 +INIT_ADDRESS_OFFSET: Final[int] = LOAD_ADDRESS_OFFSET + WORD_SIZE +PLAY_ADDRESS_OFFSET: Final[int] = INIT_ADDRESS_OFFSET + WORD_SIZE +TITLE_OFFSET: Final[int] = PLAY_ADDRESS_OFFSET + WORD_SIZE +ARTIST_OFFSET: Final[int] = TITLE_OFFSET + STRING_FIELD_SIZE +COPYRIGHT_OFFSET: Final[int] = ARTIST_OFFSET + STRING_FIELD_SIZE +NTSC_PERIOD_OFFSET: Final[int] = COPYRIGHT_OFFSET + STRING_FIELD_SIZE +BANKSWITCH_OFFSET: Final[int] = NTSC_PERIOD_OFFSET + WORD_SIZE +PAL_PERIOD_OFFSET: Final[int] = BANKSWITCH_OFFSET + BANKSWITCH_SIZE +REGION_OFFSET: Final[int] = PAL_PERIOD_OFFSET + WORD_SIZE +EXPANSION_OFFSET: Final[int] = REGION_OFFSET + 1 +NSF2_FEATURES_OFFSET: Final[int] = EXPANSION_OFFSET + 1 +NSF2_LENGTH_OFFSET: Final[int] = NSF2_FEATURES_OFFSET + 1 +HEADER_SIZE: Final[int] = NSF2_LENGTH_OFFSET + NSF2_LENGTH_SIZE + +PAL_PLAY_PERIOD_MICROSECONDS: Final[int] = 20000 +NTSC_REGION: Final[int] = 0x00 +NO_EXPANSION_CHIPS: Final[int] = 0x00 +NO_NSF2_FEATURES: Final[int] = 0x00 +NO_BANKSWITCHING: Final[bytes] = bytes(BANKSWITCH_SIZE) +NSF2_LENGTH_UNSTATED: Final[bytes] = bytes(NSF2_LENGTH_SIZE) + +DEFAULT_COPYRIGHT: Final[str] = f"generated by {SAMPLETONES_NAME}" diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py index 524dd4644..60909c1b7 100644 --- a/src/sampletones_player/specification/song.py +++ b/src/sampletones_player/specification/song.py @@ -1,8 +1,7 @@ from typing import Final from sampletones_core.constants.enums import GeneratorName - -WORD_SIZE: Final[int] = 2 +from sampletones_player.specification.binary import WORD_SIZE STEP_WHOLE_OFFSET: Final[int] = 0 STEP_FRACTION_OFFSET: Final[int] = STEP_WHOLE_OFFSET + 1 diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py index 857c1c911..783e3e5d2 100644 --- a/src/sampletones_shared/paths/extensions.py +++ b/src/sampletones_shared/paths/extensions.py @@ -8,6 +8,7 @@ EXT_FILE_PROJECT: Final[str] = ".stp" EXT_FILE_MODULE: Final[str] = ".ftm" EXT_FILE_BITPHASE: Final[str] = ".btp" +EXT_FILE_NSF: Final[str] = ".nsf" EXT_FILE_WAVE: Final[str] = ".wav" EXT_FILE_MP3: Final[str] = ".mp3" EXT_FILE_FLAC: Final[str] = ".flac" diff --git a/tests/integration/nsf/__init__.py b/tests/integration/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/nsf/conftest.py b/tests/integration/nsf/conftest.py new file mode 100644 index 000000000..f8edf209d --- /dev/null +++ b/tests/integration/nsf/conftest.py @@ -0,0 +1,48 @@ +from pathlib import Path +from typing import Dict, Final, Optional + +import pytest + +from sampletones_core.project.instruments.sample import Sample +from sampletones_player.builder import song_from_reconstruction +from sampletones_player.driver.image import DriverImage +from sampletones_player.song import Song +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.integration.output import resolve_output_directory, resolve_output_path +from tests.integration.paths import NSF_OUTPUT_ENV + +EXPORTED_SAMPLE: Final[str] = "lead" + + +@pytest.fixture(scope="session") +def nsf_output_dir() -> Optional[Path]: + """The persistent output directory ``SAMPLETONES_NSF_OUTPUT_DIR`` names.""" + return resolve_output_directory(NSF_OUTPUT_ENV) + + +@pytest.fixture +def nsf_paths( + nsf_output_dir: Optional[Path], + tmp_path: Path, + instrument_catalog: Dict[str, Sample], +) -> Dict[str, Path]: + """Where each sample's produced ``.nsf`` is written.""" + return {name: resolve_output_path(nsf_output_dir, tmp_path, f"{name}{EXT_FILE_NSF}") for name in instrument_catalog} + + +@pytest.fixture(scope="session") +def driver_image() -> DriverImage: + """The assembled driver every exported file carries.""" + return DriverImage.load() + + +@pytest.fixture +def sample(instrument_catalog: Dict[str, Sample]) -> Sample: + """The sample the structural cases read, covering both pulse channels.""" + return instrument_catalog[EXPORTED_SAMPLE] + + +@pytest.fixture +def song(sample: Sample) -> Song: + """The song the console plays that sample as.""" + return song_from_reconstruction(sample.reconstruction, loop_tick=None) diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py new file mode 100644 index 000000000..2906b7abc --- /dev/null +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -0,0 +1,155 @@ +import struct +from pathlib import Path +from typing import Dict, Final, Tuple + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.reconstructions import Reconstruction +from sampletones_player.builder import song_from_reconstruction +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.file import write_nsf +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.nsf import ( + HEADER_SIZE, + NSF_MAGIC, + PROGRAM_SIZE, +) +from sampletones_player.specification.song import ( + LOOP_TICK_OFFSET, + NO_LOOP, + SONG_HEADER_SIZE, + STEP_FRACTION_OFFSET, + STEP_WHOLE_OFFSET, + STREAM_OFFSETS_OFFSET, + TOTAL_TICKS_OFFSET, +) +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION + +ARTIST: Final[str] = "Integration" + + +def exported_information(name: str) -> NSFInformation: + return NSFInformation(title=name, artist=ARTIST) + + +def song_block(data: bytes, image: DriverImage) -> bytes: + return data[HEADER_SIZE + len(image.code) :] + + +def read_word(data: bytes, offset: int) -> int: + return int(struct.unpack_from(" Tuple[int, ...]: + return tuple(read_word(block, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(GeneratorName))) + + +@pytest.fixture +def exported(song: Song, sample: Sample, nsf_paths: Dict[str, Path]) -> bytes: + destination = nsf_paths[sample.name] + write_nsf(destination, song, exported_information(sample.name)) + return destination.read_bytes() + + +class TestNsfPipeline: + """End-to-end: synthesized and reconstructed samples -> `Song` -> a playable `.nsf`.""" + + def test_every_sample_reaches_a_playable_file( + self, + instrument_catalog: Dict[str, Sample], + nsf_paths: Dict[str, Path], + ) -> None: + for name, sample in instrument_catalog.items(): + song = song_from_reconstruction(sample.reconstruction, loop_tick=None) + write_nsf(nsf_paths[name], song, exported_information(name)) + assert nsf_paths[name].read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC + + def test_the_file_carries_the_shipped_driver(self, exported: bytes, driver_image: DriverImage) -> None: + assert exported[HEADER_SIZE : HEADER_SIZE + len(driver_image.code)] == driver_image.code + + def test_the_loaded_image_fits_the_program_area(self, exported: bytes) -> None: + assert len(exported) - HEADER_SIZE <= PROGRAM_SIZE + + def test_the_song_follows_the_driver_whole( + self, + exported: bytes, + song: Song, + driver_image: DriverImage, + ) -> None: + available = PROGRAM_SIZE - len(driver_image.code) + assert song_block(exported, driver_image) == song_to_bytes(song, available) + + +class TestTheSongTheFileCarries: + """What the driver reads out of the block behind it.""" + + def test_the_song_states_the_ticks_the_reconstruction_covers( + self, + exported: bytes, + song: Song, + driver_image: DriverImage, + ) -> None: + assert read_word(song_block(exported, driver_image), TOTAL_TICKS_OFFSET) == song.ticks + + def test_the_song_states_the_rate_it_was_built_at( + self, + exported: bytes, + song: Song, + driver_image: DriverImage, + ) -> None: + block = song_block(exported, driver_image) + step = song.schedule.fixed_point_step + assert (block[STEP_WHOLE_OFFSET], read_word(block, STEP_FRACTION_OFFSET)) == (step.whole, step.fraction) + + def test_a_sample_that_ends_stops_there( + self, + exported: bytes, + driver_image: DriverImage, + ) -> None: + block = song_block(exported, driver_image) + assert read_word(block, LOOP_TICK_OFFSET) == NO_LOOP + + def test_every_stream_begins_inside_the_block( + self, + exported: bytes, + driver_image: DriverImage, + ) -> None: + block = song_block(exported, driver_image) + offsets = stream_offsets(block) + assert offsets[0] == SONG_HEADER_SIZE + assert all(offset < len(block) for offset in offsets) + + def test_the_streams_stand_in_channel_order( + self, + exported: bytes, + driver_image: DriverImage, + ) -> None: + offsets = stream_offsets(song_block(exported, driver_image)) + assert list(offsets) == sorted(offsets) + + +class TestAStoredReconstructionExportsTheSameFile: + """A reconstruction saved and read back plays as the very file it played as before.""" + + def test_the_round_trip_leaves_the_exported_bytes_alone( + self, + sample: Sample, + song: Song, + tmp_path: Path, + ) -> None: + stored = tmp_path / f"{sample.name}{EXT_FILE_RECONSTRUCTION}" + sample.reconstruction.save(stored) + reloaded = song_from_reconstruction(Reconstruction.load(stored), loop_tick=None) + + information = exported_information(sample.name) + before = tmp_path / "before.nsf" + after = tmp_path / "after.nsf" + write_nsf(before, song, information) + write_nsf(after, reloaded, information) + + assert after.read_bytes() == before.read_bytes() diff --git a/tests/integration/paths.py b/tests/integration/paths.py index dc63ef711..0b015e24d 100644 --- a/tests/integration/paths.py +++ b/tests/integration/paths.py @@ -25,3 +25,5 @@ def _repo_root() -> Path: DOCUMENT_FILENAME: Final[str] = "drums.btp" GROOVE_DOCUMENT_FILENAME: Final[str] = "drums-groove.btp" BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR" + +NSF_OUTPUT_ENV: Final[str] = "SAMPLETONES_NSF_OUTPUT_DIR" diff --git a/tests/suite/player.py b/tests/suite/player.py index d78a92067..da18e1458 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -1,7 +1,14 @@ -from typing import Dict, Final, Optional, Sequence +import os +from pathlib import Path +from typing import Dict, Final, List, Optional, Sequence +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH -from sampletones_core.instructions import PulseInstruction +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.arithmetic import frequency_to_timer from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.registers.noise import NoiseRegisters @@ -121,3 +128,25 @@ def sounding_pulse( def silent_pulse() -> PulseInstruction: return PulseInstruction.null_instruction() + + +PLAYER_APPROXIMATION_SAMPLES: Final[int] = 64 + + +def player_reconstruction( + instructions: Dict[GeneratorName, List[InstructionUnion]], + nes_frequency: int, +) -> Reconstruction: + """A reconstruction carrying the given channel streams, built at ``nes_frequency``. + + The audio itself is silent, since what a player test reads off a reconstruction is the + instructions its channels carry and the rate they advance at. + """ + return Reconstruction.create( + approximation=np.zeros(PLAYER_APPROXIMATION_SAMPLES, dtype=np.float32), + approximations={}, + instructions=instructions, + config=Config().with_library(nes_frequency=nes_frequency), + coefficient=1.0, + audio_filepath=Path(os.devnull), + ) diff --git a/tests/unit/sampletones_player/nsf/test_file.py b/tests/unit/sampletones_player/nsf/test_file.py new file mode 100644 index 000000000..98375dca8 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_file.py @@ -0,0 +1,104 @@ +import struct +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.file import nsf_to_bytes, write_nsf +from sampletones_player.nsf.header import header_to_bytes +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.nsf import ( + HEADER_SIZE, + LOAD_ADDRESS_OFFSET, + PROGRAM_SIZE, +) +from sampletones_shared.exceptions import SongTooLargeError +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + player_song, + pulse_tick, + resting_streams, +) + +NTSC_FREQUENCY: Final[int] = 60 +FILENAME: Final[str] = "song.nsf" +INFORMATION: Final[NSFInformation] = NSFInformation(title="Amen", artist="Jakim") + +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) + + +@pytest.fixture(scope="module") +def image() -> DriverImage: + return DriverImage.load() + + +@pytest.fixture +def song() -> Song: + return player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + + +def oversized_song(image: DriverImage) -> Song: + ticks = PROGRAM_SIZE - len(image.code) + return player_song(resting_streams((SOUNDING,) * ticks), NTSC_FREQUENCY, loop_tick=None) + + +class TestNSFBytes: + """The three parts a console loads, in the order it loads them.""" + + def test_the_file_leads_with_its_header(self, song: Song, image: DriverImage) -> None: + assert nsf_to_bytes(song, INFORMATION)[:HEADER_SIZE] == header_to_bytes(INFORMATION, image.addresses) + + def test_the_driver_follows_the_header(self, song: Song, image: DriverImage) -> None: + assert nsf_to_bytes(song, INFORMATION)[HEADER_SIZE : HEADER_SIZE + len(image.code)] == image.code + + def test_the_song_follows_the_driver(self, song: Song, image: DriverImage) -> None: + data = nsf_to_bytes(song, INFORMATION) + assert data[HEADER_SIZE + len(image.code) :] == song_to_bytes(song, PROGRAM_SIZE - len(image.code)) + + def test_the_file_is_its_three_parts_and_nothing_more(self, song: Song, image: DriverImage) -> None: + block = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) + assert len(nsf_to_bytes(song, INFORMATION)) == HEADER_SIZE + len(image.code) + len(block) + + def test_the_loaded_image_fits_the_program_area(self, song: Song) -> None: + assert len(nsf_to_bytes(song, INFORMATION)) - HEADER_SIZE <= PROGRAM_SIZE + + def test_the_header_loads_the_image_where_the_driver_expects_it( + self, + song: Song, + image: DriverImage, + ) -> None: + data = nsf_to_bytes(song, INFORMATION) + assert struct.unpack_from(" None: + data = nsf_to_bytes(song, INFORMATION) + block = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) + song_start = len(data) - len(block) + assert image.addresses.load + song_start - HEADER_SIZE == image.addresses.song + + +class TestSongsBeyondTheProgramArea: + """A song outgrowing the room behind the driver names the overflow.""" + + def test_a_song_too_large_for_the_program_area_raises(self, image: DriverImage) -> None: + with pytest.raises(SongTooLargeError): + nsf_to_bytes(oversized_song(image), INFORMATION) + + +class TestWriteNSF: + """The bytes reaching a file on disk.""" + + def test_the_file_holds_the_bytes_the_song_serialises_to(self, song: Song, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + write_nsf(destination, song, INFORMATION) + assert destination.read_bytes() == nsf_to_bytes(song, INFORMATION) diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py new file mode 100644 index 000000000..b83fe65d2 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -0,0 +1,179 @@ +import struct +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.nsf.header import header_to_bytes +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.specification.clock import PLAY_PERIOD_MICROSECONDS +from sampletones_player.specification.nsf import ( + ARTIST_OFFSET, + BANKSWITCH_OFFSET, + BANKSWITCH_SIZE, + COPYRIGHT_OFFSET, + DEFAULT_COPYRIGHT, + EXPANSION_OFFSET, + FIRST_SONG, + FIRST_SONG_OFFSET, + HEADER_SIZE, + INIT_ADDRESS_OFFSET, + LOAD_ADDRESS_OFFSET, + MAGIC_OFFSET, + NO_EXPANSION_CHIPS, + NO_NSF2_FEATURES, + NSF2_FEATURES_OFFSET, + NSF2_LENGTH_OFFSET, + NSF2_LENGTH_SIZE, + NSF_MAGIC, + NSF_VERSION, + NTSC_PERIOD_OFFSET, + NTSC_REGION, + PAL_PERIOD_OFFSET, + PAL_PLAY_PERIOD_MICROSECONDS, + PLAY_ADDRESS_OFFSET, + REGION_OFFSET, + SONG_COUNT, + SONG_COUNT_OFFSET, + STRING_FIELD_SIZE, + TITLE_OFFSET, + VERSION_OFFSET, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +SONG_ADDRESS: Final[int] = 0x8153 +TITLE: Final[str] = "Amen" +ARTIST: Final[str] = "Jakim" +ADDRESSES: Final[DriverAddresses] = DriverAddresses(song=SONG_ADDRESS) +INFORMATION: Final[NSFInformation] = NSFInformation(title=TITLE, artist=ARTIST) + + +def header() -> bytes: + return header_to_bytes(INFORMATION, ADDRESSES) + + +def read_word(data: bytes, offset: int) -> int: + return int(struct.unpack_from(" bytes: + return data[offset : offset + STRING_FIELD_SIZE] + + +class TestHeaderBytes: + """The exact bytes an NSF header serialises to. + + The layout is what every console player reads a file through, so the literal states it in + full: the identity, the three addresses, the three text fields, and the playback fields + behind them. + """ + + EXPECTED: Final[bytes] = ( + NSF_MAGIC + + b"\x01\x01\x01" + + b"\x00\x80\x00\x80\x03\x80" + + TITLE.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + + ARTIST.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + + DEFAULT_COPYRIGHT.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + + b"\x1a\x41" + + bytes(BANKSWITCH_SIZE) + + b"\x20\x4e" + + b"\x00\x00\x00" + + bytes(NSF2_LENGTH_SIZE) + ) + + def test_the_header_serialises_to_the_expected_bytes(self) -> None: + assert header() == self.EXPECTED + + def test_the_header_fills_the_program_area_it_precedes(self) -> None: + assert len(header()) == HEADER_SIZE + + +class TestHeaderIdentity: + """What names the file an NSF, and the one tune it carries.""" + + def test_the_header_leads_with_the_magic(self) -> None: + assert header()[MAGIC_OFFSET : MAGIC_OFFSET + len(NSF_MAGIC)] == NSF_MAGIC + + def test_the_header_states_its_version(self) -> None: + assert header()[VERSION_OFFSET] == NSF_VERSION + + def test_the_file_carries_one_tune(self) -> None: + assert header()[SONG_COUNT_OFFSET] == SONG_COUNT + + def test_the_tune_it_opens_on_is_the_one_it_carries(self) -> None: + assert header()[FIRST_SONG_OFFSET] == FIRST_SONG == SONG_COUNT + + +class TestHeaderRoutines: + """The addresses a console loads the image at and drives it through.""" + + def test_the_load_address_is_where_the_driver_loads(self) -> None: + assert read_word(header(), LOAD_ADDRESS_OFFSET) == ADDRESSES.load + + def test_the_init_address_is_the_drivers_own(self) -> None: + assert read_word(header(), INIT_ADDRESS_OFFSET) == ADDRESSES.init + + def test_the_play_address_is_the_drivers_own(self) -> None: + assert read_word(header(), PLAY_ADDRESS_OFFSET) == ADDRESSES.play + + +class TestHeaderStrings(BaseTestSuite): + """The three text fields, each written into a fixed field and padded out with NULs.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + offset: int + expected: str + + @property + def label(self) -> str: + return self.expected + + test_cases = ( + TestCase(offset=TITLE_OFFSET, expected=TITLE), + TestCase(offset=ARTIST_OFFSET, expected=ARTIST), + TestCase(offset=COPYRIGHT_OFFSET, expected=DEFAULT_COPYRIGHT), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_field_carries_its_text(self, test_case: TestCase) -> None: + field = read_string(header(), test_case.offset) + assert field == test_case.expected.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + + def test_text_longer_than_the_field_is_written_as_much_as_fits(self) -> None: + overlong = "A" * (STRING_FIELD_SIZE * 2) + data = header_to_bytes(NSFInformation(title=overlong, artist=ARTIST), ADDRESSES) + assert read_string(data, TITLE_OFFSET) == overlong.encode("utf-8")[:STRING_FIELD_SIZE] + + def test_the_fields_stand_back_to_back(self) -> None: + assert (ARTIST_OFFSET - TITLE_OFFSET, COPYRIGHT_OFFSET - ARTIST_OFFSET) == ( + STRING_FIELD_SIZE, + STRING_FIELD_SIZE, + ) + + +class TestHeaderPlayback: + """The rate the console drives the file at, and the hardware it asks for.""" + + def test_the_ntsc_period_is_the_one_the_schedule_counts_in(self) -> None: + assert read_word(header(), NTSC_PERIOD_OFFSET) == PLAY_PERIOD_MICROSECONDS + + def test_the_pal_period_states_the_fiftieth_of_a_second(self) -> None: + assert read_word(header(), PAL_PERIOD_OFFSET) == PAL_PLAY_PERIOD_MICROSECONDS + + def test_the_image_loads_whole(self) -> None: + assert header()[BANKSWITCH_OFFSET : BANKSWITCH_OFFSET + BANKSWITCH_SIZE] == bytes(BANKSWITCH_SIZE) + + def test_the_tune_is_ntsc(self) -> None: + assert header()[REGION_OFFSET] == NTSC_REGION + + def test_the_tune_plays_on_the_2a03_alone(self) -> None: + assert header()[EXPANSION_OFFSET] == NO_EXPANSION_CHIPS + + def test_the_header_states_the_first_nsf_version(self) -> None: + trailing: Tuple[int, ...] = tuple(header()[NSF2_LENGTH_OFFSET : NSF2_LENGTH_OFFSET + NSF2_LENGTH_SIZE]) + assert header()[NSF2_FEATURES_OFFSET] == NO_NSF2_FEATURES + assert trailing == (0,) * NSF2_LENGTH_SIZE diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 80da90062..06e013002 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -7,6 +7,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song +from sampletones_player.specification.binary import WORD_SIZE from sampletones_player.specification.song import ( LOOP_TICK_OFFSET, MAX_STREAM_OFFSET, @@ -16,7 +17,6 @@ STEP_WHOLE_OFFSET, STREAM_OFFSETS_OFFSET, TOTAL_TICKS_OFFSET, - WORD_SIZE, ) from sampletones_shared.exceptions import SongTooLargeError from tests.suite.base import BaseTestSuite diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py new file mode 100644 index 000000000..7793c5d41 --- /dev/null +++ b/tests/unit/sampletones_player/test_builder.py @@ -0,0 +1,131 @@ +from typing import Dict, Final, List + +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.builder import ( + channel_instructions, + song_from_reconstruction, + streams_from_instructions, +) +from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.specification.registers import ( + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SOUNDING_RELOAD, +) +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_REFERENCE_PITCH, + PLAYER_TIMER_TABLE, + player_reconstruction, + silent_pulse, + sounding_pulse, +) + +NTSC_FREQUENCY: Final[int] = 60 +HALF_RATE_FREQUENCY: Final[int] = 30 +SOUNDING_TICKS: Final[int] = 4 +BASS_PITCH: Final[int] = 45 +NOISE_PERIOD: Final[int] = 10 +NOISE_VOLUME: Final[int] = 8 + + +def melody() -> List[InstructionUnion]: + return [sounding_pulse(PLAYER_REFERENCE_PITCH, PLAYER_FULL_VOLUME, 0) for _ in range(SOUNDING_TICKS)] + + +def one_channel(generator: GeneratorName) -> Dict[GeneratorName, List[InstructionUnion]]: + return {generator: melody()} + + +class TestChannelInstructions: + """A channel's stream read as the instruction type its encoder takes.""" + + def test_a_stream_of_the_channels_own_type_passes_through(self) -> None: + instructions = melody() + assert channel_instructions(instructions, PulseInstruction) == instructions + + def test_a_channel_describing_no_frame_rests_for_a_tick(self) -> None: + assert channel_instructions([], PulseInstruction) == [PulseInstruction.null_instruction()] + + def test_a_resting_channel_rests_in_its_own_type(self) -> None: + assert channel_instructions([], NoiseInstruction) == [NoiseInstruction.null_instruction()] + + def test_a_stream_of_another_channels_type_raises(self) -> None: + with pytest.raises(ValueError): + channel_instructions(melody(), TriangleInstruction) + + +class TestStreamsFromInstructions: + """The four channels encoded together, each through the encoder its own type names.""" + + def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release(self) -> None: + streams = streams_from_instructions(one_channel(GeneratorName.PULSE1), PLAYER_TIMER_TABLE) + assert len(streams.pulse1) == SOUNDING_TICKS + 1 + + def test_a_channel_describing_no_frame_carries_a_single_tick(self) -> None: + streams = streams_from_instructions(one_channel(GeneratorName.PULSE1), PLAYER_TIMER_TABLE) + assert (len(streams.pulse2), len(streams.triangle), len(streams.noise)) == (1, 1, 1) + + def test_a_pitch_reaches_the_timer_the_table_states(self) -> None: + streams = streams_from_instructions(one_channel(GeneratorName.PULSE1), PLAYER_TIMER_TABLE) + timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert (streams.pulse1[0].timer_low, streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + + def test_each_channel_reads_its_own_stream(self) -> None: + instructions: Dict[GeneratorName, List[InstructionUnion]] = { + GeneratorName.PULSE2: melody(), + GeneratorName.TRIANGLE: [TriangleInstruction(on=True, pitch=BASS_PITCH)], + GeneratorName.NOISE: [ + NoiseInstruction(on=True, period=NOISE_PERIOD, volume=NOISE_VOLUME, short=False), + ], + } + streams = streams_from_instructions(instructions, PLAYER_TIMER_TABLE) + assert len(streams.pulse2) == SOUNDING_TICKS + 1 + assert streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD + assert streams.noise[0].control & 0x0F == NOISE_VOLUME + + def test_a_channel_holding_another_channels_instructions_raises(self) -> None: + instructions: Dict[GeneratorName, List[InstructionUnion]] = {GeneratorName.TRIANGLE: melody()} + with pytest.raises(ValueError): + streams_from_instructions(instructions, PLAYER_TIMER_TABLE) + + +class TestSongFromReconstruction: + """A reconstruction read as the song the console plays it as.""" + + def test_the_song_lasts_the_ticks_its_longest_channel_covers(self) -> None: + reconstruction = player_reconstruction(one_channel(GeneratorName.PULSE1), NTSC_FREQUENCY) + assert song_from_reconstruction(reconstruction, loop_tick=None).ticks == SOUNDING_TICKS + 1 + + def test_the_schedule_follows_the_rate_the_reconstruction_was_built_at(self) -> None: + reconstruction = player_reconstruction(one_channel(GeneratorName.PULSE1), HALF_RATE_FREQUENCY) + song = song_from_reconstruction(reconstruction, loop_tick=None) + assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) + + def test_the_song_carries_the_loop_it_is_given(self) -> None: + reconstruction = player_reconstruction(one_channel(GeneratorName.PULSE1), NTSC_FREQUENCY) + assert song_from_reconstruction(reconstruction, loop_tick=0).loop_tick == 0 + + def test_a_loop_beyond_the_songs_ticks_raises(self) -> None: + reconstruction = player_reconstruction(one_channel(GeneratorName.PULSE1), NTSC_FREQUENCY) + with pytest.raises(ValueError): + song_from_reconstruction(reconstruction, loop_tick=SOUNDING_TICKS + 1) + + def test_the_timers_come_from_the_reconstructions_own_configuration(self) -> None: + reconstruction = player_reconstruction(one_channel(GeneratorName.PULSE1), NTSC_FREQUENCY) + song = song_from_reconstruction(reconstruction, loop_tick=None) + timer = get_timer_table(reconstruction.config)[PLAYER_REFERENCE_PITCH] + assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + + def test_a_reconstruction_describing_no_frame_plays_one_resting_tick(self) -> None: + reconstruction = player_reconstruction({GeneratorName.PULSE1: [silent_pulse()]}, NTSC_FREQUENCY) + song = song_from_reconstruction(reconstruction, loop_tick=None) + assert song.ticks == 1 From 0b32a9e5675a513ac270f6f0eff567ce546ff0f6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 15:53:29 +0200 Subject: [PATCH 022/142] Added: py65/ffpmeg toolchain, mixing utilities --- Makefile | 6 +- docs/development/bugs-and-todos.md | 1 + docs/development/dependencies.md | 38 +++- pyproject.toml | 1 + scripts/nsf_render.py | 172 ++++++++++++++++++ src/sampletones_core/audio/__init__.py | 4 + src/sampletones_core/audio/mixing.py | 88 +++++++++ .../formats/famitracker/builder.py | 4 +- .../famitracker/specification/parameters.py | 3 - src/sampletones_core/generators/__init__.py | 3 + src/sampletones_core/generators/render.py | 59 ++++++ .../reconstruction/reconstruction.py | 49 ++--- src/sampletones_player/builder.py | 5 +- .../driver/assembler/toolchain.py | 13 +- src/sampletones_player/nsf/information.py | 4 +- src/sampletones_player/specification/nsf.py | 3 - src/sampletones_shared/application.py | 2 + .../utils/system/programs.py | 46 +++++ tests/integration/nsf/console/__init__.py | 0 tests/integration/nsf/console/instructions.py | 155 ++++++++++++++++ tests/integration/nsf/console/machine.py | 132 ++++++++++++++ tests/integration/nsf/console/session.py | 45 +++++ tests/integration/nsf/exports.py | 10 + tests/integration/nsf/test_driver_audio.py | 116 ++++++++++++ tests/integration/nsf/test_driver_trace.py | 117 ++++++++++++ tests/integration/nsf/test_nsf_pipeline.py | 10 +- .../sampletones_core/audio/test_mixing.py | 134 ++++++++++++++ .../generators/test_render.py | 107 +++++++++++ .../sampletones_player/nsf/test_header.py | 6 +- tests/unit/sampletones_player/test_builder.py | 4 +- .../utils/system/test_programs.py | 51 ++++++ uv.lock | 11 ++ 32 files changed, 1329 insertions(+), 70 deletions(-) create mode 100755 scripts/nsf_render.py create mode 100644 src/sampletones_core/audio/mixing.py create mode 100644 src/sampletones_core/generators/render.py create mode 100644 src/sampletones_shared/utils/system/programs.py create mode 100644 tests/integration/nsf/console/__init__.py create mode 100644 tests/integration/nsf/console/instructions.py create mode 100644 tests/integration/nsf/console/machine.py create mode 100644 tests/integration/nsf/console/session.py create mode 100644 tests/integration/nsf/exports.py create mode 100644 tests/integration/nsf/test_driver_audio.py create mode 100644 tests/integration/nsf/test_driver_trace.py create mode 100644 tests/unit/sampletones_core/audio/test_mixing.py create mode 100644 tests/unit/sampletones_core/generators/test_render.py create mode 100644 tests/unit/sampletones_shared/utils/system/test_programs.py diff --git a/Makefile b/Makefile index 86825df4a..a25081737 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples nsf-samples icons player check-import-boundary check-tag-names check-unused-tags \ + ftm-samples nsf-samples nsf-render icons player check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -71,6 +71,7 @@ help: @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) @echo $(Q) make nsf-samples - Emit example .nsf files to build/nsf via the integration suite$(Q) + @echo $(Q) make nsf-render - Render the .nsf files in build/nsf to waves with ffmpeg$(Q) @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make player - Assemble the NES player driver with cc65$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @@ -117,6 +118,9 @@ nsf-samples: export SAMPLETONES_NSF_OUTPUT_DIR := build/nsf nsf-samples: uv run python -m pytest tests/integration/nsf +nsf-render: nsf-samples + uv run scripts/nsf_render.py + icons: uv run --group assets python scripts/assets/icons.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index b5b2a61c7..56a690e4b 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -38,6 +38,7 @@ * Per-tab undo routing * In-application console * Improve performance of browser favorite scan of the entire tree per click +* NSF play rate: the driver's step follows the 16666 µs the header asks for, while players drive the play routine from the NTSC frame rate ## Bugs diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index a5e733fdd..77f8b7c53 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -82,8 +82,7 @@ same on every system the project supports. Assembling needs `ca65` and `ld65` from [cc65](https://cc65.github.io/) — on Debian and Ubuntu, `sudo apt install cc65`, and a build names the equivalent for whichever system it runs on when the -programs are absent. cc65 is a build-time tool for the driver alone, which is why it belongs -neither in the requirements a user installs nor in `scripts/linux/build/dependencies.sh`. +programs are absent. cc65 is a build-time tool for the driver alone. The assembled `driver.bin` is committed, so a checkout carries the player and exporting an NSF needs no assembler. A jump table leads the image, which fixes the addresses an NSF header names @@ -97,6 +96,41 @@ cc65 is distributed under the zlib licence, and the driver stays clear of it: th our own object files and our own `nsf.cfg`, so nothing of cc65's start-up code or libraries reaches the committed image. That keeps the blob entirely ours to ship under the project's MIT licence. +### Verifying the driver + +`tests/integration/nsf` runs an exported file the way a console runs it. [py65](https://github.com/mnaberez/py65) +— a 6502 emulator in the `dev` dependency group — executes the assembled driver against memory that +watches the APU's address range, so each routine answers with the register writes it made and the +suite holds the whole run against `RegisterTrace.from_song`. Reading those writes back into +instructions and rendering them through the project's own generators closes the loop on the sound +as well: what the console plays stands against the very waveform the reconstruction carries. py65 +is a developer dependency, outside both the wheel and the bundles, and its BSD licence leaves the +project's own terms untouched. + +Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) carrying the `libgme` demuxer, which +is a build option rather than a given: `make nsf-render` asks the installed ffmpeg which demuxers +it holds and names this system's install command before it decodes anything. It exports the example +files and renders each one to a wave beside it, its length read out of the song block the file +carries. That is an ear rather than a gate: the register trace is what the driver answers to, and +the wave is what a person listens to. + +### The player's tools + +Three tools serve the player, each reached by one command: + +| Tool | Run by | Installed with | Reaches | +| --- | --- | --- | --- | +| cc65 (`ca65`, `ld65`) | `make player` | the system's package manager | the machine assembling the driver | +| py65 | `make test` | `uv sync --group dev` | the `dev` dependency group | +| ffmpeg with `libgme` | `make nsf-render` | the system's package manager | the machine listening to an export | + +`scripts/linux/build/dependencies.sh` and its macOS counterpart carry what building and running the +application needs, and the workflows install the `dev` group, so py65 is the one of the three CI +reaches — the suite verifies the driver through it alone. cc65 and ffmpeg stay on the machine of +whoever runs `make player` or `make nsf-render`, and a workflow that assembles the driver or renders +a wave is what would put them in those scripts. The application itself calls neither: an export is +written by the package's own code, from the committed `driver.bin`. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/pyproject.toml b/pyproject.toml index 44ade8cc3..40eb97cac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ dev = [ "isort==8.0.1", "mypy==2.1.0", "pre-commit==4.6.0", + "py65==1.2.0", "pylint==4.0.6", "pylint-pydantic==0.4.1", "pytest==9.1.1", diff --git a/scripts/nsf_render.py b/scripts/nsf_render.py new file mode 100755 index 000000000..f0428ede6 --- /dev/null +++ b/scripts/nsf_render.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + +import argparse +import struct +import subprocess +import sys +from pathlib import Path +from typing import Dict, Final, List, Sequence + +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.clock import ( + FIXED_POINT_SCALE, + MICROSECONDS_PER_SECOND, + PLAY_PERIOD_MICROSECONDS, +) +from sampletones_player.specification.nsf import HEADER_SIZE +from sampletones_player.specification.song import ( + STEP_FRACTION_OFFSET, + STEP_WHOLE_OFFSET, + TOTAL_TICKS_OFFSET, +) +from sampletones_shared.paths.extensions import EXT_FILE_NSF, EXT_FILE_WAVE +from sampletones_shared.utils.system.programs import ( + locate_program, + missing_program_message, +) +from sampletones_shared.utils.system.system import System + +SAMPLES_DIRECTORY: Final[Path] = Path("build") / "nsf" +TAIL_SECONDS: Final[float] = 0.5 +FFMPEG: Final[str] = "ffmpeg" +GME_FORMAT: Final[str] = "libgme" +WORD: Final[str] = " bool: + """Whether the installed ffmpeg carries the demuxer an exported file is read through. + + A demuxer is a build option, so ffmpeg is asked which ones it carries rather than taken to + carry this one. + + Returns: + bool: True where ffmpeg reports the libgme demuxer among its own. + + Raises: + CalledProcessError: If ffmpeg fails to report its demuxers. + """ + reported = subprocess.run( + [FFMPEG, "-hide_banner", "-loglevel", "error", "-demuxers"], + capture_output=True, + text=True, + check=True, + ) + return GME_FORMAT in reported.stdout + + +def song_seconds(data: bytes, code_length: int) -> float: + """How long the song in an exported file lasts, read out of the block behind the driver. + + The block states the ticks the song covers and the step the driver advances them by, and the + header asks the console for one play call every `PLAY_PERIOD_MICROSECONDS`, so the three + together give the seconds the song sounds for. + + Args: + data: The whole `.nsf` file, header included. + code_length: The length of the driver the file carries. + + Returns: + float: The seconds the song lasts. + """ + block = data[HEADER_SIZE + code_length :] + ticks: int = struct.unpack_from(WORD, block, TOTAL_TICKS_OFFSET)[0] + fraction: int = struct.unpack_from(WORD, block, STEP_FRACTION_OFFSET)[0] + step: float = block[STEP_WHOLE_OFFSET] + fraction / FIXED_POINT_SCALE + return ticks / step * PLAY_PERIOD_MICROSECONDS / MICROSECONDS_PER_SECOND + + +def render(source: Path, destination: Path, seconds: float) -> None: + """Decodes one exported file to a wave through libgme's own 2A03. + + Args: + source: The `.nsf` file to play. + destination: Where the rendered wave is written. + seconds: How much of the song to render. + + Raises: + CalledProcessError: If ffmpeg rejects the file. + """ + subprocess.run( + [ + FFMPEG, + "-y", + "-loglevel", + "error", + "-f", + GME_FORMAT, + "-i", + str(source), + "-t", + f"{seconds:.3f}", + str(destination), + ], + check=True, + ) + + +def main(argv: Sequence[str]) -> int: + """Renders every exported file in a directory to a wave beside it.""" + + parser = argparse.ArgumentParser( + description="Render exported .nsf files to waves with ffmpeg's libgme demuxer.", + ) + parser.add_argument( + "--directory", + type=Path, + default=SAMPLES_DIRECTORY, + help="directory holding the exported .nsf files", + ) + parser.add_argument( + "--tail", + type=float, + default=TAIL_SECONDS, + help="seconds to keep past the end of each song", + ) + arguments = parser.parse_args(list(argv)) + + if locate_program(FFMPEG) is None: + print(missing_program_message(FFMPEG, RENDER_PURPOSE, INSTALL_HINTS), file=sys.stderr) + return 1 + + if not decodes_exports(): + print( + f"{FFMPEG} reports no {GME_FORMAT} demuxer; rendering needs a build made with --enable-libgme", + file=sys.stderr, + ) + return 1 + + sources: List[Path] = sorted(arguments.directory.glob(f"*{EXT_FILE_NSF}")) + if not sources: + print( + f"no {EXT_FILE_NSF} files in {arguments.directory}; run make nsf-samples first", + file=sys.stderr, + ) + return 1 + + code_length = len(DriverImage.load().code) + for source in sources: + destination = source.with_suffix(EXT_FILE_WAVE) + seconds = song_seconds(source.read_bytes(), code_length) + arguments.tail + try: + render(source, destination, seconds) + except subprocess.CalledProcessError as error: + print( + f"{FFMPEG} rejected {source}: exit status {error.returncode}", + file=sys.stderr, + ) + return 1 + + print(f"{destination} {seconds:.3f} s") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index 436ef3c6f..3648e3ca6 100644 --- a/src/sampletones_core/audio/__init__.py +++ b/src/sampletones_core/audio/__init__.py @@ -1,6 +1,7 @@ from .device import AudioDevice, CurrentDevice from .io import load_audio, read_wave, write_wave from .manager import CHANNELS, FORMAT, AudioDeviceManager +from .mixing import align, common_length, mix from .processing import ( active_frame_level, amplitude_to_decibels, @@ -27,12 +28,15 @@ "AudioDeviceManager", "CurrentDevice", "active_frame_level", + "align", "amplitude_to_decibels", "clip_audio", "clip_audio_inplace", + "common_length", "interpolate", "load_audio", "minmax_decimate", + "mix", "normalize", "quantize", "read_wave", diff --git a/src/sampletones_core/audio/mixing.py b/src/sampletones_core/audio/mixing.py new file mode 100644 index 000000000..fa0c127c9 --- /dev/null +++ b/src/sampletones_core/audio/mixing.py @@ -0,0 +1,88 @@ +from typing import Iterable, List, Sequence + +import numpy as np + +from sampletones_shared.utils.arrays import pad + +from .processing import silence +from .validation import validate_audio_array + + +def common_length(tracks: Iterable[np.ndarray]) -> int: + """The length every track reaches when they are laid over one another. + + Args: + tracks: The waveforms to measure. + + Returns: + int: The length of the longest track, and zero where there are none. + + Examples: + >>> common_length([np.zeros(3), np.zeros(7)]) + 7 + >>> common_length([]) + 0 + """ + return max((len(track) for track in tracks), default=0) + + +def align(tracks: Sequence[np.ndarray], length: int) -> List[np.ndarray]: + """Every track brought to one length, silence filling what a shorter one leaves. + + A shorter track keeps its samples and runs on in silence; a longer one ends at `length`. + Waveforms sharing a length stack into a single array, which is what lets a set of sources + be summed at once and stored as one another's equals. + + Args: + tracks: The waveforms to align, each one-dimensional. + length: The length each track reaches. + + Returns: + List[np.ndarray]: The tracks in the order given, each one `length` samples long. + + Raises: + TypeError: If a track is not a numpy array. + ValueError: If a track is not one-dimensional, or if `length` is negative. + + Examples: + >>> align([np.array([1.0, 2.0]), np.array([3.0])], 3) + [array([1., 2., 0.]), array([3., 0., 0.])] + """ + if length < 0: + raise ValueError(f"Length must be at least 0, got {length}") + + for track in tracks: + validate_audio_array(track) + + return [pad(track, 0, length) for track in tracks] + + +def mix(tracks: Sequence[np.ndarray]) -> np.ndarray: + """The tracks summed into one waveform, as long as the longest of them. + + Every track carries the level it reaches the mix at — a generator bakes its channel's mixer + weight into what it renders, and a recorded stem carries the level it was captured at — so a + plain sum is what combines them once they share a length. + + Args: + tracks: The waveforms to mix, each one-dimensional. + + Returns: + np.ndarray: The mixed waveform, empty where no track sounds. + + Raises: + TypeError: If a track is not a numpy array. + ValueError: If a track is not one-dimensional. + + Examples: + >>> mix([np.array([1.0, 1.0]), np.array([0.5])]) + array([1.5, 1. ], dtype=float32) + >>> mix([]) + array([], dtype=float32) + """ + aligned = align(tracks, common_length(tracks)) + if not aligned: + return silence(0) + + mixed: np.ndarray = np.sum(np.array(aligned), axis=0).astype(np.float32) + return mixed diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 8d797231b..48baa95ed 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -33,7 +33,6 @@ MAX_INSTRUMENTS, ) from sampletones_core.formats.famitracker.specification.parameters import ( - DEFAULT_COPYRIGHT, DEFAULT_HIGHLIGHT_FIRST, DEFAULT_HIGHLIGHT_SECOND, DEFAULT_SPEED_SPLIT_POINT, @@ -59,6 +58,7 @@ from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.song import Song +from sampletones_shared.application import SAMPLETONES_COPYRIGHT def build_instrument( @@ -278,7 +278,7 @@ def project_to_module(project: Project) -> FamiTrackerModule: information = ModuleInformation( title=info.title, author=info.author, - copyright=DEFAULT_COPYRIGHT, + copyright=SAMPLETONES_COPYRIGHT, ) patterns: List[PatternData] = [] diff --git a/src/sampletones_core/formats/famitracker/specification/parameters.py b/src/sampletones_core/formats/famitracker/specification/parameters.py index 9788b081a..e1b0c5e2d 100644 --- a/src/sampletones_core/formats/famitracker/specification/parameters.py +++ b/src/sampletones_core/formats/famitracker/specification/parameters.py @@ -1,8 +1,6 @@ from enum import IntEnum from typing import Final -from sampletones_shared.application import SAMPLETONES_NAME - class Machine(IntEnum): """Playback machine stored in the PARAMS block.""" @@ -21,5 +19,4 @@ class Machine(IntEnum): DEFAULT_SPEED_SPLIT_POINT: Final[int] = 32 -DEFAULT_COPYRIGHT: Final[str] = f"generated by {SAMPLETONES_NAME}" COMMENT_HIDDEN_ON_OPEN: Final[int] = 0 diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py index 95edc7d2b..43ccc9dd0 100644 --- a/src/sampletones_core/generators/__init__.py +++ b/src/sampletones_core/generators/__init__.py @@ -10,6 +10,7 @@ LIBRARY_GENERATOR_CLASS_MAP, MIXER_LEVELS, ) +from .render import render_generators, render_instructions from .types import ( GeneratorClass, GeneratorClassNames, @@ -44,4 +45,6 @@ "get_generators_by_names", "get_generators_map", "get_remaining_generator_classes", + "render_generators", + "render_instructions", ] diff --git a/src/sampletones_core/generators/render.py b/src/sampletones_core/generators/render.py new file mode 100644 index 000000000..588c3ffd5 --- /dev/null +++ b/src/sampletones_core/generators/render.py @@ -0,0 +1,59 @@ +from typing import Dict, Mapping, Sequence + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.instructions import InstructionUnion + +from .maps import GENERATOR_CLASSES + + +def render_instructions( + instructions: Sequence[InstructionUnion], + generator_name: GeneratorName, + config: Config, +) -> np.ndarray: + """One channel's audio, rendered frame by frame from the instructions that drive it. + + Each frame continues the oscillator the one before it left running, so a note held across + frames sounds as a single tone. An instruction spans `config.frame_length` samples, which is + what ties the rendered length to the rate the configuration runs at. + + Args: + instructions: The channel's instructions, one per frame. + generator_name: The channel the instructions drive. + config: The configuration the frames are rendered at. + + Returns: + np.ndarray: The channel's waveform, one frame per instruction. + + Raises: + ValueError: If the channel describes no frame. + """ + generator = GENERATOR_CLASSES[generator_name](config, generator_name.value) + frames = [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type] + return np.concatenate(frames) + + +def render_generators( + instructions: Mapping[GeneratorName, Sequence[InstructionUnion]], + config: Config, +) -> Dict[GeneratorName, np.ndarray]: + """The audio of every channel that describes a frame, in channel order. + + A channel standing by renders nothing, so what comes back names the channels that sound and + the waveform each of them plays. + + Args: + instructions: The instructions each channel is driven by. + config: The configuration the frames are rendered at. + + Returns: + Dict[GeneratorName, np.ndarray]: The waveform each sounding channel renders to. + """ + return { + generator_name: render_instructions(instructions[generator_name], generator_name, config) + for generator_name in GeneratorName.items() + if instructions.get(generator_name) + } diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index b066f129b..c5af02dde 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -20,6 +20,7 @@ import numpy as np from pydantic import ConfigDict, Field, ValidationError, field_serializer +from sampletones_core.audio.mixing import align, common_length, mix from sampletones_core.configs import Config from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel, Metadata, MetadataContract @@ -30,7 +31,7 @@ ExporterUnion, Features, ) -from sampletones_core.generators.maps import GENERATOR_CLASSES +from sampletones_core.generators.render import render_generators from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION from sampletones_shared.exceptions import ( @@ -43,7 +44,6 @@ from sampletones_shared.types.callback import Callback from sampletones_shared.types.data import SerializedData from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.arrays import pad from sampletones_shared.utils.serialization import load_binary, serialize_array from ..reconstructor.state import ReconstructionState @@ -233,7 +233,7 @@ def from_state( return None approximations = {name: np.concatenate(state.approximations[name]) for name in state.approximations} - approximation = cls._sum_approximations(list(approximations.values())) + approximation = mix(list(approximations.values())) return cls.create( approximation=approximation, @@ -284,7 +284,7 @@ def update_generator_data( ) self.instructions_data = [streams[name] for name in GeneratorName.items()] self._invalidate_derived_caches(self) - self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data]) + self.approximation = mix([item.approximation for item in self.approximations_data]) def get_generator_instructions( self, @@ -325,25 +325,12 @@ def _resynthesized(self, config: Config) -> Reconstruction: plain sum reproduces the stored approximation shape. Drive is left at unity to match the regeneration path. """ - rendered: Dict[GeneratorName, np.ndarray] = {} - for generator_name, instructions in self.instructions.items(): - if not instructions: - continue - - generator = GENERATOR_CLASSES[generator_name]( - config, - generator_name.value, - ) - rendered[generator_name] = np.concatenate( - [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type] - ) - - max_length = max((len(audio) for audio in rendered.values()), default=0) + rendered = render_generators(self.instructions, config) approximations_data = self._build_approximations_data( rendered, - max_length, + common_length(rendered.values()), ) - approximation = self._sum_approximations([item.approximation for item in approximations_data]) + approximation = mix([item.approximation for item in approximations_data]) retuned: Reconstruction = self.model_copy( update={ @@ -355,36 +342,24 @@ def _resynthesized(self, config: Config) -> Reconstruction: self._invalidate_derived_caches(retuned) return retuned - @staticmethod - def _sum_approximations(arrays: Sequence[np.ndarray]) -> np.ndarray: - """Mixes equal-length per-generator approximations into one waveform. - - Returns an empty float array when no generator contributes, so a reconstruction with no - rendered audio still carries a valid approximation. - """ - if not arrays: - return np.zeros(0, dtype=np.float32) - - mixed: np.ndarray = np.sum(np.array(arrays), axis=0).astype(np.float32) - return mixed - @staticmethod def _build_approximations_data( rendered: Mapping[GeneratorName, np.ndarray], length: int, ) -> List[ApproximationsItem]: - """Pads each rendered channel's audio to ``length``, in channel order. + """Brings each rendered channel's audio to ``length``, in channel order. A shared length lets the per-generator arrays stack and sum into the mixed approximation, and a fixed order keeps a stored reconstruction reading the same however an edit reached it. """ + names = [generator_name for generator_name in GeneratorName.items() if generator_name in rendered] + aligned = align([rendered[generator_name] for generator_name in names], length) return [ ApproximationsItem( generator_name=generator_name, - approximation=pad(rendered[generator_name], 0, length), + approximation=audio, ) - for generator_name in GeneratorName.items() - if generator_name in rendered + for generator_name, audio in zip(names, aligned) ] @staticmethod diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 82bfa3324..9c2304a00 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -36,7 +36,7 @@ def channel_instructions( List[InstructionT]: The stream, covering at least one tick. Raises: - ValueError: If the stream holds an instruction another channel sounds. + TypeError: If the stream holds an instruction another channel sounds. """ typed: List[InstructionT] = [] for instruction in instructions: @@ -72,7 +72,7 @@ def streams_from_instructions( ChannelStreams: The four streams the driver plays. Raises: - ValueError: If a channel's stream holds an instruction another channel sounds. + TypeError: If a channel's stream holds an instruction another channel sounds. """ pulse1 = channel_instructions( instructions.get(GeneratorName.PULSE1, ()), @@ -117,6 +117,7 @@ def song_from_reconstruction( Song: The streams, the clock and the loop point as the player holds them. Raises: + TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ return Song( diff --git a/src/sampletones_player/driver/assembler/toolchain.py b/src/sampletones_player/driver/assembler/toolchain.py index 0f70e1e74..f27b47c8c 100644 --- a/src/sampletones_player/driver/assembler/toolchain.py +++ b/src/sampletones_player/driver/assembler/toolchain.py @@ -1,17 +1,21 @@ from __future__ import annotations -import shutil import subprocess from dataclasses import dataclass from pathlib import Path from typing import Dict, Final, List, Sequence from sampletones_shared.exceptions import DriverBuildError, ToolchainMissingError +from sampletones_shared.utils.system.programs import ( + locate_program, + missing_program_message, +) from sampletones_shared.utils.system.system import System ASSEMBLER: Final[str] = "ca65" LINKER: Final[str] = "ld65" TARGET_CPU: Final[str] = "6502" +ASSEMBLY_PURPOSE: Final[str] = "the player driver is assembled with cc65" INSTALL_HINTS: Final[Dict[System, str]] = { System.LINUX: "sudo apt install cc65", @@ -61,12 +65,11 @@ def find(program: str) -> Path: Raises: ToolchainMissingError: If the program is absent from the system. """ - located = shutil.which(program) + located = locate_program(program) if located is None: - hint = INSTALL_HINTS[System.current()] - raise ToolchainMissingError(f"{program} is missing: the player driver is assembled with cc65 ({hint})") + raise ToolchainMissingError(missing_program_message(program, ASSEMBLY_PURPOSE, INSTALL_HINTS)) - return Path(located) + return located def assemble(self, source: Path, include_directory: Path, destination: Path) -> None: """Assembles one 6502 source into an object file. diff --git a/src/sampletones_player/nsf/information.py b/src/sampletones_player/nsf/information.py index c89ff8c1f..782b8a909 100644 --- a/src/sampletones_player/nsf/information.py +++ b/src/sampletones_player/nsf/information.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict -from sampletones_player.specification.nsf import DEFAULT_COPYRIGHT +from sampletones_shared.application import SAMPLETONES_COPYRIGHT class NSFInformation(BaseModel): @@ -19,4 +19,4 @@ class NSFInformation(BaseModel): title: str artist: str - copyright: str = DEFAULT_COPYRIGHT + copyright: str = SAMPLETONES_COPYRIGHT diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py index 808032f7e..44573b851 100644 --- a/src/sampletones_player/specification/nsf.py +++ b/src/sampletones_player/specification/nsf.py @@ -1,7 +1,6 @@ from typing import Final from sampletones_player.specification.binary import WORD_SIZE -from sampletones_shared.application import SAMPLETONES_NAME PROGRAM_START: Final[int] = 0x8000 PROGRAM_SIZE: Final[int] = 0x8000 @@ -40,5 +39,3 @@ NO_NSF2_FEATURES: Final[int] = 0x00 NO_BANKSWITCHING: Final[bytes] = bytes(BANKSWITCH_SIZE) NSF2_LENGTH_UNSTATED: Final[bytes] = bytes(NSF2_LENGTH_SIZE) - -DEFAULT_COPYRIGHT: Final[str] = f"generated by {SAMPLETONES_NAME}" diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index cf5222e64..ed56544ed 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -13,3 +13,5 @@ SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" SAMPLETONES_AUTHOR: Final[str] = "Jakim" SAMPLETONES_GROUP: Final[str] = "Stage Magician" + +SAMPLETONES_COPYRIGHT: Final[str] = f"generated by {SAMPLETONES_NAME}" diff --git a/src/sampletones_shared/utils/system/programs.py b/src/sampletones_shared/utils/system/programs.py new file mode 100644 index 000000000..52e39136c --- /dev/null +++ b/src/sampletones_shared/utils/system/programs.py @@ -0,0 +1,46 @@ +import shutil +from pathlib import Path +from typing import Mapping, Optional + +from .system import System + + +def locate_program(program: str) -> Optional[Path]: + """Where a program is installed, found the way a shell finds it. + + Args: + program: The program's name, as it answers on the command line. + + Returns: + Optional[Path]: The program's location, and None where this system carries none. + """ + located = shutil.which(program) + if located is None: + return None + + return Path(located) + + +def missing_program_message( + program: str, + purpose: str, + hints: Mapping[System, str], +) -> str: + """What to report when a program the project reaches for is absent. + + Each supported system states its own way of installing the program, so the message names the + command this one is served by. + + Args: + program: The program's name, as it answers on the command line. + purpose: What the project runs the program for. + hints: How each supported system installs it. + + Returns: + str: The message, naming the program, what it is for and how this system installs it. + + Raises: + OSError: If the system is unsupported. + KeyError: If no hint is stated for this system. + """ + return f"{program} is missing: {purpose} ({hints[System.current()]})" diff --git a/tests/integration/nsf/console/__init__.py b/tests/integration/nsf/console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/nsf/console/instructions.py b/tests/integration/nsf/console/instructions.py new file mode 100644 index 000000000..d2d139df6 --- /dev/null +++ b/tests/integration/nsf/console/instructions.py @@ -0,0 +1,155 @@ +from typing import Dict, Final, List, Mapping, Tuple + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + NOISE_MODE_SHIFT, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SOUNDING_RELOAD, +) +from sampletones_player.trace.trace import RegisterTrace +from tests.integration.nsf.console.machine import register_file + +TRIANGLE_SOUNDING: Final[int] = TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD + + +def channel_values(registers: Mapping[int, int], channel: GeneratorName) -> Tuple[int, ...]: + """The values standing in one channel's registers, in the order the driver writes them.""" + return tuple(registers[address] for address in CHANNEL_REGISTER_ADDRESSES[channel]) + + +def timer_value(timer_low: int, timer_high: int) -> int: + """The period the two halves of a channel's timer carry together.""" + return (timer_high << TIMER_HIGH_SHIFT) | timer_low + + +def pulse_instruction( + registers: Mapping[int, int], + channel: GeneratorName, + pitches: Mapping[int, int], +) -> PulseInstruction: + """The instruction a pulse channel's registers sound. + + Volume rides in the control byte's low nibble and the duty cycle in its top two bits, so a + level of zero is what marks the tick a rest — the pitch and timbre standing there are the ones + the channel was holding when it stopped sounding. + + Args: + registers: The whole register file at a tick. + channel: Which pulse channel to read. + pitches: The pitch each timer value belongs to. + + Returns: + PulseInstruction: The frame the channel plays. + + Raises: + KeyError: If the timer standing there belongs to no pitch the configuration covers. + """ + control, timer_low, timer_high = channel_values(registers, channel) + volume = control & MAX_VOLUME + return PulseInstruction( + on=volume > 0, + pitch=pitches[timer_value(timer_low, timer_high)], + volume=volume, + duty_cycle=control >> DUTY_CYCLE_SHIFT, + ) + + +def triangle_instruction(registers: Mapping[int, int], pitches: Mapping[int, int]) -> TriangleInstruction: + """The instruction the triangle channel's registers sound. + + The channel states whether it sounds through the linear counter's reload value, so a full + reload beside the control bit is the tick sounding and anything else is a rest. + + Args: + registers: The whole register file at a tick. + pitches: The pitch each timer value belongs to. + + Returns: + TriangleInstruction: The frame the channel plays. + + Raises: + KeyError: If the timer standing there belongs to no pitch the configuration covers. + """ + linear_counter, timer_low, timer_high = channel_values(registers, GeneratorName.TRIANGLE) + return TriangleInstruction( + on=linear_counter == TRIANGLE_SOUNDING, + pitch=pitches[timer_value(timer_low, timer_high)], + ) + + +def noise_instruction(registers: Mapping[int, int]) -> NoiseInstruction: + """The instruction the noise channel's registers sound. + + The register counts periods from the fastest and the project counts them from the slowest, so + the period reaches the instruction as its complement, the way the encoder wrote it. + + Args: + registers: The whole register file at a tick. + + Returns: + NoiseInstruction: The frame the channel plays. + """ + control, period = channel_values(registers, GeneratorName.NOISE) + volume = control & MAX_VOLUME + return NoiseInstruction( + on=volume > 0, + period=MAX_PERIOD - (period & MAX_PERIOD), + volume=volume, + short=bool(period >> NOISE_MODE_SHIFT), + ) + + +def instructions_at(registers: Mapping[int, int], pitches: Mapping[int, int]) -> Dict[GeneratorName, InstructionUnion]: + """Every channel's instruction for one tick, read back out of the registers standing at it. + + Args: + registers: The whole register file at a tick. + pitches: The pitch each timer value belongs to. + + Returns: + Dict[GeneratorName, InstructionUnion]: One instruction per channel. + """ + return { + GeneratorName.PULSE1: pulse_instruction(registers, GeneratorName.PULSE1, pitches), + GeneratorName.PULSE2: pulse_instruction(registers, GeneratorName.PULSE2, pitches), + GeneratorName.TRIANGLE: triangle_instruction(registers, pitches), + GeneratorName.NOISE: noise_instruction(registers), + } + + +def instructions_from_trace( + trace: RegisterTrace, + timer_table: Mapping[int, int], +) -> Dict[GeneratorName, List[InstructionUnion]]: + """The per-tick instructions a captured run plays, one stream per channel. + + This closes the loop the export opens: instructions became register values, the values became + a file, the driver moved them to the APU, and reading them back states what the console sounds + in the very terms the generators render from. + + Args: + trace: The writes a run of the driver made. + timer_table: The timer register value each pitch sounds at. + + Returns: + Dict[GeneratorName, List[InstructionUnion]]: Each channel's stream, one instruction per + tick the run sounded. + """ + pitches = {timer: pitch for pitch, timer in timer_table.items()} + streams: Dict[GeneratorName, List[InstructionUnion]] = {channel: [] for channel in GeneratorName.items()} + + for registers in register_file(trace): + for channel, instruction in instructions_at(registers, pitches).items(): + streams[channel].append(instruction) + + return streams diff --git a/tests/integration/nsf/console/machine.py b/tests/integration/nsf/console/machine.py new file mode 100644 index 000000000..7a5d7b69e --- /dev/null +++ b/tests/integration/nsf/console/machine.py @@ -0,0 +1,132 @@ +from typing import Dict, Final, List, Tuple + +from py65.devices.mpu6502 import MPU +from py65.memory import ObservableMemory + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.specification.nsf import HEADER_SIZE +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + FIRST_CHANNEL_REGISTER, +) +from sampletones_player.trace.trace import RegisterTrace +from sampletones_player.trace.write import RegisterWrite + +RETURN_SENTINEL: Final[int] = 0xFFF0 +STACK_PAGE: Final[int] = 0x0100 +STACK_TOP: Final[int] = 0xFF +FIRST_SONG_INDEX: Final[int] = 0x00 +NTSC_MACHINE: Final[int] = 0x00 +STEP_BUDGET: Final[int] = 1_000_000 + + +class Console: + """A 6502 running an exported file, watching every APU register the driver writes. + + An NSF player loads the image behind the header, calls the init routine once with the song + number in the accumulator and the machine in X, and calls the play routine at the rate the + header asks for. This runs the same sequence over py65's CPU with the APU's address range + subscribed, so each routine answers with the writes it made. Capturing writes rather than + sound is what lets a run stand against `RegisterTrace.from_song` value for value. + """ + + def __init__(self, data: bytes, addresses: DriverAddresses) -> None: + """Loads an exported file the way a player loads it. + + Args: + data: The whole `.nsf` file, header included. + addresses: Where the image loads and which routines it answers at. + """ + self._addresses = addresses + self._writes: List[RegisterWrite] = [] + self._memory = ObservableMemory() + self._memory.write(addresses.load, list(data[HEADER_SIZE:])) + self._memory.subscribe_to_write( + range(FIRST_CHANNEL_REGISTER, APU_FRAME_COUNTER + 1), + self._observe, + ) + self._processor = MPU(memory=self._memory) + + def _observe(self, address: int, value: int) -> None: + self._writes.append(RegisterWrite(address, value)) + + def _seed_stack(self) -> None: + self._memory[STACK_PAGE + STACK_TOP] = (RETURN_SENTINEL - 1) >> 8 + self._memory[STACK_PAGE + STACK_TOP - 1] = (RETURN_SENTINEL - 1) & 0xFF + self._processor.sp = STACK_TOP - 2 + + def _call(self, address: int, accumulator: int) -> Tuple[RegisterWrite, ...]: + self._writes = [] + self._processor.a = accumulator + self._processor.x = NTSC_MACHINE + self._seed_stack() + self._processor.pc = address + + for _ in range(STEP_BUDGET): + if self._processor.pc == RETURN_SENTINEL: + return tuple(self._writes) + + self._processor.step() + + raise RuntimeError(f"the routine at {address:#06x} ran for {STEP_BUDGET} instructions without returning") + + def initialise(self) -> Tuple[RegisterWrite, ...]: + """Runs the init routine, which readies the APU and sounds the song's first tick. + + Returns: + Tuple[RegisterWrite, ...]: Every register the routine wrote, in order. + """ + return self._call(self._addresses.init, FIRST_SONG_INDEX) + + def play(self) -> Tuple[RegisterWrite, ...]: + """Runs one play call, the way the console calls it each frame. + + Returns: + Tuple[RegisterWrite, ...]: Every register the call wrote, empty where the streams + hold their tick through it. + """ + return self._call(self._addresses.play, FIRST_SONG_INDEX) + + def trace(self, play_calls: int) -> RegisterTrace: + """Runs a whole session: initialisation followed by ``play_calls`` play calls. + + Args: + play_calls: How many play calls the run covers. + + Returns: + RegisterTrace: The writes the driver made, grouped the way the model states them. + """ + initialisation = self.initialise() + return RegisterTrace( + initialisation=initialisation, + play_calls=tuple(self.play() for _ in range(play_calls)), + ) + + +def register_file(trace: RegisterTrace) -> List[Dict[int, int]]: + """The APU as the driver leaves it after initialisation and after every call that sounds. + + A tick reaches the hardware as the values standing in the registers once its writes land, and + the three registers written only on change keep the value an earlier tick left there. Reading + the whole file back after each sounding call is therefore what recovers a tick's full state + from a trace that states only what changed. + + Args: + trace: The writes a run of the driver made. + + Returns: + List[Dict[int, int]]: One register file per tick the run sounded, in order. + """ + registers: Dict[int, int] = {} + ticks: List[Dict[int, int]] = [] + + for writes in (trace.initialisation, *trace.play_calls): + if not writes: + continue + + for write in writes: + registers[write.address] = write.value + + ticks.append(dict(registers)) + + return ticks diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py new file mode 100644 index 000000000..ae8be79b9 --- /dev/null +++ b/tests/integration/nsf/console/session.py @@ -0,0 +1,45 @@ +from typing import Final + +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.file import nsf_to_bytes +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.song import Song +from sampletones_player.trace.trace import RegisterTrace +from tests.integration.nsf.console.machine import Console + +TRAILING_CALLS: Final[int] = 2 + + +def play_calls_covering(song: Song) -> int: + """How many play calls carry a song from its first tick past its last. + + A stream built below the hardware rate holds its tick through some calls, so the count follows + the song's own schedule rather than its tick count. The run reaches a few calls beyond the end + as well, which is where a song without a loop is seen to stop. + + Args: + song: The song the driver plays. + + Returns: + int: The number of play calls the run covers. + """ + calls = 0 + while song.tick_at(calls) is not None: + calls += 1 + + return calls + TRAILING_CALLS + + +def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: + """Exports a song, runs the file on a 6502 and answers with every APU write it made. + + Args: + song: The song to export and play. + information: The text the exported header carries. + + Returns: + RegisterTrace: The writes of the initialisation and of every play call in the run. + """ + image = DriverImage.load() + console = Console(nsf_to_bytes(song, information), image.addresses) + return console.trace(play_calls_covering(song)) diff --git a/tests/integration/nsf/exports.py b/tests/integration/nsf/exports.py new file mode 100644 index 000000000..c7041fafb --- /dev/null +++ b/tests/integration/nsf/exports.py @@ -0,0 +1,10 @@ +from typing import Final + +from sampletones_player.nsf.information import NSFInformation + +ARTIST: Final[str] = "Integration" + + +def exported_information(name: str) -> NSFInformation: + """The header text an exported sample carries.""" + return NSFInformation(title=name, artist=ARTIST) diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py new file mode 100644 index 000000000..580ddb4b6 --- /dev/null +++ b/tests/integration/nsf/test_driver_audio.py @@ -0,0 +1,116 @@ +from typing import Dict, List + +import numpy as np +import pytest + +from sampletones_core.audio.mixing import mix +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.generators.render import render_generators +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.builder import song_from_reconstruction +from tests.integration.nsf.console.instructions import instructions_from_trace +from tests.integration.nsf.console.session import captured_trace +from tests.integration.nsf.exports import exported_information + +ChannelInstructions = Dict[GeneratorName, List[InstructionUnion]] + + +def played_by_console(sample: Sample) -> ChannelInstructions: + """The per-tick instructions the console sounds, read back out of the registers it wrote.""" + song = song_from_reconstruction(sample.reconstruction, loop_tick=None) + trace = captured_trace(song, exported_information(sample.name)) + return instructions_from_trace(trace, get_timer_table(sample.reconstruction.config)) + + +def resting(instruction: InstructionUnion) -> InstructionUnion: + """A sounding instruction as it stands, and a rest as the canonical silent one. + + A stream holds a channel's pitch and timbre through a rest so the driver leaves the timer's + high byte alone, so what a rest carries beyond its silence is the channel's own history. + """ + if instruction.on: + return instruction + + silent: InstructionUnion = type(instruction).null_instruction() + return silent + + +@pytest.fixture(scope="module") +def played(instrument_catalog: Dict[str, Sample]) -> Dict[str, ChannelInstructions]: + """What the console sounds for every sample in the catalog, together covering all four channels.""" + return {name: played_by_console(sample) for name, sample in instrument_catalog.items()} + + +@pytest.fixture(scope="module") +def rendered( + played: Dict[str, ChannelInstructions], + instrument_catalog: Dict[str, Sample], +) -> Dict[str, np.ndarray]: + """The waveform the console's instructions sound as, rendered on the reconstruction's own engine.""" + return { + name: mix(list(render_generators(played[name], sample.reconstruction.config).values())) + for name, sample in instrument_catalog.items() + } + + +class TestTheConsoleSoundsTheReconstruction: + """What the driver puts on the APU, decoded back into the terms the reconstruction speaks.""" + + def test_every_played_channel_sounds_its_own_instructions( + self, + played: Dict[str, ChannelInstructions], + instrument_catalog: Dict[str, Sample], + ) -> None: + for name, sample in instrument_catalog.items(): + for channel, instructions in sample.reconstruction.instructions.items(): + sounded = played[name][channel][: len(instructions)] + assert [resting(instruction) for instruction in sounded] == [ + resting(instruction) for instruction in instructions + ] + + def test_a_channel_the_reconstruction_leaves_out_rests_throughout( + self, + played: Dict[str, ChannelInstructions], + instrument_catalog: Dict[str, Sample], + ) -> None: + for name, sample in instrument_catalog.items(): + silent = set(GeneratorName.items()) - set(sample.reconstruction.instructions) + for channel in silent: + assert not any(instruction.on for instruction in played[name][channel]) + + def test_the_catalog_sounds_all_four_channels(self, played: Dict[str, ChannelInstructions]) -> None: + sounded = { + channel + for instructions in played.values() + for channel, stream in instructions.items() + if any(instruction.on for instruction in stream) + } + assert sounded == set(GeneratorName.items()) + + def test_every_run_ends_with_every_channel_silent(self, played: Dict[str, ChannelInstructions]) -> None: + for instructions in played.values(): + assert not any(stream[-1].on for stream in instructions.values()) + + +class TestTheConsoleRendersTheReconstructionsAudio: + """The captured trace, sounded through the very generators the reconstruction was built on.""" + + def test_the_console_reproduces_the_reconstructions_waveform( + self, + rendered: Dict[str, np.ndarray], + instrument_catalog: Dict[str, Sample], + ) -> None: + for name, sample in instrument_catalog.items(): + approximation = sample.reconstruction.approximation + assert np.array_equal(rendered[name][: len(approximation)], approximation) + + def test_the_audio_past_the_reconstruction_is_silent( + self, + rendered: Dict[str, np.ndarray], + instrument_catalog: Dict[str, Sample], + ) -> None: + for name, sample in instrument_catalog.items(): + approximation = sample.reconstruction.approximation + assert not np.any(rendered[name][len(approximation) :]) diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py new file mode 100644 index 000000000..035f638e4 --- /dev/null +++ b/tests/integration/nsf/test_driver_trace.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass +from typing import Final + +import pytest + +from sampletones_core.project.instruments.sample import Sample +from sampletones_player.builder import song_from_reconstruction +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.nsf import PROGRAM_SIZE +from sampletones_player.specification.song import STEP_FRACTION_OFFSET, STEP_WHOLE_OFFSET +from sampletones_player.trace.trace import RegisterTrace +from tests.integration.nsf.console.session import ( + TRAILING_CALLS, + captured_trace, + play_calls_covering, +) +from tests.integration.nsf.exports import exported_information +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +HALF_RATE: Final[int] = 30 +DOUBLE_RATE: Final[int] = 120 + + +@pytest.fixture +def trace(song: Song, sample: Sample) -> RegisterTrace: + """Every APU write the assembled driver makes over a full run of the sample.""" + return captured_trace(song, exported_information(sample.name)) + + +@pytest.fixture +def expected(song: Song) -> RegisterTrace: + """The writes the model states a correct driver makes over that same run.""" + return RegisterTrace.from_song(song, play_calls_covering(song)) + + +class TestTheDriverWritesWhatTheModelStates: + """The assembled 6502 driver run on py65, held against `RegisterTrace.from_song`.""" + + def test_initialisation_readies_the_console_the_way_the_model_states( + self, + trace: RegisterTrace, + expected: RegisterTrace, + ) -> None: + assert trace.initialisation == expected.initialisation + + def test_every_play_call_writes_what_the_model_states( + self, + trace: RegisterTrace, + expected: RegisterTrace, + ) -> None: + assert trace.play_calls == expected.play_calls + + def test_a_song_without_a_loop_stops_where_it_ends(self, trace: RegisterTrace) -> None: + assert all(not writes for writes in trace.play_calls[-TRAILING_CALLS:]) + + def test_the_run_sounds_every_tick_the_song_covers( + self, + trace: RegisterTrace, + song: Song, + ) -> None: + sounding = [writes for writes in trace.play_calls if writes] + assert len(sounding) + 1 == song.ticks + + +class TestAReClockedStreamPlaysTheSameTicks(BaseTestSuite): + """A reconstruction built at another rate reaches the console through the same data. + + The file states one stream and the rate it was built at, and the driver advances that stream + by a fractional number of ticks each call. Every rate therefore plays the same ticks in the + same order, spread over as many calls as the rate asks for. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + + @property + def label(self) -> str: + return f"{self.expected} Hz" + + test_cases = ( + TestCase(expected=HALF_RATE), + TestCase(expected=DOUBLE_RATE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_driver_writes_what_the_model_states( + self, + test_case: TestCase, + sample: Sample, + ) -> None: + reclocked = sample.reconstruction.with_nes_frequency(test_case.expected) + song = song_from_reconstruction(reclocked, loop_tick=None) + + trace = captured_trace(song, exported_information(sample.name)) + assert trace == RegisterTrace.from_song(song, play_calls_covering(song)) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_rate_reaches_the_console_as_the_step_alone( + self, + test_case: TestCase, + song: Song, + sample: Sample, + driver_image: DriverImage, + ) -> None: + reclocked = sample.reconstruction.with_nes_frequency(test_case.expected) + available = PROGRAM_SIZE - len(driver_image.code) + + block = song_to_bytes(song, available) + reclocked_block = song_to_bytes(song_from_reconstruction(reclocked, loop_tick=None), available) + + assert block[STEP_FRACTION_OFFSET + WORD_SIZE :] == reclocked_block[STEP_FRACTION_OFFSET + WORD_SIZE :] + assert block[:STEP_WHOLE_OFFSET] == reclocked_block[:STEP_WHOLE_OFFSET] diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py index 2906b7abc..b41a1bb17 100644 --- a/tests/integration/nsf/test_nsf_pipeline.py +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -1,6 +1,6 @@ import struct from pathlib import Path -from typing import Dict, Final, Tuple +from typing import Dict, Tuple import pytest @@ -10,7 +10,6 @@ from sampletones_player.builder import song_from_reconstruction from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.file import write_nsf -from sampletones_player.nsf.information import NSFInformation from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE @@ -29,12 +28,7 @@ TOTAL_TICKS_OFFSET, ) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION - -ARTIST: Final[str] = "Integration" - - -def exported_information(name: str) -> NSFInformation: - return NSFInformation(title=name, artist=ARTIST) +from tests.integration.nsf.exports import exported_information def song_block(data: bytes, image: DriverImage) -> bytes: diff --git a/tests/unit/sampletones_core/audio/test_mixing.py b/tests/unit/sampletones_core/audio/test_mixing.py new file mode 100644 index 000000000..6bb9abb93 --- /dev/null +++ b/tests/unit/sampletones_core/audio/test_mixing.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from typing import Any, List, Type, Union + +import numpy as np +import pytest + +from sampletones_core.audio.mixing import align, common_length, mix +from tests.suite.arrays import assert_array_equal +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.errors import expect_error + + +class TestCommonLength: + """The length a set of tracks reaches when they are laid over one another.""" + + def test_the_longest_track_sets_the_length(self) -> None: + assert common_length([np.zeros(3), np.zeros(7), np.zeros(5)]) == 7 + + def test_tracks_of_one_length_keep_it(self) -> None: + assert common_length([np.zeros(4), np.zeros(4)]) == 4 + + def test_no_tracks_reach_no_length(self) -> None: + assert common_length([]) == 0 + + +class TestAlign(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Union[List[np.ndarray], Type[Exception]] + tracks: Any + length: int + + test_cases = ( + TestCase( + label="a_shorter_track_runs_on_in_silence", + tracks=[np.array([1.0, 2.0])], + length=4, + expected=[np.array([1.0, 2.0, 0.0, 0.0])], + ), + TestCase( + label="a_longer_track_ends_at_the_length", + tracks=[np.array([1.0, 2.0, 3.0])], + length=2, + expected=[np.array([1.0, 2.0])], + ), + TestCase( + label="a_track_of_the_length_stands_as_it_is", + tracks=[np.array([1.0, 2.0])], + length=2, + expected=[np.array([1.0, 2.0])], + ), + TestCase( + label="every_track_reaches_the_same_length", + tracks=[np.array([1.0]), np.array([2.0, 3.0, 4.0])], + length=3, + expected=[np.array([1.0, 0.0, 0.0]), np.array([2.0, 3.0, 4.0])], + ), + TestCase( + label="no_tracks_align_to_none", + tracks=[], + length=5, + expected=[], + ), + TestCase( + label="a_negative_length_raises", + tracks=[np.array([1.0])], + length=-1, + expected=ValueError, + ), + TestCase( + label="a_two_dimensional_track_raises", + tracks=[np.zeros((2, 2))], + length=2, + expected=ValueError, + ), + TestCase( + label="a_list_is_not_a_track", + tracks=[[1.0, 2.0]], + length=2, + expected=TypeError, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_align(self, test_case: TestCase) -> None: + if expect_error(align, test_case.expected, test_case.tracks, test_case.length): + return + + aligned = align(test_case.tracks, test_case.length) + assert len(aligned) == len(test_case.expected) + for track, expected in zip(aligned, test_case.expected): + assert_array_equal(track, expected) + + +class TestMix: + """Tracks summed into one waveform, each carrying the level it reaches the mix at.""" + + def test_tracks_of_one_length_sum_sample_by_sample(self) -> None: + assert_array_equal( + mix([np.array([0.25, -0.25]), np.array([0.5, 0.5])]), + np.array([0.75, 0.25], dtype=np.float32), + ) + + def test_a_shorter_track_falls_silent_at_its_end(self) -> None: + assert_array_equal( + mix([np.array([1.0, 1.0, 1.0]), np.array([0.5])]), + np.array([1.5, 1.0, 1.0], dtype=np.float32), + ) + + def test_the_mix_lasts_as_long_as_the_longest_track(self) -> None: + assert len(mix([np.zeros(3), np.zeros(9)])) == 9 + + def test_one_track_reaches_the_mix_as_it_stands(self) -> None: + assert_array_equal( + mix([np.array([0.5, -0.5])]), + np.array([0.5, -0.5], dtype=np.float32), + ) + + def test_no_tracks_mix_to_silence(self) -> None: + mixed = mix([]) + assert mixed.dtype == np.float32 + assert len(mixed) == 0 + + def test_the_mix_is_float32_whatever_the_tracks_carry(self) -> None: + assert mix([np.array([1, 2], dtype=np.int32)]).dtype == np.float32 + + def test_a_track_that_is_not_an_array_raises(self) -> None: + with pytest.raises(TypeError): + mix([[1.0, 2.0]]) diff --git a/tests/unit/sampletones_core/generators/test_render.py b/tests/unit/sampletones_core/generators/test_render.py new file mode 100644 index 000000000..aba454485 --- /dev/null +++ b/tests/unit/sampletones_core/generators/test_render.py @@ -0,0 +1,107 @@ +from typing import Dict, List + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.generators.render import render_generators, render_instructions +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) + + +@pytest.fixture +def config() -> Config: + return Config() + + +@pytest.fixture +def pulse_stream() -> List[InstructionUnion]: + return [ + PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0), + PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0), + PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0), + ] + + +class TestRenderInstructions: + """One channel's audio, rendered frame by frame from the instructions that drive it.""" + + def test_every_instruction_renders_one_frame( + self, + pulse_stream: List[InstructionUnion], + config: Config, + ) -> None: + rendered = render_instructions(pulse_stream, GeneratorName.PULSE1, config) + assert len(rendered) == len(pulse_stream) * config.library.frame_length + + def test_a_held_note_runs_its_oscillator_on( + self, + pulse_stream: List[InstructionUnion], + config: Config, + ) -> None: + frame_length = config.library.frame_length + rendered = render_instructions(pulse_stream, GeneratorName.PULSE1, config) + first = rendered[:frame_length] + second = rendered[frame_length : 2 * frame_length] + assert not np.array_equal(first, second) + + def test_a_rest_renders_silence(self, pulse_stream: List[InstructionUnion], config: Config) -> None: + frame_length = config.library.frame_length + rendered = render_instructions(pulse_stream, GeneratorName.PULSE1, config) + assert not np.any(rendered[2 * frame_length :]) + + def test_a_channel_describing_no_frame_raises(self, config: Config) -> None: + with pytest.raises(ValueError): + render_instructions([], GeneratorName.PULSE1, config) + + +class TestRenderGenerators: + """The audio of every channel that describes a frame, in channel order.""" + + @pytest.fixture + def streams(self, pulse_stream: List[InstructionUnion]) -> Dict[GeneratorName, List[InstructionUnion]]: + return { + GeneratorName.PULSE1: pulse_stream, + GeneratorName.TRIANGLE: [TriangleInstruction(on=True, pitch=48)], + GeneratorName.NOISE: [NoiseInstruction(on=True, period=4, volume=15, short=False)], + } + + def test_every_sounding_channel_renders( + self, + streams: Dict[GeneratorName, List[InstructionUnion]], + config: Config, + ) -> None: + assert set(render_generators(streams, config)) == set(streams) + + def test_a_channel_standing_by_renders_nothing( + self, + streams: Dict[GeneratorName, List[InstructionUnion]], + config: Config, + ) -> None: + streams[GeneratorName.PULSE2] = [] + assert GeneratorName.PULSE2 not in render_generators(streams, config) + + def test_the_channels_come_back_in_channel_order( + self, + streams: Dict[GeneratorName, List[InstructionUnion]], + config: Config, + ) -> None: + rendered = render_generators(streams, config) + assert list(rendered) == [name for name in GeneratorName.items() if name in streams] + + def test_a_channel_sounds_what_it_renders_on_its_own( + self, + streams: Dict[GeneratorName, List[InstructionUnion]], + config: Config, + ) -> None: + rendered = render_generators(streams, config) + for generator_name, instructions in streams.items(): + assert np.array_equal( + rendered[generator_name], + render_instructions(instructions, generator_name, config), + ) diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py index b83fe65d2..98b37025f 100644 --- a/tests/unit/sampletones_player/nsf/test_header.py +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -13,7 +13,6 @@ BANKSWITCH_OFFSET, BANKSWITCH_SIZE, COPYRIGHT_OFFSET, - DEFAULT_COPYRIGHT, EXPANSION_OFFSET, FIRST_SONG, FIRST_SONG_OFFSET, @@ -40,6 +39,7 @@ TITLE_OFFSET, VERSION_OFFSET, ) +from sampletones_shared.application import SAMPLETONES_COPYRIGHT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -76,7 +76,7 @@ class TestHeaderBytes: + b"\x00\x80\x00\x80\x03\x80" + TITLE.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + ARTIST.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") - + DEFAULT_COPYRIGHT.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + + SAMPLETONES_COPYRIGHT.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + b"\x1a\x41" + bytes(BANKSWITCH_SIZE) + b"\x20\x4e" @@ -135,7 +135,7 @@ def label(self) -> str: test_cases = ( TestCase(offset=TITLE_OFFSET, expected=TITLE), TestCase(offset=ARTIST_OFFSET, expected=ARTIST), - TestCase(offset=COPYRIGHT_OFFSET, expected=DEFAULT_COPYRIGHT), + TestCase(offset=COPYRIGHT_OFFSET, expected=SAMPLETONES_COPYRIGHT), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index 7793c5d41..ce8b7d19d 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -59,7 +59,7 @@ def test_a_resting_channel_rests_in_its_own_type(self) -> None: assert channel_instructions([], NoiseInstruction) == [NoiseInstruction.null_instruction()] def test_a_stream_of_another_channels_type_raises(self) -> None: - with pytest.raises(ValueError): + with pytest.raises(TypeError): channel_instructions(melody(), TriangleInstruction) @@ -94,7 +94,7 @@ def test_each_channel_reads_its_own_stream(self) -> None: def test_a_channel_holding_another_channels_instructions_raises(self) -> None: instructions: Dict[GeneratorName, List[InstructionUnion]] = {GeneratorName.TRIANGLE: melody()} - with pytest.raises(ValueError): + with pytest.raises(TypeError): streams_from_instructions(instructions, PLAYER_TIMER_TABLE) diff --git a/tests/unit/sampletones_shared/utils/system/test_programs.py b/tests/unit/sampletones_shared/utils/system/test_programs.py new file mode 100644 index 000000000..f59899de6 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/test_programs.py @@ -0,0 +1,51 @@ +from pathlib import Path +from typing import Dict, Final +from unittest.mock import patch + +import pytest + +from sampletones_shared.utils.system.programs import ( + locate_program, + missing_program_message, +) +from sampletones_shared.utils.system.system import System + +HINTS: Final[Dict[System, str]] = { + System.LINUX: "sudo apt install tool", + System.MACOS: "brew install tool", + System.WINDOWS: "install tool and add it to PATH", +} + + +class TestLocateProgram: + """Where a program is installed, or nothing where this system carries none.""" + + def test_an_installed_program_reports_where_it_is(self) -> None: + with patch("shutil.which", return_value="/usr/bin/tool"): + assert locate_program("tool") == Path("/usr/bin/tool") + + def test_an_absent_program_reports_nothing(self) -> None: + with patch("shutil.which", return_value=None): + assert locate_program("tool") is None + + +class TestMissingProgramMessage: + """What to report when a program the project reaches for is absent.""" + + def test_the_message_names_the_program_its_purpose_and_the_install_command(self) -> None: + with patch("platform.system", return_value="Linux"): + message = missing_program_message("tool", "waves are rendered with tool", HINTS) + + assert message == "tool is missing: waves are rendered with tool (sudo apt install tool)" + + def test_every_system_names_its_own_command(self) -> None: + with patch("platform.system", return_value="Darwin"): + assert HINTS[System.MACOS] in missing_program_message("tool", "purpose", HINTS) + + with patch("platform.system", return_value="Windows"): + assert HINTS[System.WINDOWS] in missing_program_message("tool", "purpose", HINTS) + + def test_a_system_with_no_hint_raises(self) -> None: + with patch("platform.system", return_value="Linux"): + with pytest.raises(KeyError): + missing_program_message("tool", "purpose", {System.WINDOWS: "installer"}) diff --git a/uv.lock b/uv.lock index d28681761..1b3974a07 100644 --- a/uv.lock +++ b/uv.lock @@ -1355,6 +1355,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "py65" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/de/8ceb654608a24ae247ac44443b369f70183d9a39d94e682ddb3c9767b330/py65-1.2.tar.gz", hash = "sha256:e1cb213823fc6ea8aecb219939943144b09439e5c14b3406cb5ca825d5875b93", size = 83481, upload-time = "2024-04-12T20:10:19.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/3d/7171f8401bb3cf058d233af314d3e314a348488bd22b2143b3591566a172/py65-1.2.0-py2.py3-none-any.whl", hash = "sha256:8e51f149647f2c2b44e5eb54350057771ccaef97f5f3d593fa3737e44c1ce4b7", size = 60518, upload-time = "2024-04-12T20:10:15.198Z" }, +] + [[package]] name = "pyaudio" version = "0.2.14" @@ -1834,6 +1843,7 @@ dev = [ { name = "mypy" }, { name = "pillow" }, { name = "pre-commit" }, + { name = "py65" }, { name = "pylint" }, { name = "pylint-pydantic" }, { name = "pytest" }, @@ -1878,6 +1888,7 @@ dev = [ { name = "mypy", specifier = "==2.1.0" }, { name = "pillow", specifier = ">=11,<13" }, { name = "pre-commit", specifier = "==4.6.0" }, + { name = "py65", specifier = "==1.2.0" }, { name = "pylint", specifier = "==4.0.6" }, { name = "pylint-pydantic", specifier = "==0.4.1" }, { name = "pytest", specifier = "==9.1.1" }, From 21e1f8a5abc14afab79f313f7c04fd982c08c05b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 17:16:45 +0200 Subject: [PATCH 023/142] Introduced: multiple source audios --- docs/concepts/stems.md | 37 +++++++- src/sampletones_application/application.py | 4 +- .../coordinators/original_audio.py | 16 ++-- .../coordinators/reconstruction.py | 8 +- .../logic/reconstruction/data.py | 48 ++++++----- .../logic/reconstruction/manager.py | 28 ++++--- .../logic/reconstruction/reconstruction.py | 57 +++++++------ .../ui/panels/reconstruction/audio.py | 54 +++++++++++- .../reconstruction/paths/__init__.py | 0 .../view_model/reconstruction/paths/path.py | 18 ++++ .../view_model/reconstruction/paths/state.py | 49 +++++++++++ .../reconstruction/reconstruction.py | 34 ++------ src/sampletones_core/audio/__init__.py | 2 + src/sampletones_core/audio/mixing.py | 25 ++++++ .../reconstructions/naming/__init__.py | 0 .../reconstructions/naming/derive.py | 30 +++++++ .../reconstructions/naming/protocol.py | 10 +++ .../reconstructions/naming/rules/__init__.py | 0 .../naming/rules/common_directory.py | 12 +++ .../naming/rules/single_source.py | 12 +++ .../reconstruction/reconstruction.py | 12 ++- .../reconstructor/reconstructor.py | 16 +--- src/sampletones_shared/utils/system/paths.py | 48 ++++++++++- .../utils/system/reveal/__init__.py | 5 ++ .../utils/system/reveal/file_manager1.py | 79 +++++++++++++++++ .../utils/system/reveal/grouped.py | 24 ++++++ .../utils/system/reveal/protocol.py | 13 +++ .../utils/system/reveal/selection.py | 55 ++++++++++++ .../test_stems_reconstruction.py | 49 ++++++++++- .../logic/reconstruction/test_data.py | 74 +++++++++++++++- .../logic/reconstruction/test_manager.py | 62 ++++++++++++-- .../reconstruction/test_reconstruction.py | 30 +++++-- .../sampletones_application/test_startup.py | 6 +- .../ui/panels/reconstruction/test_plot.py | 10 ++- .../reconstruction/test_reconstruction.py | 54 +++++++++++- .../sampletones_core/audio/test_mixing.py | 37 ++++++++ .../reconstructions/naming/test_naming.py | 27 ++++++ .../reconstruction/test_reconstruction.py | 31 +++++++ .../utils/system/reveal/test_file_manager1.py | 74 ++++++++++++++++ .../utils/system/reveal/test_grouped.py | 38 +++++++++ .../utils/system/reveal/test_selection.py | 84 +++++++++++++++++++ .../utils/system/test_paths.py | 36 ++++++++ 42 files changed, 1160 insertions(+), 148 deletions(-) create mode 100644 src/sampletones_application/view_model/reconstruction/paths/__init__.py create mode 100644 src/sampletones_application/view_model/reconstruction/paths/path.py create mode 100644 src/sampletones_application/view_model/reconstruction/paths/state.py create mode 100644 src/sampletones_core/audio/mixing.py create mode 100644 src/sampletones_core/reconstructions/naming/__init__.py create mode 100644 src/sampletones_core/reconstructions/naming/derive.py create mode 100644 src/sampletones_core/reconstructions/naming/protocol.py create mode 100644 src/sampletones_core/reconstructions/naming/rules/__init__.py create mode 100644 src/sampletones_core/reconstructions/naming/rules/common_directory.py create mode 100644 src/sampletones_core/reconstructions/naming/rules/single_source.py create mode 100644 src/sampletones_shared/utils/system/reveal/__init__.py create mode 100644 src/sampletones_shared/utils/system/reveal/file_manager1.py create mode 100644 src/sampletones_shared/utils/system/reveal/grouped.py create mode 100644 src/sampletones_shared/utils/system/reveal/protocol.py create mode 100644 src/sampletones_shared/utils/system/reveal/selection.py create mode 100644 tests/unit/sampletones_core/audio/test_mixing.py create mode 100644 tests/unit/sampletones_core/reconstructions/naming/test_naming.py create mode 100644 tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py create mode 100644 tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py create mode 100644 tests/unit/sampletones_shared/utils/system/reveal/test_selection.py diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index e7ff82bc2..5d978c5e1 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -1,10 +1,11 @@ # Stems reconstruction This document explains how one reconstruction is assigned across several stems. -Consult it when changing the stems assignment algorithm, its configuration, or -the per-stem record a reconstruction carries. The single-sample pipeline this -builds on is described in [Reconstruction](reconstruction.md), and the stored -record in [Reconstructions](../formats/reconstructions.md). +Consult it when changing the stems assignment algorithm, its configuration, the +per-stem record a reconstruction carries, or the way the application loads, +names, and reveals the recorded stems. The single-sample pipeline this builds on +is described in [Reconstruction](reconstruction.md), and the stored record in +[Reconstructions](../formats/reconstructions.md). A stems reconstruction converts several audio stems at once. The stems are mixed and the mix is matched against the instruction library; within each frame, @@ -75,3 +76,31 @@ handed to `Reconstructor.reconstruct_stems` together with the stem paths; it is part of the process rather than of the standard configuration. Per-frame assignment is greedy for now; Viterbi continuity and playback that decides per frame on the recorded streams are future work. + +## The recorded stems in the application + +A stems reconstruction records its stem paths under `audio_filepath` as a tuple, +in entry order; the serialized form carries them in order. The application reads +them through `source_paths`: empty once the reconstruction is detached from its +origin, one path for a single source, the tuple for stems. + +Opening the document loads each recorded stem the way a single source loads +(resampled, normalized and quantized as the configuration asks) and mixes them +with `mix_audios` — padded to the longest stem and summed. The mix is the +original audio the source toggle and the waveform offer, computed fresh on every +load. A recorded stem absent or unreadable on this machine follows the +single-source rule: the whole original is unavailable, the approximation stands +on its own, and the application names the first missing path in its dialog. + +The document's name follows the naming rules in +`sampletones_core.reconstructions.naming`, applied to the recorded paths in +order: a single source names the document after the file's stem, several stems +sharing one directory name it after that directory, and paths sharing no +directory fall back to the `.stn` filename. + +The reconstruction tab's Audio source panel shows one shortened path line per +stem, each line carrying its own full-path tooltip. Locating reveals every +recorded path according to the capability matrix in +[Desktop capabilities](../development/desktop-capabilities.md): one file-manager +window with every stem selected where the file manager supports it, one window +per directory otherwise. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 770c3d43c..c68698e5b 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -701,7 +701,7 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: reconstruction_saveable=self._reconstruction_coordinator.is_saveable(), reconstruction_in_project=self._editing_project_sample(), reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(), - reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None, + reconstruction_audio_recorded=bool(self.reconstruction_manager.source_paths), operation_active=self._is_operation_active(), can_undo=self.history.can_undo, can_redo=self.history.can_redo, @@ -742,7 +742,7 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: reconstruction_saveable=self._reconstruction_coordinator.is_saveable(), reconstruction_in_project=self._editing_project_sample(), reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(), - reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None, + reconstruction_audio_recorded=bool(self.reconstruction_manager.source_paths), operation_active=self._is_operation_active(), can_undo=self.history.can_undo, can_redo=self.history.can_redo, diff --git a/src/sampletones_application/coordinators/original_audio.py b/src/sampletones_application/coordinators/original_audio.py index 69f2e5eb5..4406e0ca3 100644 --- a/src/sampletones_application/coordinators/original_audio.py +++ b/src/sampletones_application/coordinators/original_audio.py @@ -5,7 +5,8 @@ from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger -from sampletones_shared.utils.system.paths import open_path_in_explorer +from sampletones_shared.utils.system.paths import first_missing, to_paths +from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer class OriginalAudioLocator: @@ -34,17 +35,16 @@ def locate(self, filepath: Path) -> None: self._dialogs.show_error(exception, self._language_manager["reconstructions.browser.message.load_error"]) return - if audio_filepath is None: + audio_paths = to_paths(audio_filepath) + if not audio_paths: return - if not isinstance(audio_filepath, Path): - return - - if not audio_filepath.exists(): + missing_path = first_missing(audio_paths) + if missing_path is not None: self._dialogs.show_file_not_found( - audio_filepath, + missing_path, self._language_manager["reconstructions.reconstruction.message.locate_audio_failed"], ) return - open_path_in_explorer(audio_filepath) + open_paths_in_explorer(audio_paths) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index d3ac86b88..5e0f8a893 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -35,7 +35,7 @@ from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from sampletones_shared.types.callback import Callback, VoidCallback -from sampletones_shared.utils.system.paths import get_filename +from sampletones_shared.utils.system.paths import first_missing, get_filename class ReconstructionCoordinator: @@ -291,10 +291,10 @@ def on_reconstruction_loaded(self) -> None: raise RuntimeError("No reconstruction is loaded after loading process") self._audio_device_manager.stop() - audio_filepath = reconstruction_data.reconstruction.audio_filepath - if isinstance(audio_filepath, Path) and not audio_filepath.exists(): + missing_path = first_missing(reconstruction_data.reconstruction.source_paths) + if missing_path is not None: self._dialogs.show_file_not_found( - audio_filepath, + missing_path, self._language_manager["reconstructions.browser.message.audio_file_not_found"], ) diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 7c60ba312..481945c28 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -6,10 +6,11 @@ from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.audio import load_audio +from sampletones_core.audio import load_audio, mix_audios from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.naming.derive import derive_name from sampletones_shared.logger import logger @@ -103,12 +104,13 @@ def _assemble( def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str: """Names the document after its source audio when present, otherwise after the file. - A file-backed reconstruction keeps the audio's name for display and export; a detached + A file-backed reconstruction keeps the audio's name for display and export; several + source paths (stems) name the document through the source-naming rules, and a detached reconstruction (no source audio) falls back to the ``.stn`` filename. """ - audio_filepath = reconstruction.audio_filepath - if isinstance(audio_filepath, Path): - return audio_filepath.stem + source_paths = reconstruction.source_paths + if source_paths: + return derive_name(source_paths, fallback_stem=filepath.stem) return filepath.stem @@ -119,24 +121,32 @@ def _load_original_audio( """Loads the source audio, yielding ``None`` when no usable original exists. A reconstruction detached from its origin (a project sample) records no source path, and a - file-backed reconstruction may point at audio absent or unreadable on this machine. Both - cases yield ``None``; the approximation then stands on its own in playback and the display. + file-backed reconstruction may point at audio absent or unreadable on this machine. Several + recorded paths (stems) mix into one recording, so one unreadable stem costs the whole + original. Every such case yields ``None``; the approximation then stands on its own in + playback and the display. """ - audio_filepath = reconstruction.audio_filepath - if not isinstance(audio_filepath, Path): + source_paths = reconstruction.source_paths + if not source_paths: return None config = reconstruction.config - try: - return load_audio( - path=audio_filepath, - target_sample_rate=config.library.sample_rate, - normalize=config.general.normalize, - quantize=config.general.quantize, - ) - except (FileNotFoundError, IsADirectoryError, PermissionError, OSError): - logger.warning(f"Could not load original audio from '{audio_filepath}'. The original is unavailable") - return None + recordings: List[np.ndarray] = [] + for path in source_paths: + try: + recordings.append( + load_audio( + path=path, + target_sample_rate=config.library.sample_rate, + normalize=config.general.normalize, + quantize=config.general.quantize, + ) + ) + except (FileNotFoundError, IsADirectoryError, PermissionError, OSError): + logger.warning(f"Could not load original audio from '{path}'. The original is unavailable") + return None + + return mix_audios(recordings) def waveform_data(self) -> WaveformData: """Projects the slice of this data the waveform display renders.""" diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 073666b80..df05dacbb 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -1,5 +1,7 @@ +import errno +import os from pathlib import Path -from typing import Optional, Tuple, Union +from typing import Optional, Tuple from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.data import ReconstructionData @@ -11,7 +13,8 @@ from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.serialization import hash_model -from sampletones_shared.utils.system.paths import open_path_in_explorer +from sampletones_shared.utils.system.paths import first_missing +from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer class ReconstructionManager(CallbackMixin): @@ -172,14 +175,19 @@ def close_reconstruction(self) -> None: ) def locate_original_audio(self) -> None: - original_audio_path = self.audio_filepath - if not isinstance(original_audio_path, Path): # to do: support multiple paths + original_audio_paths = self.source_paths + if not original_audio_paths: return - if not original_audio_path.exists(): - raise FileNotFoundError(f"Original audio file '{original_audio_path}' could not be found.") + missing_path = first_missing(original_audio_paths) + if missing_path is not None: + raise FileNotFoundError( + errno.ENOENT, + os.strerror(errno.ENOENT), + str(missing_path), + ) - open_path_in_explorer(original_audio_path) + open_paths_in_explorer(original_audio_paths) @property def current_reconstruction(self) -> Optional[ReconstructionData]: @@ -208,8 +216,8 @@ def is_file_backed(self) -> bool: return self.filepath is not None @property - def audio_filepath(self) -> Optional[Union[Path, Tuple[Path, ...]]]: + def source_paths(self) -> Tuple[Path, ...]: if self._current_reconstruction is None: - return None + return () - return self._current_reconstruction.reconstruction.audio_filepath + return self._current_reconstruction.reconstruction.source_paths diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index bf6ec1dbd..890c38b6f 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,14 +1,18 @@ from pathlib import Path -from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple, Union +from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple import numpy as np from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.view_model.reconstruction.reconstruction import ( - ReconstructionPathState, +from sampletones_application.view_model.reconstruction.paths.path import ( ReconstructionPathViewModel, +) +from sampletones_application.view_model.reconstruction.paths.state import ( + ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) from sampletones_application.view_model.shared.audio_data import AudioData @@ -24,7 +28,11 @@ from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.system.paths import get_filename, open_path_in_explorer +from sampletones_shared.utils.system.paths import ( + first_missing, + get_filename, + open_path_in_explorer, +) class ExportServiceProtocol(Protocol): @@ -162,7 +170,7 @@ def close_reconstruction(self) -> None: self.call(self.on_waveform_cleared) empty_path = ReconstructionPathViewModel( state=ReconstructionPathState.EMPTY, - path="", + paths=(), ) self.call( self.on_view_changed, @@ -397,15 +405,18 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: self._export_service.export_wav(filepath, sample_rate, audio_snapshot) def handle_locate_original_audio(self) -> None: - path = self._reconstruction_manager.audio_filepath - if not isinstance(path, Path): + if not self._reconstruction_manager.source_paths: return try: self._reconstruction_manager.locate_original_audio() except FileNotFoundError: - logger.warning(f"Original audio file could not be found: '{logger.format_path(path)}'") - self.call(self.on_locate_audio_not_found, path) + missing_path = first_missing(self._reconstruction_manager.source_paths) + if missing_path is None: + raise + + logger.warning(f"Original audio file could not be found: '{logger.format_path(missing_path)}'") + self.call(self.on_locate_audio_not_found, missing_path) def open_reconstruction_in_explorer(self) -> None: """Reveals the loaded reconstruction's own file in the OS file manager.""" @@ -456,7 +467,7 @@ def _build_path_view_models( """ reconstruction_file = self._build_file_path_view_model(reconstruction_data.filepath) original_audio = self._build_audio_path_view_model( - reconstruction_data.reconstruction.audio_filepath, + reconstruction_data.reconstruction.source_paths, reconstruction_data.original_audio, ) return reconstruction_file, original_audio @@ -468,42 +479,30 @@ def _build_file_path_view_model( if filepath is None: return ReconstructionPathViewModel( state=ReconstructionPathState.NOT_APPLICABLE, - path="", + paths=(), ) return ReconstructionPathViewModel( state=ReconstructionPathState.AVAILABLE, - path=str(filepath), + paths=(str(filepath),), ) @staticmethod def _build_audio_path_view_model( - audio_filepath: Optional[Union[Path, Tuple[Path, ...]]], + source_paths: Tuple[Path, ...], original_audio: Optional[np.ndarray], ) -> ReconstructionPathViewModel: """Reports the original-audio location, treating a recorded path with unusable content the same as a missing one, so the source toggle and waveform agree with what actually loaded.""" - if audio_filepath is None: - return ReconstructionPathViewModel( - state=ReconstructionPathState.NOT_APPLICABLE, - path="", - ) - - if original_audio is None: + if source_paths and original_audio is None: return ReconstructionPathViewModel( state=ReconstructionPathState.NOT_FOUND, - path="", - ) - - if isinstance(audio_filepath, Path): - return ReconstructionPathViewModel( - state=ReconstructionPathState.AVAILABLE, - path=str(audio_filepath), + paths=(), ) return ReconstructionPathViewModel( - state=ReconstructionPathState.AVAILABLE, - path=", ".join(str(path) for path in audio_filepath), + state=ReconstructionPathState.from_source_paths(source_paths), + paths=tuple(str(path) for path in source_paths), ) @property diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 59a65183d..564e7d365 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -1,9 +1,10 @@ -from typing import Callable, Optional +from typing import Callable, List, Optional, Tuple import dearpygui.dearpygui as dpg from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, @@ -18,9 +19,13 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_application.view_model.reconstruction.reconstruction import ( - ReconstructionPathState, +from sampletones_application.view_model.reconstruction.paths.path import ( ReconstructionPathViewModel, +) +from sampletones_application.view_model.reconstruction.paths.state import ( + ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) from sampletones_core.constants.enums import AudioSourceType @@ -44,6 +49,7 @@ def __init__( self._reconstruction_file_path: GUIPathText self._original_audio_path: GUIPathText + self._original_audio_stem_paths: List[GUIPathText] = [] self.on_audio_source_changed: Optional[Callable[[AudioSourceType], None]] = None @@ -79,7 +85,7 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: self._reconstruction_file_path, view_model.reconstruction_file, ) - self._render_path(self._original_audio_path, view_model.original_audio) + self._render_original_audio(view_model.original_audio) dpg_configure_item( TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, @@ -91,6 +97,45 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: self._lbl_reconstruction_radio, ) + def _render_original_audio(self, view_model: ReconstructionPathViewModel) -> None: + """Draws the original-audio location: one line per recorded stem, one line otherwise.""" + if view_model.state is ReconstructionPathState.MULTIPLE: + self._render_stem_paths(view_model.paths) + return + + self._clear_stem_paths() + self._render_path(self._original_audio_path, view_model) + + def _render_stem_paths(self, paths: Tuple[str, ...]) -> None: + while len(self._original_audio_stem_paths) > len(paths): + self._original_audio_stem_paths.pop().destroy() + + for index, path in enumerate(paths): + widget = ( + self._original_audio_stem_paths[index] + if index < len(self._original_audio_stem_paths) + else self._create_stem_path(index) + ) + widget.set_path(path) + + def _clear_stem_paths(self) -> None: + while len(self._original_audio_stem_paths) > 1: + self._original_audio_stem_paths.pop().destroy() + + def _create_stem_path(self, index: int) -> GUIPathText: + widget = GUIPathText( + tag=compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO, str(index)), + path=None, + parent=self._body_container, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_path_status, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + self._original_audio_stem_paths.append(widget) + return widget + def _render_path( self, path_widget: GUIPathText, @@ -135,6 +180,7 @@ def _create_path_display(self) -> None: font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) + self._original_audio_stem_paths.append(self._original_audio_path) self._reconstruction_file_path.set_status("", self._path_status_color) self._original_audio_path.set_status("", self._path_status_color) diff --git a/src/sampletones_application/view_model/reconstruction/paths/__init__.py b/src/sampletones_application/view_model/reconstruction/paths/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/view_model/reconstruction/paths/path.py b/src/sampletones_application/view_model/reconstruction/paths/path.py new file mode 100644 index 000000000..052abe6be --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/paths/path.py @@ -0,0 +1,18 @@ +from typing import Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.reconstruction.paths.state import ReconstructionPathState + + +class ReconstructionPathViewModel(BaseModel, frozen=True): + state: ReconstructionPathState + paths: Tuple[str, ...] = () + + @property + def path(self) -> str: + """The single path the location carries, empty while it holds none or several.""" + if len(self.paths) == 1: + return self.paths[0] + + return "" diff --git a/src/sampletones_application/view_model/reconstruction/paths/state.py b/src/sampletones_application/view_model/reconstruction/paths/state.py new file mode 100644 index 000000000..8889b27ad --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/paths/state.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Final, Tuple + + +class ReconstructionPathState(StrEnum): + """Whether a path can be shown for a reconstruction location. + + ``AVAILABLE`` carries one resolvable path. ``MULTIPLE`` carries several paths recorded + for one location (a stems reconstruction's source files). ``NOT_FOUND`` marks a + recorded path whose file is absent on this machine. ``NOT_APPLICABLE`` marks a + location that a reconstruction does not have (a sequencer sample keeps no file + locations). ``EMPTY`` is the resting state when no reconstruction is loaded. + """ + + AVAILABLE = "available" + MULTIPLE = "multiple" + NOT_FOUND = "not_found" + NOT_APPLICABLE = "not_applicable" + EMPTY = "empty" + + @classmethod + def from_source_paths( + cls, + source_paths: Tuple[Path, ...], + ) -> ReconstructionPathState: + """Returns the state a recorded location takes: not-applicable with no path, + available with one, multiple with several.""" + if not source_paths: + return cls.NOT_APPLICABLE + + if len(source_paths) == 1: + return cls.AVAILABLE + + return cls.MULTIPLE + + +RECORDED_PATH_STATES: Final[Tuple[ReconstructionPathState, ...]] = ( + ReconstructionPathState.AVAILABLE, + ReconstructionPathState.MULTIPLE, + ReconstructionPathState.NOT_FOUND, +) + +PLAYABLE_PATH_STATES: Final[Tuple[ReconstructionPathState, ...]] = ( + ReconstructionPathState.AVAILABLE, + ReconstructionPathState.MULTIPLE, +) diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index 5d7ed84a1..64ce22c7c 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -1,37 +1,17 @@ -from enum import StrEnum -from typing import Final, FrozenSet, Tuple +from typing import FrozenSet from pydantic import BaseModel from sampletones_core.constants.enums import ChannelName - -class ReconstructionPathState(StrEnum): - """Whether a path can be shown for a reconstruction location. - - ``AVAILABLE`` carries a resolvable path. ``NOT_FOUND`` marks a recorded path whose - file is absent on this machine. ``NOT_APPLICABLE`` marks a location that a - reconstruction does not have (a sequencer sample keeps no file locations). - ``EMPTY`` is the resting state when no reconstruction is loaded. - """ - - AVAILABLE = "available" - NOT_FOUND = "not_found" - NOT_APPLICABLE = "not_applicable" - EMPTY = "empty" - - -RECORDED_PATH_STATES: Final[Tuple[ReconstructionPathState, ...]] = ( - ReconstructionPathState.AVAILABLE, - ReconstructionPathState.NOT_FOUND, +from .paths.path import ReconstructionPathViewModel +from .paths.state import ( + PLAYABLE_PATH_STATES, + RECORDED_PATH_STATES, + ReconstructionPathState, ) -class ReconstructionPathViewModel(BaseModel, frozen=True): - state: ReconstructionPathState - path: str - - class ReconstructionViewModel(BaseModel, frozen=True): """What the reconstruction view renders, including which channels the waveform offers. @@ -50,7 +30,7 @@ class ReconstructionViewModel(BaseModel, frozen=True): def audio_source_enabled(self) -> bool: """The source toggle offers the original audio once its file is present on disk; until then playback stays on the reconstruction.""" - return self.original_audio.state is ReconstructionPathState.AVAILABLE + return self.original_audio.state in PLAYABLE_PATH_STATES @property def locate_audio_enabled(self) -> bool: diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index 436ef3c6f..42a3891c8 100644 --- a/src/sampletones_core/audio/__init__.py +++ b/src/sampletones_core/audio/__init__.py @@ -1,6 +1,7 @@ from .device import AudioDevice, CurrentDevice from .io import load_audio, read_wave, write_wave from .manager import CHANNELS, FORMAT, AudioDeviceManager +from .mixing import mix_audios from .processing import ( active_frame_level, amplitude_to_decibels, @@ -33,6 +34,7 @@ "interpolate", "load_audio", "minmax_decimate", + "mix_audios", "normalize", "quantize", "read_wave", diff --git a/src/sampletones_core/audio/mixing.py b/src/sampletones_core/audio/mixing.py new file mode 100644 index 000000000..720483a4d --- /dev/null +++ b/src/sampletones_core/audio/mixing.py @@ -0,0 +1,25 @@ +from typing import List + +import numpy as np + +from sampletones_shared.utils.arrays import pad + + +def mix_audios(audios: List[np.ndarray]) -> np.ndarray: + """Sums the recordings, each padded to the longest one's length. + + A single recording is the mix itself, returned as is. A mix holds a sample for + every position the longest recording covers, so a recording shorter than the + others contributes silence for the rest. + """ + if not audios: + raise ValueError("At least one recording is required") + + if len(audios) == 1: + return audios[0] + + max_length = max(audio.shape[0] for audio in audios) + return sum( + (pad(audio, 0, max_length) for audio in audios), + np.zeros(max_length, dtype=np.float64), + ) diff --git a/src/sampletones_core/reconstructions/naming/__init__.py b/src/sampletones_core/reconstructions/naming/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/reconstructions/naming/derive.py b/src/sampletones_core/reconstructions/naming/derive.py new file mode 100644 index 000000000..3164359c3 --- /dev/null +++ b/src/sampletones_core/reconstructions/naming/derive.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import Final, Tuple, Type + +from .protocol import NameRule +from .rules.common_directory import CommonDirectoryRule +from .rules.single_source import SingleSourceRule + +SOURCE_RULES: Final[Tuple[Type[NameRule], ...]] = ( + SingleSourceRule, + CommonDirectoryRule, +) + + +def derive_name( + source_paths: Tuple[Path, ...], + *, + fallback_stem: str, +) -> str: + """Names a reconstruction from its sources, falling back through the rule hierarchy. + + The first rule that applies derives the name: one source names after itself, + several sources sharing one directory name after that directory, and the + caller-supplied fallback stem names every other set of sources. + """ + for rule_class in SOURCE_RULES: + rule = rule_class() + if rule.applies(source_paths): + return rule.derive(source_paths) + + return fallback_stem diff --git a/src/sampletones_core/reconstructions/naming/protocol.py b/src/sampletones_core/reconstructions/naming/protocol.py new file mode 100644 index 000000000..d3377661a --- /dev/null +++ b/src/sampletones_core/reconstructions/naming/protocol.py @@ -0,0 +1,10 @@ +from pathlib import Path +from typing import Protocol, Tuple + + +class NameRule(Protocol): + """One step of the naming hierarchy: it states which source sets it names and the name it derives.""" + + def applies(self, source_paths: Tuple[Path, ...]) -> bool: ... + + def derive(self, source_paths: Tuple[Path, ...]) -> str: ... diff --git a/src/sampletones_core/reconstructions/naming/rules/__init__.py b/src/sampletones_core/reconstructions/naming/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/reconstructions/naming/rules/common_directory.py b/src/sampletones_core/reconstructions/naming/rules/common_directory.py new file mode 100644 index 000000000..c8d060f1f --- /dev/null +++ b/src/sampletones_core/reconstructions/naming/rules/common_directory.py @@ -0,0 +1,12 @@ +from pathlib import Path +from typing import Tuple + + +class CommonDirectoryRule: + """Names a reconstruction after the directory all of its sources share.""" + + def applies(self, source_paths: Tuple[Path, ...]) -> bool: + return len(source_paths) > 1 and len({path.parent for path in source_paths}) == 1 + + def derive(self, source_paths: Tuple[Path, ...]) -> str: + return source_paths[0].parent.name diff --git a/src/sampletones_core/reconstructions/naming/rules/single_source.py b/src/sampletones_core/reconstructions/naming/rules/single_source.py new file mode 100644 index 000000000..994acc08f --- /dev/null +++ b/src/sampletones_core/reconstructions/naming/rules/single_source.py @@ -0,0 +1,12 @@ +from pathlib import Path +from typing import Tuple + + +class SingleSourceRule: + """Names a reconstruction after its one source recording.""" + + def applies(self, source_paths: Tuple[Path, ...]) -> bool: + return len(source_paths) == 1 + + def derive(self, source_paths: Tuple[Path, ...]) -> str: + return source_paths[0].stem diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index a0eb1ace5..ae89b8be3 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -52,6 +52,7 @@ from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.arrays import pad from sampletones_shared.utils.serialization import load_binary, serialize_array +from sampletones_shared.utils.system.paths import to_paths RECONSTRUCTION_DATA_CONTRACT: Final[MetadataContract] = MetadataContract( label="Reconstruction data", @@ -73,7 +74,10 @@ class Reconstruction(DataModel): ) audio_filepath: Optional[Union[Path, Tuple[Path, ...]]] = Field( ..., - description="Location of the source audio: one path for a single source, the stem paths for a stems reconstruction, and None once detached from the local origin", + description=( + "Location of the source audio: one path for a single source, the stem paths " + "for a stems reconstruction, and None once detached from the local origin" + ), ) config: Config = Field( ..., @@ -101,6 +105,11 @@ class Reconstruction(DataModel): description="Normalization coefficient used during reconstruction", ) + @cached_property + def source_paths(self) -> Tuple[Path, ...]: + """The recorded source audio paths, empty while the reconstruction is detached.""" + return to_paths(self.audio_filepath) + @cached_property def approximations(self) -> Dict[ChannelName, np.ndarray]: return {item.channel_name: item.approximation for item in self.approximations_data} @@ -312,6 +321,7 @@ def detach_source(self) -> None: origin, so a saved project stays portable. """ self.audio_filepath = None + self.__dict__.pop("source_paths", None) def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: """Returns a copy retuned to ``nes_frequency`` by re-rendering its audio. diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 9c09af7ae..636e8758e 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -3,7 +3,7 @@ import numpy as np -from sampletones_core.audio import active_frame_level, load_audio +from sampletones_core.audio import active_frame_level, load_audio, mix_audios from sampletones_core.configs import Config from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL from sampletones_core.constants.enums import ChannelName @@ -27,7 +27,6 @@ from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker from sampletones_shared.exceptions import NoLibraryDataError from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.arrays import pad from sampletones_shared.utils.system.paths import to_path @@ -165,7 +164,7 @@ def reconstruct_stems( checked_paths.append(to_path(path)) audios = [self.load_audio(path) for path in checked_paths] - mix = self._mix_audios(audios) + mix = mix_audios(audios) coefficient = self.get_coefficient(mix) self.reset_generators() covered = {channel for entry in stems_config.entries for channel in entry.channels} @@ -237,17 +236,6 @@ def reconstruct_stems( stems_data=stems_data, ) - def _mix_audios(self, audios: List[np.ndarray]) -> np.ndarray: - """Sums the stem audios, each padded to the longest stem's length.""" - if not audios: - raise ValueError("At least one stem audio is required") - - max_length = max(audio.shape[0] for audio in audios) - return sum( - (pad(audio, 0, max_length) for audio in audios), - np.zeros(max_length, dtype=np.float64), - ) - def load_audio(self, path: Path) -> np.ndarray: """Loads and preconditions the audio at ``path`` for reconstruction. diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index 3b3ce37fa..fe2176d63 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -1,7 +1,7 @@ import os import subprocess from pathlib import Path -from typing import Final, Optional +from typing import Final, Optional, Sequence, Tuple, Union from sampletones_shared.types.path import GeneralPathlike, Pathlike @@ -198,6 +198,52 @@ def get_directory(path: Pathlike) -> Path: return path if path.is_dir() else path.parent +def to_paths( + location: Optional[Union[Pathlike, Tuple[Pathlike, ...]]], +) -> Tuple[Path, ...]: + """ + Returns the recorded location as a tuple of paths. + + One path becomes a one-tuple, several paths stay as they are, and an absent + location becomes an empty tuple — one shape for every form a recorded source + takes. + + Args: + location: The recorded location, or ``None``. + + Returns: + Tuple[Path, ...]: The paths in order, empty for an absent location. + """ + if location is None: + return () + + if isinstance(location, (str, Path, os.PathLike)): + return (to_path(location),) + + return tuple(to_path(path) for path in location) + + +def first_missing(paths: Sequence[Pathlike]) -> Optional[Path]: + """ + Returns the first path that names no file on disk, preserving the given order. + + Answers ``None`` when every path stands. The order matters to callers that report + one missing location among several. + + Args: + paths: The paths to check, in report order. + + Returns: + Optional[Path]: The first absent path, or ``None`` when every path stands. + """ + for path in paths: + normalized = to_path(path) + if not normalized.exists(): + return normalized + + return None + + def open_directory_in_explorer_linux(path: Path) -> None: """ Opens a directory in the default Linux file manager using xdg-open. diff --git a/src/sampletones_shared/utils/system/reveal/__init__.py b/src/sampletones_shared/utils/system/reveal/__init__.py new file mode 100644 index 000000000..afb7a9220 --- /dev/null +++ b/src/sampletones_shared/utils/system/reveal/__init__.py @@ -0,0 +1,5 @@ +from .selection import open_paths_in_explorer + +__all__ = [ + "open_paths_in_explorer", +] diff --git a/src/sampletones_shared/utils/system/reveal/file_manager1.py b/src/sampletones_shared/utils/system/reveal/file_manager1.py new file mode 100644 index 000000000..5cd7077f3 --- /dev/null +++ b/src/sampletones_shared/utils/system/reveal/file_manager1.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Final, List, Tuple, Type + +from jeepney import ( + AuthenticationError, + DBusAddress, + DBusErrorResponse, + new_method_call, +) +from jeepney.io.blocking import open_dbus_connection + +FILE_MANAGER_BUS_NAME: Final[str] = "org.freedesktop.FileManager1" +FILE_MANAGER_OBJECT_PATH: Final[str] = "/org/freedesktop/FileManager1" +FILE_MANAGER_INTERFACE: Final[str] = "org.freedesktop.FileManager1" +SHOW_ITEMS_METHOD: Final[str] = "ShowItems" +SHOW_ITEMS_SIGNATURE: Final[str] = "ass" +EMPTY_STARTUP_ID: Final[str] = "" +SESSION_BUS: Final[str] = "SESSION" + +FILE_MANAGER_ADDRESS: Final[DBusAddress] = DBusAddress( + FILE_MANAGER_OBJECT_PATH, + bus_name=FILE_MANAGER_BUS_NAME, + interface=FILE_MANAGER_INTERFACE, +) + +OUT_OF_REACH_ERRORS: Final[Tuple[Type[Exception], ...]] = ( + KeyError, + RuntimeError, + OSError, + AuthenticationError, + DBusErrorResponse, +) + + +class FileManager1Backend: + """ + Reveals files through the desktop's ``org.freedesktop.FileManager1`` service. + + One ``ShowItems`` call opens a single file-manager window with every file selected, + whichever directories the files live in. The method takes the file URIs followed by a + startup id; an empty startup id marks the call as independent of any launch context, + which file managers accept. + """ + + def open(self, paths: Tuple[Path, ...]) -> None: + uris: List[str] = [path.as_uri() for path in paths] + with open_dbus_connection(bus=SESSION_BUS) as connection: + connection.send_and_get_reply( + new_method_call( + FILE_MANAGER_ADDRESS, + SHOW_ITEMS_METHOD, + SHOW_ITEMS_SIGNATURE, + (uris, EMPTY_STARTUP_ID), + ) + ) + + @classmethod + def answers(cls) -> bool: + """ + Reports whether the service answers a probe call on the session bus. + + The probe sends ``ShowItems`` with an empty URI list, which the service answers once + it accepts the interface. ``open_dbus_connection`` opens the session bus directly, so + the probe reaches the same service :meth:`open` uses. + """ + try: + with open_dbus_connection(bus=SESSION_BUS) as connection: + connection.send_and_get_reply( + new_method_call( + FILE_MANAGER_ADDRESS, + SHOW_ITEMS_METHOD, + SHOW_ITEMS_SIGNATURE, + ([], EMPTY_STARTUP_ID), + ) + ) + except OUT_OF_REACH_ERRORS: + return False + + return True diff --git a/src/sampletones_shared/utils/system/reveal/grouped.py b/src/sampletones_shared/utils/system/reveal/grouped.py new file mode 100644 index 000000000..9dd7f7946 --- /dev/null +++ b/src/sampletones_shared/utils/system/reveal/grouped.py @@ -0,0 +1,24 @@ +from pathlib import Path +from typing import Tuple + +from sampletones_shared.utils.system.paths import open_path_in_explorer + + +def distinct_parents(paths: Tuple[Path, ...]) -> Tuple[Path, ...]: + """ + Returns the directories holding the paths, each once and in first-seen order. + """ + return tuple(dict.fromkeys(path.parent for path in paths)) + + +class GroupedDirectoryBackend: + """ + Reveals files by opening the directories that hold them. + + Files sharing a directory are revealed together in that directory's window, one window + per directory, which suits file managers that open directories. + """ + + def open(self, paths: Tuple[Path, ...]) -> None: + for directory in distinct_parents(paths): + open_path_in_explorer(directory) diff --git a/src/sampletones_shared/utils/system/reveal/protocol.py b/src/sampletones_shared/utils/system/reveal/protocol.py new file mode 100644 index 000000000..b66306e97 --- /dev/null +++ b/src/sampletones_shared/utils/system/reveal/protocol.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Protocol, Tuple + + +class RevealBackend(Protocol): + """The file-revealing surface that callers depend on. + + Every implementation opens the desktop's file manager on the given files, so callers + reveal a set of paths through this type and stay independent of the environment that + draws the windows. + """ + + def open(self, paths: Tuple[Path, ...]) -> None: ... diff --git a/src/sampletones_shared/utils/system/reveal/selection.py b/src/sampletones_shared/utils/system/reveal/selection.py new file mode 100644 index 000000000..00f49721a --- /dev/null +++ b/src/sampletones_shared/utils/system/reveal/selection.py @@ -0,0 +1,55 @@ +import importlib.util +from typing import Final, Sequence + +from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.system.paths import open_path_in_explorer, to_path +from sampletones_shared.utils.system.system import System + +from .grouped import GroupedDirectoryBackend +from .protocol import RevealBackend + +JEEPNEY_MODULE: Final[str] = "jeepney" + + +def open_paths_in_explorer(paths: Sequence[Pathlike]) -> None: + """ + Reveals the given paths in the system's default file explorer. + + A single path opens the explorer on it the way :func:`open_path_in_explorer` does. Several + paths go through the environment's reveal backend: a Linux session whose file manager + offers ``org.freedesktop.FileManager1`` opens one window with every file selected, and any + other environment opens one window per directory holding the files. + + Args: + paths: The file paths to reveal. + + Raises: + ValueError: If at least one path is required. + """ + normalized = tuple(to_path(path) for path in paths) + if not normalized: + raise ValueError("At least one path is required") + + if len(normalized) == 1: + open_path_in_explorer(normalized[0]) + return + + select_reveal_backend().open(normalized) + + +def select_reveal_backend() -> RevealBackend: + """ + Returns the reveal backend that fits the running environment. + + Linux sessions whose file manager answers the ``FileManager1`` probe reveal every path in + one window with all of them selected; every other environment reveals one window per + directory holding the paths. The service is probed at selection time, so the grouped + backend serves sessions where it is absent. + """ + if System.current() == System.LINUX and importlib.util.find_spec(JEEPNEY_MODULE) is not None: + from .file_manager1 import FileManager1Backend + + if FileManager1Backend.answers(): + return FileManager1Backend() + + return GroupedDirectoryBackend() diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 44dd36bc4..b0e455321 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -4,7 +4,8 @@ import numpy as np import pytest -from sampletones_core.audio import write_wave +from sampletones_application.logic.reconstruction.data import ReconstructionData +from sampletones_core.audio import load_audio, mix_audios, write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions import Reconstructor @@ -85,3 +86,49 @@ def test_requires_one_path_per_entry(self, tmp_path: Path) -> None: [tmp_path / "only_one.wav"], _stems_config(), ) + + +class TestStemsOriginalAudio: + def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + + sample_rate = config.library.sample_rate + count = int(sample_rate * _DURATION_SECONDS) + time = np.arange(count) / sample_rate + tone = 0.5 * np.sin(2 * np.pi * _TONE_FREQUENCY * time) + rng = np.random.default_rng(93) + noise = rng.uniform(-0.3, 0.3, count) + + tone_path = tmp_path / "tone.wav" + noise_path = tmp_path / "noise.wav" + write_wave(tone_path, sample_rate, tone) + write_wave(noise_path, sample_rate, noise) + + reconstruction = reconstructor.reconstruct_stems( + [tone_path, noise_path], + _stems_config(), + ) + assert reconstruction is not None + + save_path = tmp_path / "stems.stn" + reconstruction.save(save_path) + + data = ReconstructionData.load(save_path) + + assert data.reconstruction.source_paths == (tone_path, noise_path) + assert data.name == tmp_path.name + load_options = { + "target_sample_rate": config.library.sample_rate, + "normalize": config.general.normalize, + "quantize": config.general.quantize, + } + expected = mix_audios( + [ + load_audio(path=tone_path, **load_options), + load_audio(path=noise_path, **load_options), + ] + ) + assert data.original_audio is not None + np.testing.assert_allclose(data.original_audio, expected) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index e684490f0..6b887bb9c 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -4,7 +4,7 @@ import numpy as np from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_core.audio import write_wave +from sampletones_core.audio import load_audio, mix_audios, write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @@ -81,6 +81,48 @@ def test_loads_original_audio_when_source_file_is_available( assert data.original_audio is not None + def test_mixes_several_recorded_paths_into_the_original( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + config = Config() + first = tmp_path / "kick.wav" + second = tmp_path / "snare.wav" + write_wave(first, config.library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + write_wave(second, config.library.sample_rate, np.ones(64, dtype=np.float32) * 0.25) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (first, second)}) + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + load_options = { + "target_sample_rate": config.library.sample_rate, + "normalize": config.general.normalize, + "quantize": config.general.quantize, + } + expected = mix_audios( + [ + load_audio(path=first, **load_options), + load_audio(path=second, **load_options), + ] + ) + assert data.original_audio is not None + np.testing.assert_allclose(data.original_audio, expected) + + def test_one_unreadable_stem_costs_the_whole_original( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + first = tmp_path / "kick.wav" + missing = tmp_path / "gone.wav" + write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (first, missing)}) + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + assert data.original_audio is None + class TestReconstructionDataLoad: def test_load_round_trips_reconstruction( @@ -173,6 +215,36 @@ def test_names_after_the_source_audio_when_present( assert reconstruction.audio_filepath is not None assert copy.name == reconstruction.audio_filepath.stem + def test_names_after_the_shared_directory_of_stems( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + drums = tmp_path / "drums" + drums.mkdir() + stems = (drums / "kick.wav", drums / "snare.wav") + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": stems}) + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + copy = data.detached_copy(tmp_path / "lead.stn") + + assert copy.name == "drums" + + def test_names_after_the_file_when_stems_share_no_directory( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + (tmp_path / "one").mkdir() + (tmp_path / "two").mkdir() + stems = (tmp_path / "one" / "kick.wav", tmp_path / "two" / "snare.wav") + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": stems}) + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + copy = data.detached_copy(tmp_path / "lead.stn") + + assert copy.name == "lead" + def test_reuses_the_already_loaded_original_audio( self, reconstruction_factory: Callable[[], Reconstruction], diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index 3ed935c0f..e33d67501 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -1,11 +1,12 @@ from pathlib import Path from typing import Callable -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import numpy as np import pytest from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import PulseInstruction @@ -283,14 +284,14 @@ def test_close_marks_session_closed( reconstruction_manager.close_reconstruction() assert not reconstruction_manager.session.is_loaded - def test_close_resets_audio_filepath_to_none( + def test_close_resets_source_paths_to_empty( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") reconstruction_manager.close_reconstruction() - assert reconstruction_manager.audio_filepath is None + assert reconstruction_manager.source_paths == () class TestReconstructionManagerProperties: @@ -311,14 +312,14 @@ def test_filepath_property_is_none_for_in_memory_reconstruction( reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") assert reconstruction_manager.filepath is None - def test_audio_filepath_returns_reconstruction_audio_filepath( + def test_source_paths_return_reconstruction_source_paths( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") - assert reconstruction_manager.audio_filepath == reconstruction.audio_filepath + assert reconstruction_manager.source_paths == reconstruction.source_paths def test_current_features_is_populated_after_load( self, @@ -342,11 +343,11 @@ def test_filepath_is_none_when_nothing_loaded( ) -> None: assert reconstruction_manager.filepath is None - def test_audio_filepath_is_none_when_nothing_loaded( + def test_source_paths_are_empty_when_nothing_loaded( self, reconstruction_manager: ReconstructionManager, ) -> None: - assert reconstruction_manager.audio_filepath is None + assert reconstruction_manager.source_paths == () class TestReconstructionManagerMarkUpdated: @@ -393,3 +394,50 @@ def test_locate_audio_returns_silently_when_nothing_loaded( reconstruction_manager: ReconstructionManager, ) -> None: reconstruction_manager.locate_original_audio() + + def test_locate_audio_opens_the_recorded_paths( + self, + reconstruction_manager: ReconstructionManager, + tmp_path: Path, + ) -> None: + first = tmp_path / "kick.wav" + second = tmp_path / "snare.wav" + write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32)) + write_wave(second, Config().library.sample_rate, np.ones(64, dtype=np.float32)) + reconstruction = Reconstruction.create( + approximation=np.zeros(64, dtype=np.float32), + approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, + config=Config(), + coefficient=1.0, + audio_filepath=(first, second), + ) + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + + with patch("sampletones_application.logic.reconstruction.manager.open_paths_in_explorer") as open_paths: + reconstruction_manager.locate_original_audio() + + open_paths.assert_called_once_with((first, second)) + + def test_locate_audio_reports_the_first_missing_path( + self, + reconstruction_manager: ReconstructionManager, + tmp_path: Path, + ) -> None: + present = tmp_path / "kick.wav" + missing = tmp_path / "gone.wav" + write_wave(present, Config().library.sample_rate, np.ones(64, dtype=np.float32)) + reconstruction = Reconstruction.create( + approximation=np.zeros(64, dtype=np.float32), + approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, + config=Config(), + coefficient=1.0, + audio_filepath=(present, missing), + ) + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + + with pytest.raises(FileNotFoundError) as raised: + reconstruction_manager.locate_original_audio() + + assert raised.value.filename == str(missing) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index b367f2a92..7e41399e3 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -11,8 +11,10 @@ from sampletones_application.logic.reconstruction.reconstruction import ( ReconstructionPanelLogic, ) -from sampletones_application.view_model.reconstruction.reconstruction import ( +from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) from sampletones_core.audio import write_wave @@ -224,16 +226,32 @@ class AudioPathCase(BaseRegularTestCase): @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) def test_audio_path_state_follows_loaded_content(self, case: AudioPathCase) -> None: - audio_filepath = Path("/songs/source.wav") if case.has_filepath else None + source_paths = (Path("/songs/source.wav"),) if case.has_filepath else () original_audio = np.zeros(4, dtype=np.float32) if case.has_content else None view_model = ReconstructionPanelLogic._build_audio_path_view_model( - audio_filepath, + source_paths, original_audio, ) assert view_model.state is case.expected + def test_stem_paths_report_multiple_state(self) -> None: + stem_paths = ( + Path("/stems/drums/kick.wav"), + Path("/stems/drums/snare.wav"), + ) + original_audio = np.zeros(4, dtype=np.float32) + + view_model = ReconstructionPanelLogic._build_audio_path_view_model( + stem_paths, + original_audio, + ) + + assert view_model.state is ReconstructionPathState.MULTIPLE + assert view_model.paths == tuple(str(path) for path in stem_paths) + assert view_model.path == "" + class TestReconstructionPanelLogicUpdate: def test_update_with_no_data_is_no_op( @@ -830,12 +848,12 @@ def test_set_audio_source_with_no_data_emits_none( class TestReconstructionPanelLogicLocateAudio: - def test_no_audio_filepath_skips_locating( + def test_no_source_paths_skips_locating( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, ) -> None: - mock_reconstruction_manager.audio_filepath = None + mock_reconstruction_manager.source_paths = () panel_logic.handle_locate_original_audio() mock_reconstruction_manager.locate_original_audio.assert_not_called() @@ -846,7 +864,7 @@ def test_missing_audio_file_fires_on_locate_audio_not_found( tmp_path: Path, ) -> None: missing = tmp_path / "ghost.wav" - mock_reconstruction_manager.audio_filepath = missing + mock_reconstruction_manager.source_paths = (missing,) mock_reconstruction_manager.locate_original_audio.side_effect = FileNotFoundError callback = MagicMock() panel_logic.on_locate_audio_not_found = callback diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index f3d53a4b4..264d5db20 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -306,12 +306,12 @@ def test_adding_open_document_keeps_its_source_audio( ) -> None: self._open_file_backed_reconstruction(app, reconstruction_factory, tmp_path) app.project_controller.new() - source_before = app.reconstruction_manager.audio_filepath + source_before = app.reconstruction_manager.source_paths app._add_current_reconstruction_to_sequencer() - assert source_before is not None - assert app.reconstruction_manager.audio_filepath == source_before + assert source_before + assert app.reconstruction_manager.source_paths == source_before assert app._build_menu_bar_viewmodel().locate_audio_enabled def test_embedded_sample_is_a_detached_copy( diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py index d6fa821c7..4566f66b3 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -7,9 +7,13 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) -from sampletones_application.view_model.reconstruction.reconstruction import ( - ReconstructionPathState, +from sampletones_application.view_model.reconstruction.paths.path import ( ReconstructionPathViewModel, +) +from sampletones_application.view_model.reconstruction.paths.state import ( + ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) from sampletones_core.constants.enums import ChannelName @@ -79,7 +83,7 @@ def _view_model( playing: FrozenSet[ChannelName], selected: FrozenSet[ChannelName], ) -> ReconstructionViewModel: - empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="") + empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()) return ReconstructionViewModel( reconstruction_loaded=True, playing_channels=playing, diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 5dfd920f9..715abb8f9 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -1,10 +1,15 @@ from dataclasses import dataclass +from pathlib import Path import pytest -from sampletones_application.view_model.reconstruction.reconstruction import ( - ReconstructionPathState, +from sampletones_application.view_model.reconstruction.paths.path import ( ReconstructionPathViewModel, +) +from sampletones_application.view_model.reconstruction.paths.state import ( + ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -28,6 +33,14 @@ class EnablementCase: locate_audio_enabled=True, show_locate_audio_hint=False, ), + EnablementCase( + "stems_recorded", + original_audio_state=ReconstructionPathState.MULTIPLE, + reconstruction_loaded=True, + audio_source_enabled=True, + locate_audio_enabled=True, + show_locate_audio_hint=False, + ), EnablementCase( "audio_file_moved", original_audio_state=ReconstructionPathState.NOT_FOUND, @@ -73,10 +86,43 @@ def test_enablement_follows_original_audio_state( reconstruction_loaded=case.reconstruction_loaded, playing_channels=frozenset(), selected_channels=frozenset(), - reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path=""), - original_audio=ReconstructionPathViewModel(state=case.original_audio_state, path=""), + reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()), + original_audio=ReconstructionPathViewModel(state=case.original_audio_state, paths=()), ) assert view_model.audio_source_enabled is case.audio_source_enabled assert view_model.locate_audio_enabled is case.locate_audio_enabled assert view_model.show_locate_audio_hint is case.show_locate_audio_hint + + +class TestReconstructionPathViewModelPath: + def test_single_path_is_the_location(self) -> None: + view_model = ReconstructionPathViewModel( + state=ReconstructionPathState.AVAILABLE, + paths=("/songs/source.wav",), + ) + + assert view_model.path == "/songs/source.wav" + + def test_several_paths_leave_no_single_location(self) -> None: + view_model = ReconstructionPathViewModel( + state=ReconstructionPathState.MULTIPLE, + paths=("/a/one.wav", "/b/two.wav"), + ) + + assert view_model.path == "" + + +class TestReconstructionPathStateFromSourcePaths: + def test_no_paths_are_not_applicable(self) -> None: + assert ReconstructionPathState.from_source_paths(()) is ReconstructionPathState.NOT_APPLICABLE + + def test_one_path_is_available(self) -> None: + assert ( + ReconstructionPathState.from_source_paths((Path("/songs/source.wav"),)) is ReconstructionPathState.AVAILABLE + ) + + def test_several_paths_are_multiple(self) -> None: + paths = (Path("/a/one.wav"), Path("/b/two.wav")) + + assert ReconstructionPathState.from_source_paths(paths) is ReconstructionPathState.MULTIPLE diff --git a/tests/unit/sampletones_core/audio/test_mixing.py b/tests/unit/sampletones_core/audio/test_mixing.py new file mode 100644 index 000000000..1aa6969c2 --- /dev/null +++ b/tests/unit/sampletones_core/audio/test_mixing.py @@ -0,0 +1,37 @@ +import numpy as np +import pytest + +from sampletones_core.audio.mixing import mix_audios + + +class TestMixAudios: + def test_pads_shorter_recordings_to_the_longest(self) -> None: + mixed = mix_audios( + [ + np.array([1.0, 2.0, 3.0], dtype=np.float64), + np.array([4.0], dtype=np.float64), + ] + ) + + np.testing.assert_array_equal(mixed, np.array([5.0, 2.0, 3.0])) + + def test_sums_equal_length_recordings(self) -> None: + mixed = mix_audios( + [ + np.array([1.0, 1.0], dtype=np.float64), + np.array([2.0, 2.0], dtype=np.float64), + ] + ) + + np.testing.assert_array_equal(mixed, np.array([3.0, 3.0])) + + def test_a_single_recording_is_the_mix_itself(self) -> None: + recording = np.array([1.0, 2.0], dtype=np.float64) + + mixed = mix_audios([recording]) + + assert mixed is recording + + def test_empty_recordings_raise(self) -> None: + with pytest.raises(ValueError, match="At least one recording"): + mix_audios([]) diff --git a/tests/unit/sampletones_core/reconstructions/naming/test_naming.py b/tests/unit/sampletones_core/reconstructions/naming/test_naming.py new file mode 100644 index 000000000..ee022feb0 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/naming/test_naming.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from sampletones_core.reconstructions.naming.derive import derive_name + + +class TestDeriveName: + def test_single_source_names_after_its_stem(self) -> None: + assert derive_name((Path("/stems/kick.wav"),), fallback_stem="document") == "kick" + + def test_sources_sharing_one_directory_name_after_it(self) -> None: + paths = ( + Path("/stems/drums/kick.wav"), + Path("/stems/drums/snare.wav"), + ) + + assert derive_name(paths, fallback_stem="document") == "drums" + + def test_sources_from_different_directories_fall_back(self) -> None: + paths = ( + Path("/a/kick.wav"), + Path("/b/snare.wav"), + ) + + assert derive_name(paths, fallback_stem="document") == "document" + + def test_no_sources_fall_back(self) -> None: + assert derive_name((), fallback_stem="document") == "document" diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index f9fdc5e62..c989b0a07 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -139,6 +139,37 @@ def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> No assert loaded.audio_filepath == stem_paths +class TestSourcePaths: + def test_single_source_yields_one_path(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert reconstruction.source_paths == (Path("/dev/null"),) + + def test_stem_sources_yield_the_recorded_tuple(self) -> None: + stem_paths = ( + Path("/dev/null/stem_a.wav"), + Path("/dev/null/stem_b.wav"), + ) + reconstruction = Reconstruction.create( + approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), + approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH)]}, + config=Config(), + coefficient=1.0, + audio_filepath=stem_paths, + ) + + assert reconstruction.source_paths == stem_paths + + def test_detaching_empties_the_source_paths(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + assert reconstruction.source_paths == (Path("/dev/null"),) + + reconstruction.detach_source() + + assert reconstruction.source_paths == () + + class TestRoundTrip: def test_save_load_round_trip( self, diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py b/tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py new file mode 100644 index 000000000..b5c80665d --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py @@ -0,0 +1,74 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +from jeepney import ( + AuthenticationError, + DBusErrorResponse, + Endianness, + HeaderFields, + MessageFlag, + MessageType, +) +from jeepney.low_level import Header, Message + +from sampletones_shared.utils.system.reveal.file_manager1 import ( + EMPTY_STARTUP_ID, + FILE_MANAGER_ADDRESS, + SHOW_ITEMS_METHOD, + SHOW_ITEMS_SIGNATURE, + FileManager1Backend, +) + +MODULE = "sampletones_shared.utils.system.reveal.file_manager1" +METHOD_UNKNOWN_ERROR: str = "org.freedesktop.FileManager1.MethodUnknown" + + +def _error_response() -> DBusErrorResponse: + header = Header( + Endianness.little, + MessageType.error, + MessageFlag.no_reply_expected, + 1, + 0, + 0, + {HeaderFields.error_name: METHOD_UNKNOWN_ERROR}, + ) + return DBusErrorResponse(Message(header, ())) + + +class TestOpen: + def test_sends_one_show_items_call_with_every_uri(self) -> None: + paths = ( + Path("/a/one.wav"), + Path("/b/two.wav"), + ) + + with patch(f"{MODULE}.open_dbus_connection") as open_connection: + connection = MagicMock() + open_connection.return_value.__enter__.return_value = connection + with patch(f"{MODULE}.new_method_call") as build_call: + FileManager1Backend().open(paths) + + build_call.assert_called_once_with( + FILE_MANAGER_ADDRESS, + SHOW_ITEMS_METHOD, + SHOW_ITEMS_SIGNATURE, + ([Path("/a/one.wav").as_uri(), Path("/b/two.wav").as_uri()], EMPTY_STARTUP_ID), + ) + connection.send_and_get_reply.assert_called_once_with(build_call.return_value) + + +class TestAnswers: + def test_an_answered_probe_reports_true(self) -> None: + with patch(f"{MODULE}.open_dbus_connection") as open_connection: + assert FileManager1Backend.answers() + + open_connection.assert_called_once_with(bus="SESSION") + + @patch(f"{MODULE}.open_dbus_connection", side_effect=_error_response()) + def test_an_erroring_probe_reports_false(self, open_connection: MagicMock) -> None: + assert not FileManager1Backend.answers() + + @patch(f"{MODULE}.open_dbus_connection", side_effect=AuthenticationError("denied")) + def test_an_unreachable_bus_reports_false(self, open_connection: MagicMock) -> None: + assert not FileManager1Backend.answers() diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py b/tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py new file mode 100644 index 000000000..d4e15c2f6 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py @@ -0,0 +1,38 @@ +from pathlib import Path +from unittest.mock import call, patch + +from sampletones_shared.utils.system.reveal.grouped import ( + GroupedDirectoryBackend, + distinct_parents, +) + + +class TestDistinctParents: + def test_each_parent_once_in_first_seen_order(self) -> None: + paths = ( + Path("/a/one.wav"), + Path("/a/two.wav"), + Path("/b/three.wav"), + Path("/a/four.wav"), + ) + + assert distinct_parents(paths) == (Path("/a"), Path("/b")) + + def test_paths_in_one_directory_share_their_parent(self) -> None: + paths = (Path("/a/one.wav"), Path("/a/two.wav")) + + assert distinct_parents(paths) == (Path("/a"),) + + +class TestGroupedDirectoryBackend: + def test_opens_each_directory_once(self) -> None: + paths = ( + Path("/a/one.wav"), + Path("/a/two.wav"), + Path("/b/three.wav"), + ) + + with patch("sampletones_shared.utils.system.reveal.grouped.open_path_in_explorer") as open_directory: + GroupedDirectoryBackend().open(paths) + + assert open_directory.call_args_list == [call(Path("/a")), call(Path("/b"))] diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py b/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py new file mode 100644 index 000000000..38c623eca --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py @@ -0,0 +1,84 @@ +from pathlib import Path +from typing import Callable, Optional +from unittest.mock import patch + +import pytest + +from sampletones_shared.utils.system.reveal.file_manager1 import FileManager1Backend +from sampletones_shared.utils.system.reveal.grouped import GroupedDirectoryBackend +from sampletones_shared.utils.system.reveal.selection import ( + open_paths_in_explorer, + select_reveal_backend, +) +from sampletones_shared.utils.system.system import System + +MODULE = "sampletones_shared.utils.system.reveal.selection" +BACKEND_MODULE = "sampletones_shared.utils.system.reveal.file_manager1" + + +def _find_spec(available: bool) -> Callable[[str], Optional[object]]: + def resolver(module: str) -> Optional[object]: + return object() if available else None + + return resolver + + +class TestOpenPathsInExplorer: + def test_a_single_path_opens_the_explorer_on_it(self) -> None: + path = Path("/a/one.wav") + + with patch(f"{MODULE}.open_path_in_explorer") as open_single: + open_paths_in_explorer([path]) + + open_single.assert_called_once_with(path) + + def test_several_paths_go_through_the_selected_backend(self) -> None: + paths = (Path("/a/one.wav"), Path("/b/two.wav")) + + with patch(f"{MODULE}.select_reveal_backend") as select: + open_paths_in_explorer(list(paths)) + + select.assert_called_once() + select.return_value.open.assert_called_once_with(paths) + + def test_no_path_raises(self) -> None: + with pytest.raises(ValueError, match="At least one path is required"): + open_paths_in_explorer([]) + + +class TestSelectRevealBackend: + def test_grouped_on_non_linux_systems(self) -> None: + with patch(f"{MODULE}.System.current", return_value=System.WINDOWS): + assert isinstance(select_reveal_backend(), GroupedDirectoryBackend) + + def test_grouped_when_jeepney_is_absent(self) -> None: + with ( + patch(f"{MODULE}.System.current", return_value=System.LINUX), + patch( + f"{MODULE}.importlib.util.find_spec", + side_effect=_find_spec(available=False), + ), + ): + assert isinstance(select_reveal_backend(), GroupedDirectoryBackend) + + def test_file_manager1_when_the_service_answers(self) -> None: + with ( + patch(f"{MODULE}.System.current", return_value=System.LINUX), + patch( + f"{MODULE}.importlib.util.find_spec", + side_effect=_find_spec(available=True), + ), + patch(f"{BACKEND_MODULE}.FileManager1Backend.answers", return_value=True), + ): + assert isinstance(select_reveal_backend(), FileManager1Backend) + + def test_grouped_when_the_service_stays_silent(self) -> None: + with ( + patch(f"{MODULE}.System.current", return_value=System.LINUX), + patch( + f"{MODULE}.importlib.util.find_spec", + side_effect=_find_spec(available=True), + ), + patch(f"{BACKEND_MODULE}.FileManager1Backend.answers", return_value=False), + ): + assert isinstance(select_reveal_backend(), GroupedDirectoryBackend) diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index b0ab5a04b..821ea056b 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -9,6 +9,7 @@ from sampletones_shared.utils.system.paths import ( DEFAULT_MAX_FILENAME_DISPLAY, ensure_suffix, + first_missing, get_directory, get_filename, open_directory_in_explorer_linux, @@ -18,6 +19,7 @@ shorten_filename, shorten_path, to_path, + to_paths, ) from sampletones_shared.utils.system.system import System from tests.suite.base import BaseTestSuite @@ -25,6 +27,40 @@ from tests.suite.errors import expect_error +class TestFirstMissing: + def test_answers_the_first_path_that_names_no_file(self, tmp_path: Path) -> None: + existing = tmp_path / "here.wav" + existing.touch() + missing = tmp_path / "gone.wav" + later_missing = tmp_path / "also_gone.wav" + + assert first_missing((existing, missing, later_missing)) == missing + + def test_answers_none_when_every_path_stands(self, tmp_path: Path) -> None: + first = tmp_path / "one.wav" + second = tmp_path / "two.wav" + first.touch() + second.touch() + + assert first_missing((first, second)) is None + + +class TestToPaths: + def test_one_path_becomes_a_one_tuple(self) -> None: + assert to_paths(Path("/a/one.wav")) == (Path("/a/one.wav"),) + + def test_several_paths_stay_as_they_are(self) -> None: + paths = (Path("/a/one.wav"), Path("/b/two.wav")) + + assert to_paths(paths) == paths + + def test_an_absent_location_is_empty(self) -> None: + assert to_paths(None) == () + + def test_strings_become_paths(self) -> None: + assert to_paths("/a/one.wav") == (Path("/a/one.wav"),) + + class TestToPath(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): From f8605f854b4a2f84aec6f7f6098adc5e5b532ebc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 18:05:52 +0200 Subject: [PATCH 024/142] Routed: program lookups through the shared locator The file-dialog factory and the zimtohrli referee probed for their programs with shutil.which, which the player's toolchain already answers through locate_program. One mechanism now answers "is this program installed" everywhere, and each probe reads back a Path. Both are availability probes rather than failures, so they take locate_program alone; missing_program_message stays with cc65 and ffmpeg, the tools that must name an install hint. scripts/detect_cuda.py keeps its own shutil.which: it selects the CUDA extra before the packages are installed, so it imports none of them. --- docs/development/architecture.md | 2 +- .../utils/file_dialogs/selection.py | 6 ++--- .../calibration/referee/zimtohrli.py | 5 ++-- .../utils/file_dialogs/test_selection.py | 25 ++++++++++--------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index c38f1a8de..6a11ca688 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -124,7 +124,7 @@ A new exclusive operation joins by contributing its `is_active` to the authority ### 11. Platform and external-tool differences hide behind a backend Protocol -Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`shutil.which`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. +Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`locate_program`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. `utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector, reports the one the user picked, and is told which window a dialog belongs to, since the desktop draws it in another process, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py index 2859c5ad9..e5539a5bf 100644 --- a/src/sampletones_application/utils/file_dialogs/selection.py +++ b/src/sampletones_application/utils/file_dialogs/selection.py @@ -1,12 +1,12 @@ import importlib.util import os -import shutil from typing import Final, Optional from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend from sampletones_application.utils.file_dialogs.protocol import FileDialogBackend from sampletones_shared.exceptions import FileDialogUnavailableError +from sampletones_shared.utils.system.programs import locate_program from sampletones_shared.utils.system.system import System KDIALOG: Final[str] = "kdialog" @@ -58,8 +58,8 @@ def _select_linux_backend() -> Optional[FileDialogBackend]: if portal is not None: return portal - kdialog = KDialogBackend() if shutil.which(KDIALOG) is not None else None - zenity = ZenityBackend() if shutil.which(ZENITY) is not None else None + kdialog = KDialogBackend() if locate_program(KDIALOG) is not None else None + zenity = ZenityBackend() if locate_program(ZENITY) is not None else None preferred: Optional[FileDialogBackend] alternative: Optional[FileDialogBackend] diff --git a/src/sampletones_core/calibration/referee/zimtohrli.py b/src/sampletones_core/calibration/referee/zimtohrli.py index 0a5157fb9..37402f2b9 100644 --- a/src/sampletones_core/calibration/referee/zimtohrli.py +++ b/src/sampletones_core/calibration/referee/zimtohrli.py @@ -1,4 +1,3 @@ -import shutil import subprocess import tempfile from pathlib import Path @@ -7,6 +6,7 @@ import numpy as np from sampletones_core.audio.io import write_wave +from sampletones_shared.utils.system.programs import locate_program ZIMTOHRLI_BINARY: Final[str] = "zimtohrli" @@ -58,5 +58,4 @@ def _is_float(token: str) -> bool: def find_zimtohrli() -> Optional[Path]: """Path of the `zimtohrli` binary on the system, when installed.""" - binary = shutil.which(ZIMTOHRLI_BINARY) - return Path(binary) if binary else None + return locate_program(ZIMTOHRLI_BINARY) diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py index a16487f21..e39ad0409 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py @@ -1,5 +1,6 @@ import os from contextlib import AbstractContextManager +from pathlib import Path from typing import Callable, Optional from unittest.mock import MagicMock, patch @@ -24,11 +25,11 @@ PORTAL_MODULE = "sampletones_application.utils.file_dialogs.backends.portal.backend" -def _which(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[str]]: +def _located(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[Path]]: available = {"kdialog": kdialog, "zenity": zenity} - def resolver(tool: str) -> Optional[str]: - return f"/usr/bin/{tool}" if available.get(tool, False) else None + def resolver(tool: str) -> Optional[Path]: + return Path(f"/usr/bin/{tool}") if available.get(tool, False) else None return resolver @@ -59,7 +60,7 @@ def test_the_portal_leads_where_it_answers(self) -> None: portal = PortalBackend(FileChooserClient()) with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)), _portal(portal), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): @@ -68,7 +69,7 @@ def test_the_portal_leads_where_it_answers(self) -> None: def test_kde_prefers_kdialog(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)), _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): @@ -77,7 +78,7 @@ def test_kde_prefers_kdialog(self) -> None: def test_gnome_prefers_zenity(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)), _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), ): @@ -86,7 +87,7 @@ def test_gnome_prefers_zenity(self) -> None: def test_kde_without_kdialog_falls_back_to_zenity(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=True)), + patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=False, zenity=True)), _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): @@ -96,8 +97,8 @@ def test_no_linux_tools_uses_tkinter(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch( - f"{MODULE}.shutil.which", - side_effect=_which(kdialog=False, zenity=False), + f"{MODULE}.locate_program", + side_effect=_located(kdialog=False, zenity=False), ), _portal(None), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), @@ -107,7 +108,7 @@ def test_no_linux_tools_uses_tkinter(self) -> None: def test_linux_tools_win_over_missing_tkinter(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)), + patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)), patch( f"{MODULE}.importlib.util.find_spec", side_effect=_find_spec(available=False), @@ -120,8 +121,8 @@ def test_no_linux_tools_without_tkinter_raises(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch( - f"{MODULE}.shutil.which", - side_effect=_which(kdialog=False, zenity=False), + f"{MODULE}.locate_program", + side_effect=_located(kdialog=False, zenity=False), ), patch( f"{MODULE}.importlib.util.find_spec", From bbcb3b6814fd806412fc4fa2a363ab03a55c96de Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 18:40:28 +0200 Subject: [PATCH 025/142] Widened: the tracker seam into an export seam --- docs/development/packages.md | 2 +- src/sampletones_application/application.py | 16 ++--- .../categories/{trackers.py => exports.py} | 38 +++++------ .../coordinators/project.py | 36 +++++----- .../coordinators/tabs/reconstruction.py | 60 ++++++++-------- .../logic/project/controller.py | 2 +- .../logic/reconstruction/reconstruction.py | 68 +++++++++---------- .../services/export/error.py | 6 +- .../services/export/service.py | 38 +++++------ .../services/export/success.py | 6 +- src/sampletones_application/shell.py | 16 ++--- src/sampletones_application/ui/menu.py | 24 +++---- .../utils/gui/shortcuts/ids.py | 14 ++-- .../{trackers => exports}/__init__.py | 0 .../{trackers => exports}/artifact.py | 0 .../{trackers => exports}/backend.py | 14 ++-- .../{trackers => exports}/extensions.py | 18 ++--- .../{trackers => exports}/format.py | 4 +- .../implementation/__init__.py | 0 .../implementation/bitphase.py | 27 +++++--- .../implementation/famitracker.py | 20 +++--- src/sampletones_core/exports/registry.py | 24 +++++++ .../{trackers => exports}/request.py | 0 .../{trackers => exports}/scope.py | 0 .../formats/bitphase/builder.py | 2 +- .../formats/bitphase/preset.py | 2 +- src/sampletones_core/trackers/registry.py | 23 ------- .../meta/source/annotations.py | 2 +- .../services/test_export.py | 4 +- .../{test_trackers.py => test_exports.py} | 54 +++++++-------- .../coordinators/tabs/test_reconstruction.py | 10 +-- .../coordinators/test_project.py | 2 +- .../reconstruction/test_reconstruction.py | 50 +++++++------- .../services/export/test_result.py | 38 +++++------ .../services/export/test_service.py | 20 +++--- .../{trackers => exports}/__init__.py | 0 .../{trackers => exports}/test_bitphase.py | 16 ++--- .../{trackers => exports}/test_extensions.py | 38 +++++------ .../{trackers => exports}/test_famitracker.py | 10 +-- .../formats/bitphase/conftest.py | 2 +- .../meta/source/bindings/test_containers.py | 20 +++--- .../meta/source/bindings/test_scopes.py | 22 +++--- .../meta/source/test_annotations.py | 8 +-- .../meta/source/test_index.py | 4 +- 44 files changed, 385 insertions(+), 375 deletions(-) rename src/sampletones_application/categories/{trackers.py => exports.py} (52%) rename src/sampletones_core/{trackers => exports}/__init__.py (100%) rename src/sampletones_core/{trackers => exports}/artifact.py (100%) rename src/sampletones_core/{trackers => exports}/backend.py (87%) rename src/sampletones_core/{trackers => exports}/extensions.py (58%) rename src/sampletones_core/{trackers => exports}/format.py (52%) rename src/sampletones_core/{trackers => exports}/implementation/__init__.py (100%) rename src/sampletones_core/{trackers => exports}/implementation/bitphase.py (85%) rename src/sampletones_core/{trackers => exports}/implementation/famitracker.py (90%) create mode 100644 src/sampletones_core/exports/registry.py rename src/sampletones_core/{trackers => exports}/request.py (100%) rename src/sampletones_core/{trackers => exports}/scope.py (100%) delete mode 100644 src/sampletones_core/trackers/registry.py rename tests/unit/sampletones_application/categories/{test_trackers.py => test_exports.py} (61%) rename tests/unit/sampletones_core/{trackers => exports}/__init__.py (100%) rename tests/unit/sampletones_core/{trackers => exports}/test_bitphase.py (95%) rename tests/unit/sampletones_core/{trackers => exports}/test_extensions.py (73%) rename tests/unit/sampletones_core/{trackers => exports}/test_famitracker.py (94%) diff --git a/docs/development/packages.md b/docs/development/packages.md index 565a3de94..4fdce0f19 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -53,7 +53,7 @@ Third-party imports are the package author's own choice and stand outside this t **The reconstruction engine stands below the console player.** A reconstruction is produced, saved and exported to a tracker with `sampletones_player` absent from the process, which is what lets the player's format move while the engine holds still. The consequence is that an export backend -reaching the console — the seam `sampletones_core/trackers/backend.py` describes — is registered +reaching the console — the seam `sampletones_core/exports/backend.py` describes — is registered from above rather than from the engine's own registry. **Equal temperament sits at the bottom.** The MIDI pitch limits and the A4 reference are diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index c68698e5b..f150b2798 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -142,12 +142,12 @@ from sampletones_core.constants.audio import BufferSize, SampleRate from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.types.feature import FeatureValue from sampletones_shared.application import ( SAMPLETONES_AUTHOR, @@ -239,7 +239,7 @@ def __init__( self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) - self.tracker_backends: Dict[TrackerFormat, TrackerBackend] = build_tracker_backends() + self.export_backends: Dict[ExportFormat, ExportBackend] = build_tracker_backends() self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) @@ -357,7 +357,7 @@ def __init__( self.project_manager, self.session_manager, self.export_service, - tracker_backends=self.tracker_backends, + export_backends=self.export_backends, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, @@ -389,7 +389,7 @@ def __init__( reconstruction_manager=self.reconstruction_manager, browser_manager=self.browser_manager, export_service=self.export_service, - tracker_backends=self.tracker_backends, + export_backends=self.export_backends, on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, @@ -903,9 +903,9 @@ def _export_reconstruction_wav_dialog(self) -> None: if self._reconstruction_coordinator.check_loaded(): self._reconstructions_tab.request_export_wav_dialog() - def _export_reconstruction_instruments_dialog(self, tracker_format: TrackerFormat) -> None: + def _export_reconstruction_instruments_dialog(self, export_format: ExportFormat) -> None: if self._reconstruction_coordinator.check_loaded(): - self._reconstructions_tab.request_export_instruments_dialog(tracker_format) + self._reconstructions_tab.request_export_instruments_dialog(export_format) def _reconstruct_file(self, filepath: Path) -> None: self._main_tab.set_input_path(filepath, convert=True) diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/exports.py similarity index 52% rename from src/sampletones_application/categories/trackers.py rename to src/sampletones_application/categories/exports.py index be1a789c8..b2aac8c37 100644 --- a/src/sampletones_application/categories/trackers.py +++ b/src/sampletones_application/categories/exports.py @@ -7,15 +7,15 @@ GlobalMessageElements, MenuElements, ) -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat @dataclass(frozen=True) -class TrackerProjectElements: - """Which texts one tracker format's project export reads. +class ExportProjectElements: + """Which texts one export format's project export reads. Every format names its own file kind, so the dialog that picks a destination and the - one that reports the outcome speak in the words of the tracker that reads the file. + one that reports the outcome speak in the words of the program that reads the file. Attributes: dialog_title: Title of the dialog the destination is picked in. @@ -30,14 +30,14 @@ class TrackerProjectElements: export_failed_message: GlobalMessageElements -TRACKER_PROJECT_ELEMENTS: Final[Dict[TrackerFormat, TrackerProjectElements]] = { - TrackerFormat.FAMITRACKER: TrackerProjectElements( +EXPORT_PROJECT_ELEMENTS: Final[Dict[ExportFormat, ExportProjectElements]] = { + ExportFormat.FAMITRACKER: ExportProjectElements( dialog_title=GlobalDialogTitleElements.EXPORT_MODULE, filter_name=FileFilterElements.MODULE, exported_message=GlobalMessageElements.PROJECT_EXPORTED_SUCCESSFULLY, export_failed_message=GlobalMessageElements.PROJECT_EXPORT_FAILED, ), - TrackerFormat.BITPHASE: TrackerProjectElements( + ExportFormat.BITPHASE: ExportProjectElements( dialog_title=GlobalDialogTitleElements.EXPORT_BITPHASE_PROJECT, filter_name=FileFilterElements.BITPHASE_PROJECT, exported_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY, @@ -45,22 +45,22 @@ class TrackerProjectElements: ), } -TRACKER_PROJECT_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { - TrackerFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, - TrackerFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE, +EXPORT_PROJECT_MENU_LABELS: Final[Dict[ExportFormat, MenuElements]] = { + ExportFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, + ExportFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE, } -INSTRUMENT_EXPORT_FORMATS: Final[Tuple[TrackerFormat, ...]] = ( - TrackerFormat.FAMITRACKER, - TrackerFormat.BITPHASE_PRESET, +INSTRUMENT_EXPORT_FORMATS: Final[Tuple[ExportFormat, ...]] = ( + ExportFormat.FAMITRACKER, + ExportFormat.BITPHASE_PRESET, ) -TRACKER_INSTRUMENT_FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = { - TrackerFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, - TrackerFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, +EXPORT_INSTRUMENT_FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = { + ExportFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, + ExportFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, } -TRACKER_SAMPLE_MENU_LABELS: Final[Dict[TrackerFormat, MenuElements]] = { - TrackerFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, - TrackerFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, +EXPORT_SAMPLE_MENU_LABELS: Final[Dict[ExportFormat, MenuElements]] = { + ExportFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + ExportFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, } diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 63f755e37..1f66d7790 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -7,9 +7,9 @@ GlobalDialogTitleElements, GlobalMessageElements, ) +from sampletones_application.categories.exports import EXPORT_PROJECT_ELEMENTS from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.trackers import TRACKER_PROJECT_ELEMENTS from sampletones_application.config.managers.session import SessionManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager @@ -31,9 +31,9 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.scope import ExportScope from sampletones_shared.constants.project import ( DEFAULT_EXPORT_NAME, DEFAULT_PROJECT_FILENAME, @@ -67,7 +67,7 @@ def __init__( session_manager: SessionManager, export_service: ExportService, *, - tracker_backends: Dict[TrackerFormat, TrackerBackend], + export_backends: Dict[ExportFormat, ExportBackend], dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, @@ -77,7 +77,7 @@ def __init__( self._project_manager = project_manager self._session_manager = session_manager self._export_service = export_service - self._tracker_backends = tracker_backends + self._export_backends = export_backends self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch @@ -195,17 +195,17 @@ def _get_project_filename(self, extension: str) -> str: name = self.project_name or DEFAULT_EXPORT_NAME return get_filename(name, extension) - def export_project_dialog(self, tracker_format: TrackerFormat) -> None: - """Prompts for a destination and writes the open project in ``tracker_format``. + def export_project_dialog(self, export_format: ExportFormat) -> None: + """Prompts for a destination and writes the open project in ``export_format``. Args: - tracker_format: The tracker the project is written for. + export_format: The format the project is written in. """ if not self._project_controller.is_open: return - backend = self._tracker_backends[tracker_format] - elements = TRACKER_PROJECT_ELEMENTS[tracker_format] + backend = self._export_backends[export_format] + elements = EXPORT_PROJECT_ELEMENTS[export_format] extension = backend.extension(ExportScope.PROJECT) path = self._session_manager.get_project_path() filepath = save_file_dialog( @@ -220,7 +220,7 @@ def export_project_dialog(self, tracker_format: TrackerFormat) -> None: ), ) - self._handle_export_project(filepath, tracker_format) + self._handle_export_project(filepath, export_format) def _open_dialog(self) -> None: filepath = open_file_dialog( @@ -242,10 +242,10 @@ def _handle_save_as(self, filepath: Path) -> bool: return self._save(filepath) @ignore_none_path - def _handle_export_project(self, filepath: Path, tracker_format: TrackerFormat) -> None: + def _handle_export_project(self, filepath: Path, export_format: ExportFormat) -> None: self._export_service.export_project( filepath, - self._tracker_backends[tracker_format], + self._export_backends[export_format], self._project_controller.export_request, ) @@ -299,21 +299,21 @@ def _on_export_result(self, result: ExportResult) -> None: match result: case ExportSuccess( kind=ExportKind.PROJECT, - tracker_format=TrackerFormat() as tracker_format, + export_format=ExportFormat() as export_format, ): self._dialogs.show_info( TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].exported_message), + self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message), self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), ) case ExportError( kind=ExportKind.PROJECT, - tracker_format=TrackerFormat() as tracker_format, + export_format=ExportFormat() as export_format, exception=exception, ): self._dialogs.show_error( exception, - self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].export_failed_message), + self._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message), ) def _guard_open( diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 8fa8243a3..7ead19043 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -4,12 +4,12 @@ import dearpygui.dearpygui as dpg from sampletones_application.categories.export import ExportMessages -from sampletones_application.categories.hierarchy import Page, Panel, TextType -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.trackers import ( +from sampletones_application.categories.exports import ( + EXPORT_INSTRUMENT_FILTERS, INSTRUMENT_EXPORT_FORMATS, - TRACKER_INSTRUMENT_FILTERS, ) +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.coordinators.original_audio import OriginalAudioLocator @@ -81,10 +81,10 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.scope import ExportScope from sampletones_core.structures.tree import FileSystemNode -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.scope import ExportScope from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -111,7 +111,7 @@ def __init__( reconstruction_manager: ReconstructionManager, browser_manager: BrowserManager, export_service: ExportService, - tracker_backends: Dict[TrackerFormat, TrackerBackend], + export_backends: Dict[ExportFormat, ExportBackend], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], @@ -126,7 +126,7 @@ def __init__( self._language_manager = language_manager self._reconstruction_manager = reconstruction_manager self._session_manager = session_manager - self._tracker_backends = tracker_backends + self._export_backends = export_backends self._dialogs = dialogs self._original_audio_locator = original_audio_locator @@ -138,14 +138,14 @@ def __init__( self._msg_load_error = language_manager["reconstructions.browser.message.load_error"] self._export_messages = ExportMessages.build(language_manager) - self._instrument_filter_names: Dict[TrackerFormat, str] = { - tracker_format: language_manager[ + self._instrument_filter_names: Dict[ExportFormat, str] = { + export_format: language_manager[ Page.GLOBAL, Panel.DIALOG, TextType.FILTER, element, ] - for tracker_format, element in TRACKER_INSTRUMENT_FILTERS.items() + for export_format, element in EXPORT_INSTRUMENT_FILTERS.items() } self._browser_logic: BrowserLogic = BrowserLogic( @@ -203,7 +203,7 @@ def __init__( session_manager, reconstruction_manager, export_service, - tracker_backends, + export_backends, ) self._reconstruction_instruments_panel: GUIReconstructionInstrumentsPanel = GUIReconstructionInstrumentsPanel( pitch_stepper_style=layout.pitch_stepper_style, @@ -356,7 +356,7 @@ def _open_export_instrument_dialog( """Prompts for the file the ``channel_name`` slice is written to. Every format that writes a single slice is offered at once, so the type picked in the - dialog names the tracker the slice is written for. + dialog names the format the slice is written in. """ filepath = save_file_dialog( title=self._language_manager["reconstructions.instruments.title.export_instrument_dialog"], @@ -367,13 +367,13 @@ def _open_export_instrument_dialog( self._handle_export_instrument(filepath, channel_name) def _instrument_filters(self) -> Tuple[FileFilter, ...]: - """The types a destination for one slice may be given, one per tracker offered. + """The types a destination for one slice may be given, one per format offered. - Naming each tracker's own type puts the trackers an export can reach in the dialog's + Naming each format's own type puts the programs an export can reach in the dialog's type selector, so the one that is picked there names the format. """ return tuple( - self._tracker_filter(tracker_format, ExportScope.INSTRUMENT) for tracker_format in INSTRUMENT_EXPORT_FORMATS + self._export_filter(export_format, ExportScope.INSTRUMENT) for export_format in INSTRUMENT_EXPORT_FORMATS ) @ignore_none_path @@ -388,11 +388,11 @@ def _open_export_instruments_dialog( self, default_filename: str, default_path: str, - tracker_format: TrackerFormat, + export_format: ExportFormat, ) -> None: """Prompts for the destination the loaded reconstruction's slices are named after. - The tracker was chosen with the action, so the dialog offers its file type alone: a + The format was chosen with the action, so the dialog offers its file type alone: a format that gathers the whole reconstruction into one document writes it at the destination, while one that keeps an instrument per file writes its slices beside it. """ @@ -400,28 +400,28 @@ def _open_export_instruments_dialog( title=self._language_manager["reconstructions.instruments.title.export_instruments_dialog"], initial_directory=default_path, default_filename=default_filename, - filters=(self._tracker_filter(tracker_format, ExportScope.SAMPLE),), + filters=(self._export_filter(export_format, ExportScope.SAMPLE),), ) - self._handle_export_instruments(destination, tracker_format) + self._handle_export_instruments(destination, export_format) - def _tracker_filter( + def _export_filter( self, - tracker_format: TrackerFormat, + export_format: ExportFormat, scope: ExportScope, ) -> FileFilter: - """The type ``tracker_format`` writes ``scope`` files as, named after that tracker.""" + """The type ``export_format`` writes ``scope`` files as, named after that format.""" return FileFilter.for_extensions( - self._instrument_filter_names[tracker_format], - [self._tracker_backends[tracker_format].extension(scope)], + self._instrument_filter_names[export_format], + [self._export_backends[export_format].extension(scope)], ) @ignore_none_path def _handle_export_instruments( self, destination: Path, - tracker_format: TrackerFormat, + export_format: ExportFormat, ) -> None: - self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination, tracker_format) + self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination, export_format) def _open_export_wav_dialog(self, default_filename: str, default_path: str) -> None: filepath = save_file_dialog( @@ -646,8 +646,8 @@ def player(self) -> AudioPlayerProtocol: def request_export_wav_dialog(self) -> None: self._reconstruction_panel_logic.request_export_wav_dialog() - def request_export_instruments_dialog(self, tracker_format: TrackerFormat) -> None: - self._reconstruction_panel_logic.request_export_instruments_dialog(tracker_format) + def request_export_instruments_dialog(self, export_format: ExportFormat) -> None: + self._reconstruction_panel_logic.request_export_instruments_dialog(export_format) def _on_browser_autoplay_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index e643c9b82..23b65f0d2 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -4,12 +4,12 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE +from sampletones_core.exports.request import ProjectExport from sampletones_core.project import Project from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction -from sampletones_core.trackers.request import ProjectExport from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.callbacks import CallbackMixin diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 890c38b6f..6e39c884e 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -20,11 +20,11 @@ from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.extensions import format_for_extension -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import InstrumentExport, SampleExport -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.extensions import format_for_extension +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.scope import ExportScope from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -52,14 +52,14 @@ def export_wav( def export_instrument( self, destination: Path, - backend: TrackerBackend, + backend: ExportBackend, request: InstrumentExport, ) -> None: ... def export_sample( self, destination: Path, - backend: TrackerBackend, + backend: ExportBackend, request: SampleExport, ) -> None: ... @@ -70,12 +70,12 @@ def __init__( session_manager: SessionManager, reconstruction_manager: ReconstructionManager, export_service: ExportServiceProtocol, - tracker_backends: Dict[TrackerFormat, TrackerBackend], + export_backends: Dict[ExportFormat, ExportBackend], ) -> None: self._session_manager = session_manager self._reconstruction_manager = reconstruction_manager self._export_service = export_service - self._tracker_backends = tracker_backends + self._export_backends = export_backends self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._playing_channels: FrozenSet[ChannelName] = frozenset() @@ -89,7 +89,7 @@ def __init__( self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None self.on_open_export_instrument_dialog: Optional[Callable[[str, str, ChannelName], None]] = None - self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None + self.on_open_export_instruments_dialog: Optional[Callable[[str, str, ExportFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None self.on_locate_audio_not_found: Optional[PathCallback] = None @@ -207,9 +207,9 @@ def request_export_instrument_dialog( ) -> None: """Asks for the destination one channel slice is written to. - Every tracker able to write a single slice is offered at once, so the channel travels + Every format able to write a single slice is offered at once, so the channel travels with the request to the dialog and back. The suggestion is the instrument's name on its - own, leaving the tracker to the dialog's file-type selector and to any extension typed + own, leaving the format to the dialog's file-type selector and to any extension typed over it. Args: @@ -234,28 +234,28 @@ def request_export_instrument_dialog( def request_export_instruments_dialog( self, - tracker_format: TrackerFormat, + export_format: ExportFormat, ) -> None: """Asks for the destination the loaded reconstruction's slices are named after. - The tracker comes from the action that was chosen, so the dialog offers that - tracker's file type alone and the suggestion already ends in its extension. + The format comes from the action that was chosen, so the dialog offers that + format's file type alone and the suggestion already ends in its extension. Args: - tracker_format: The tracker the slices are written for. + export_format: The format the slices are written in. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting instruments") default_path = str(self._session_manager.get_instrument_path()) - extension = self._tracker_backends[tracker_format].extension(ExportScope.SAMPLE) + extension = self._export_backends[export_format].extension(ExportScope.SAMPLE) self.call( self.on_open_export_instruments_dialog, get_filename(reconstruction_data.name, extension), default_path, - tracker_format, + export_format, ) def request_export_wav_dialog(self) -> None: @@ -275,9 +275,9 @@ def handle_export_instrument_confirmed( ) -> None: """Writes the ``channel_name`` slice of the loaded reconstruction to ``filepath``. - The extension picks the tracker the slice is written for, and the instrument carries + The extension picks the format the slice is written in, and the instrument carries the name the destination was saved under, so renaming the file in the dialog renames - the instrument the tracker lists. + the instrument the file carries. Args: filepath: The destination the dialog was confirmed with. @@ -288,20 +288,20 @@ def handle_export_instrument_confirmed( logger.warning("No reconstruction data available for instrument export") return - tracker_format = self._tracker_format(filepath, ExportScope.INSTRUMENT) + export_format = self._export_format(filepath, ExportScope.INSTRUMENT) feature = reconstruction_data.feature_data[channel_name] self._session_manager.set_instrument_path(filepath.parent) self._export_service.export_instrument( filepath, - self._tracker_backends[tracker_format], + self._export_backends[export_format], self._instrument_export(channel_name, feature, filepath.stem), ) def handle_export_instruments_confirmed( self, destination: Path, - tracker_format: TrackerFormat, + export_format: ExportFormat, ) -> None: """Writes the slice of every playing channel of the loaded reconstruction to ``destination``. @@ -312,7 +312,7 @@ def handle_export_instruments_confirmed( Args: destination: The file the export was confirmed with. - tracker_format: The tracker the slices are written for. + export_format: The format the slices are written in. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: @@ -336,16 +336,16 @@ def handle_export_instruments_confirmed( self._session_manager.set_instrument_path(destination.parent) self._export_service.export_sample( destination, - self._tracker_backends[tracker_format], + self._export_backends[export_format], request, ) - def _tracker_format( + def _export_format( self, destination: Path, scope: ExportScope, - ) -> TrackerFormat: - """Reads the tracker format out of the destination's extension. + ) -> ExportFormat: + """Reads the export format out of the destination's extension. A save dialog answers with one of the extensions it offered, and an export offers the types its own formats write, so every destination reaching here names a format. @@ -355,16 +355,16 @@ def _tracker_format( scope: The scope about to be written. Returns: - TrackerFormat: The format to write in. + ExportFormat: The format to write in. Raises: ValueError: If no format able to express ``scope`` claims the extension. """ - tracker_format = format_for_extension(self._tracker_backends, scope, destination.suffix) - if tracker_format is None: - raise ValueError(f"No tracker format writes '{destination.suffix}' for a {scope} export") + export_format = format_for_extension(self._export_backends, scope, destination.suffix) + if export_format is None: + raise ValueError(f"No export format writes '{destination.suffix}' for a {scope} export") - return tracker_format + return export_format def _instrument_export( self, @@ -372,7 +372,7 @@ def _instrument_export( feature: Features, name: str, ) -> InstrumentExport: - """Packages one channel slice under ``name`` for a tracker backend. + """Packages one channel slice under ``name`` for an export backend. A reconstruction has no loop flag of its own — that belongs to a sample placed in a project — so the instrument plays its envelopes once. diff --git a/src/sampletones_application/services/export/error.py b/src/sampletones_application/services/export/error.py index a7405674d..bc44bdb5f 100644 --- a/src/sampletones_application/services/export/error.py +++ b/src/sampletones_application/services/export/error.py @@ -2,7 +2,7 @@ from typing import Optional from sampletones_application.services.export.kind import ExportKind -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat @dataclass(frozen=True, eq=False) @@ -11,10 +11,10 @@ class ExportError: Attributes: kind: The artefact the run set out to produce. - tracker_format: The format the run set out to write, and ``None`` for an audio export. + export_format: The format the run set out to write, and ``None`` for an audio export. exception: The failure raised while writing. """ kind: ExportKind - tracker_format: Optional[TrackerFormat] + export_format: Optional[ExportFormat] exception: Exception diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index e24a42d93..177eaf245 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -11,26 +11,26 @@ from sampletones_application.services.export.success import ExportSuccess from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave -from sampletones_core.trackers.artifact import ExportArtifact -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import ( +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) from sampletones_shared.logger import logger -NO_TRACKER_FORMAT: None = None +NO_EXPORT_FORMAT: None = None class ExportService(ServiceBase[ExportResult]): """Writes exports on a background thread and reports each outcome as a result. - The tracker backend arrives per call, so the service stays free of any one file + The export backend arrives per call, so the service stays free of any one file format: it owns the thread boundary and the error boundary, and the backend owns what lands on disk. Each result names the format it was written in, letting one - subscriber report an outcome in the words of the tracker that reads it. + subscriber report an outcome in the words of the program that reads it. """ def __init__(self, priority: int = 0) -> None: @@ -51,7 +51,7 @@ def task() -> None: ExportSuccess( kind=ExportKind.WAV, filepath=filepath, - tracker_format=NO_TRACKER_FORMAT, + export_format=NO_EXPORT_FORMAT, truncation=None, ) ) @@ -60,7 +60,7 @@ def task() -> None: self._emit( ExportError( kind=ExportKind.WAV, - tracker_format=NO_TRACKER_FORMAT, + export_format=NO_EXPORT_FORMAT, exception=exception, ) ) @@ -70,39 +70,39 @@ def task() -> None: def export_instrument( self, destination: Path, - backend: TrackerBackend, + backend: ExportBackend, request: InstrumentExport, ) -> None: self._submit( ExportKind.INSTRUMENT, destination, - backend.tracker_format, + backend.export_format, partial(backend.write_instrument, destination, request), ) def export_sample( self, destination: Path, - backend: TrackerBackend, + backend: ExportBackend, request: SampleExport, ) -> None: self._submit( ExportKind.SAMPLE, destination, - backend.tracker_format, + backend.export_format, partial(backend.write_sample, destination, request), ) def export_project( self, destination: Path, - backend: TrackerBackend, + backend: ExportBackend, request: ProjectExport, ) -> None: self._submit( ExportKind.PROJECT, destination, - backend.tracker_format, + backend.export_format, partial(backend.write_project, destination, request), ) @@ -110,7 +110,7 @@ def _submit( self, kind: ExportKind, destination: Path, - tracker_format: TrackerFormat, + export_format: ExportFormat, write: Callable[[], ExportArtifact], ) -> None: """Runs one backend write on the executor and reports what it produced. @@ -122,7 +122,7 @@ def _submit( Args: kind: The artefact the run produces, naming the dialog that reports it. destination: The destination the run was given. - tracker_format: The format the run writes, carried through to the result. + export_format: The format the run writes, carried through to the result. write: Calls the backend and returns what it left on disk. """ @@ -136,7 +136,7 @@ def task() -> None: ExportSuccess( kind=kind, filepath=artifact.paths[0] if artifact.paths else destination, - tracker_format=tracker_format, + export_format=export_format, truncation=artifact.truncation, ) ) @@ -145,7 +145,7 @@ def task() -> None: self._emit( ExportError( kind=kind, - tracker_format=tracker_format, + export_format=export_format, exception=exception, ) ) diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index 502a2a948..5864f082f 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -4,7 +4,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat @dataclass(frozen=True) @@ -14,12 +14,12 @@ class ExportSuccess: Attributes: kind: The artefact the run produced. filepath: A file the run wrote, which a batch reports as the first of its slices. - tracker_format: The format the run wrote, and ``None`` for an audio export. + export_format: The format the run wrote, and ``None`` for an audio export. truncation: What the target format's item limit left out, and ``None`` when the export carries every frame. """ kind: ExportKind filepath: Path - tracker_format: Optional[TrackerFormat] + export_format: Optional[ExportFormat] truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 50b805d73..f6fa2532c 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -52,7 +52,7 @@ from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_application.viewport import ViewportManager from sampletones_core.constants.enums import ChannelName -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import Callback, PathCallback @@ -72,7 +72,7 @@ class ShortcutBindings: save_project: Callback save_project_as: Callback project_properties: Callback - export_project: Callable[[TrackerFormat], None] + export_project: Callable[[ExportFormat], None] render_song: Callback close_project: Callback exit: Callback @@ -87,7 +87,7 @@ class ShortcutBindings: save_reconstruction_as: Callback close_reconstruction: Callback export_wav: Callback - export_instruments: Callable[[TrackerFormat], None] + export_instruments: Callable[[ExportFormat], None] add_reconstruction_to_sequencer: Callback open_reconstruction_in_explorer: Callback locate_original_audio: Callback @@ -271,18 +271,18 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback def _export_callbacks( bindings: ShortcutBindings, ) -> Dict[ShortcutId, Callback]: - """One export action per tracker format, the entries the Export submenus list. + """One export action per format, the entries the Export submenus list. Each action carries the format it writes, so a menu entry and its key combination reach the same coordinator call. """ project = { - shortcut_id: partial(bindings.export_project, tracker_format) - for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items() + shortcut_id: partial(bindings.export_project, export_format) + for export_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items() } instruments = { - shortcut_id: partial(bindings.export_instruments, tracker_format) - for tracker_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items() + shortcut_id: partial(bindings.export_instruments, export_format) + for export_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items() } return {**project, **instruments} diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 91b08164e..b921a4fcd 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -8,12 +8,12 @@ ContextElements, MenuElements, ) +from sampletones_application.categories.exports import ( + EXPORT_PROJECT_MENU_LABELS, + EXPORT_SAMPLE_MENU_LABELS, +) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.trackers import ( - TRACKER_PROJECT_MENU_LABELS, - TRACKER_SAMPLE_MENU_LABELS, -) from sampletones_application.constants.playback import FollowMode from sampletones_application.layout.glyphs.player import PlayerGlyphs from sampletones_application.layout.player import PlayerLayout @@ -236,7 +236,7 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: ) def _create_project_export_menu(self, state: MenuBarViewModel) -> None: - """Builds the submenu that writes the open project for one tracker. + """Builds the submenu that writes the open project for one format. Each format reads its own kind of file, so the formats are listed side by side and the one chosen decides what the destination dialog offers. The submenu is open while @@ -247,10 +247,10 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.GROUP_FILE_EXPORT), enabled=state.project_open, ): - for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items(): + for export_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items(): self._shortcut_manager.add_menu_item( shortcut_id, - label=self._label(TRACKER_PROJECT_MENU_LABELS[tracker_format]), + label=self._label(EXPORT_PROJECT_MENU_LABELS[export_format]), ) def _create_edit_menu(self, state: MenuBarViewModel) -> None: @@ -410,20 +410,20 @@ def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None: self._create_instruments_export_menu(state) def _create_instruments_export_menu(self, state: MenuBarViewModel) -> None: - """Builds the submenu that writes the loaded reconstruction's slices for one tracker. + """Builds the submenu that writes the loaded reconstruction's slices for one format. - Each format able to write a file per slice gets its own item, so choosing the tracker - is one click and the destination dialog then offers that tracker's file type alone. + Each format able to write a file per slice gets its own item, so choosing the format + is one click and the destination dialog then offers that format's file type alone. """ with dpg.menu( tag=TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, label=self._label(MenuElements.GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS), enabled=state.reconstruction_loaded, ): - for tracker_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items(): + for export_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items(): self._shortcut_manager.add_menu_item( shortcut_id, - label=self._label(TRACKER_SAMPLE_MENU_LABELS[tracker_format]), + label=self._label(EXPORT_SAMPLE_MENU_LABELS[export_format]), ) def _create_playback_menu(self, state: MenuBarViewModel) -> None: diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 02c078b52..dbf32c562 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -4,7 +4,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.constants.playback import FollowMode from sampletones_core.constants.enums import ChannelName -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat class ShortcutCategory(StrEnum): @@ -220,12 +220,12 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ChannelName.NOISE: ShortcutId.TOGGLE_CHANNEL_NOISE, } -PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { - TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_PROJECT_FAMITRACKER, - TrackerFormat.BITPHASE: ShortcutId.EXPORT_PROJECT_BITPHASE, +PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = { + ExportFormat.FAMITRACKER: ShortcutId.EXPORT_PROJECT_FAMITRACKER, + ExportFormat.BITPHASE: ShortcutId.EXPORT_PROJECT_BITPHASE, } -SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = { - TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, - TrackerFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, +SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = { + ExportFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, + ExportFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, } diff --git a/src/sampletones_core/trackers/__init__.py b/src/sampletones_core/exports/__init__.py similarity index 100% rename from src/sampletones_core/trackers/__init__.py rename to src/sampletones_core/exports/__init__.py diff --git a/src/sampletones_core/trackers/artifact.py b/src/sampletones_core/exports/artifact.py similarity index 100% rename from src/sampletones_core/trackers/artifact.py rename to src/sampletones_core/exports/artifact.py diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/exports/backend.py similarity index 87% rename from src/sampletones_core/trackers/backend.py rename to src/sampletones_core/exports/backend.py index dad68e0d6..ee8bafc61 100644 --- a/src/sampletones_core/trackers/backend.py +++ b/src/sampletones_core/exports/backend.py @@ -1,18 +1,18 @@ from pathlib import Path from typing import FrozenSet, Protocol -from sampletones_core.trackers.artifact import ExportArtifact -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import ( +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.scope import ExportScope -class TrackerBackend(Protocol): - """Writes the application's work in the file format one tracker reads. +class ExportBackend(Protocol): + """Writes the application's work in one of the file formats it exports to. A backend owns both the byte layout and the shape each :class:`ExportScope` takes on disk, so a format that gathers a whole reconstruction into one document writes one @@ -21,7 +21,7 @@ class TrackerBackend(Protocol): """ @property - def tracker_format(self) -> TrackerFormat: + def export_format(self) -> ExportFormat: """The format this backend writes.""" @property diff --git a/src/sampletones_core/trackers/extensions.py b/src/sampletones_core/exports/extensions.py similarity index 58% rename from src/sampletones_core/trackers/extensions.py rename to src/sampletones_core/exports/extensions.py index 7333ea06c..1ee0dc2d3 100644 --- a/src/sampletones_core/trackers/extensions.py +++ b/src/sampletones_core/exports/extensions.py @@ -1,18 +1,18 @@ from typing import Mapping, Optional -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.scope import ExportScope def format_for_extension( - backends: Mapping[TrackerFormat, TrackerBackend], + backends: Mapping[ExportFormat, ExportBackend], scope: ExportScope, extension: str, -) -> Optional[TrackerFormat]: +) -> Optional[ExportFormat]: """The format whose ``scope`` files carry ``extension``. - The destination the user names decides which tracker the export is written for, so the + The destination the user names decides which format the export is written in, so the extension it ends in resolves to a format here. Case folds, letting a destination typed in capitals reach the same backend. @@ -22,12 +22,12 @@ def format_for_extension( extension: The extension the chosen destination carries, leading dot included. Returns: - Optional[TrackerFormat]: The format claiming ``extension``, or ``None`` when no + Optional[ExportFormat]: The format claiming ``extension``, or ``None`` when no format able to express ``scope`` writes it. """ wanted = extension.casefold() - for tracker_format, backend in backends.items(): + for export_format, backend in backends.items(): if scope in backend.supported_scopes and backend.extension(scope).casefold() == wanted: - return tracker_format + return export_format return None diff --git a/src/sampletones_core/trackers/format.py b/src/sampletones_core/exports/format.py similarity index 52% rename from src/sampletones_core/trackers/format.py rename to src/sampletones_core/exports/format.py index 625f55e5c..5c49e1b50 100644 --- a/src/sampletones_core/trackers/format.py +++ b/src/sampletones_core/exports/format.py @@ -1,8 +1,8 @@ from enum import StrEnum -class TrackerFormat(StrEnum): - """A file format one tracker reads, and the backend that writes it.""" +class ExportFormat(StrEnum): + """A file format the application exports to, and the backend that writes it.""" FAMITRACKER = "famitracker" BITPHASE = "bitphase" diff --git a/src/sampletones_core/trackers/implementation/__init__.py b/src/sampletones_core/exports/implementation/__init__.py similarity index 100% rename from src/sampletones_core/trackers/implementation/__init__.py rename to src/sampletones_core/exports/implementation/__init__.py diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py similarity index 85% rename from src/sampletones_core/trackers/implementation/bitphase.py rename to src/sampletones_core/exports/implementation/bitphase.py index 2d97a9d8c..d154062f8 100644 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -1,6 +1,14 @@ from pathlib import Path from typing import FrozenSet, List +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope from sampletones_core.formats.bitphase.btp import write_btp from sampletones_core.formats.bitphase.builder import ( instrument_to_bitphase, @@ -8,10 +16,6 @@ sample_to_bitphase, ) from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset -from sampletones_core.trackers.artifact import ExportArtifact -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport -from sampletones_core.trackers.scope import ExportScope from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_shared.utils.system.paths import get_filename @@ -31,8 +35,8 @@ class BitphaseBackend: """ @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.BITPHASE + def export_format(self) -> ExportFormat: + return ExportFormat.BITPHASE @property def supported_scopes(self) -> FrozenSet[ExportScope]: @@ -76,8 +80,8 @@ class BitphasePresetBackend: """ @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.BITPHASE_PRESET + def export_format(self) -> ExportFormat: + return ExportFormat.BITPHASE_PRESET @property def supported_scopes(self) -> FrozenSet[ExportScope]: @@ -103,7 +107,12 @@ def write_sample( paths: List[Path] = [] for instrument in request.instruments: - filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_JSON)) + filepath = destination.with_name( + get_filename( + instrument.name, + EXT_FILE_JSON, + ) + ) paths.extend(self.write_instrument(filepath, instrument).paths) return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py similarity index 90% rename from src/sampletones_core/trackers/implementation/famitracker.py rename to src/sampletones_core/exports/implementation/famitracker.py index 66cababff..bf6a86010 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -2,6 +2,14 @@ from typing import FrozenSet, List, Optional from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.export import write_ftm from sampletones_core.formats.famitracker.instrument import write_fti @@ -11,14 +19,6 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.trackers.artifact import ExportArtifact -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import ( - InstrumentExport, - ProjectExport, - SampleExport, -) -from sampletones_core.trackers.scope import ExportScope from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_shared.utils.system.paths import get_filename @@ -34,8 +34,8 @@ class FamiTrackerBackend: """ @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.FAMITRACKER + def export_format(self) -> ExportFormat: + return ExportFormat.FAMITRACKER @property def supported_scopes(self) -> FrozenSet[ExportScope]: diff --git a/src/sampletones_core/exports/registry.py b/src/sampletones_core/exports/registry.py new file mode 100644 index 000000000..ec64c7b7b --- /dev/null +++ b/src/sampletones_core/exports/registry.py @@ -0,0 +1,24 @@ +from typing import Dict + +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.implementation.bitphase import BitphaseBackend, BitphasePresetBackend +from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend + + +def build_tracker_backends() -> Dict[ExportFormat, ExportBackend]: + """Builds one backend per tracker format the reconstruction engine writes. + + The composition root calls this once and hands the result to the components that + offer a format choice, so a new tracker format reaches the whole application by + joining this mapping. A format whose backend stands above the engine — the console + player's own — is registered beside these by the composition root itself. + + Returns: + Dict[ExportFormat, ExportBackend]: Every tracker backend, keyed by the format it writes. + """ + return { + ExportFormat.FAMITRACKER: FamiTrackerBackend(), + ExportFormat.BITPHASE: BitphaseBackend(), + ExportFormat.BITPHASE_PRESET: BitphasePresetBackend(), + } diff --git a/src/sampletones_core/trackers/request.py b/src/sampletones_core/exports/request.py similarity index 100% rename from src/sampletones_core/trackers/request.py rename to src/sampletones_core/exports/request.py diff --git a/src/sampletones_core/trackers/scope.py b/src/sampletones_core/exports/scope.py similarity index 100% rename from src/sampletones_core/trackers/scope.py rename to src/sampletones_core/exports/scope.py diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 006f4510f..38af3e067 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -5,6 +5,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.formats.bitphase.envelopes import ( ChannelEnvelopes, features_to_envelopes, @@ -66,7 +67,6 @@ from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove -from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED PREVIEW_SPEED = DEFAULT_SPEED diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index e4c8a9240..0479bc2b3 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -3,6 +3,7 @@ from typing import Final, Sequence, Tuple from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.request import InstrumentExport from sampletones_core.formats.bitphase.envelopes import features_to_envelopes from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow from sampletones_core.formats.bitphase.notes import pitch_to_note_index @@ -14,7 +15,6 @@ ) from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX from sampletones_core.formats.bitphase.tuning import generate_tuning_table -from sampletones_core.trackers.request import InstrumentExport PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( DEFAULT_CPU_FREQUENCY, diff --git a/src/sampletones_core/trackers/registry.py b/src/sampletones_core/trackers/registry.py deleted file mode 100644 index c979a926f..000000000 --- a/src/sampletones_core/trackers/registry.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Dict - -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend -from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend - - -def build_tracker_backends() -> Dict[TrackerFormat, TrackerBackend]: - """Builds one backend per tracker format the application can write. - - The composition root calls this once and hands the result to the components that - offer a format choice, so a new format reaches the whole application by joining - this mapping. - - Returns: - Dict[TrackerFormat, TrackerBackend]: Every backend, keyed by the format it writes. - """ - return { - TrackerFormat.FAMITRACKER: FamiTrackerBackend(), - TrackerFormat.BITPHASE: BitphaseBackend(), - TrackerFormat.BITPHASE_PRESET: BitphasePresetBackend(), - } diff --git a/src/sampletones_shared/meta/source/annotations.py b/src/sampletones_shared/meta/source/annotations.py index 864655523..5994feb7e 100644 --- a/src/sampletones_shared/meta/source/annotations.py +++ b/src/sampletones_shared/meta/source/annotations.py @@ -58,7 +58,7 @@ def annotation_type_name(annotation: Optional[ast.expr]) -> Optional[str]: def annotation_item_types(annotation: Optional[ast.expr]) -> Tuple[str, ...]: """The type names a container annotation states for what it holds. - `Dict[TrackerFormat, FileFilterElements]` states its key type and then its value type, and + `Dict[ExportFormat, FileFilterElements]` states its key type and then its value type, and `Tuple[MenuElements, ...]` states its item type. Args: diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 26604f4d8..2574101c4 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -10,8 +10,8 @@ from sampletones_core.audio import read_wave from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features -from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend -from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend +from sampletones_core.exports.request import InstrumentExport, SampleExport NES_FREQUENCY: Final[int] = 60 diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_exports.py similarity index 61% rename from tests/unit/sampletones_application/categories/test_trackers.py rename to tests/unit/sampletones_application/categories/test_exports.py index 7f42d3e7f..55524b380 100644 --- a/tests/unit/sampletones_application/categories/test_trackers.py +++ b/tests/unit/sampletones_application/categories/test_exports.py @@ -2,33 +2,33 @@ import pytest -from sampletones_application.categories.trackers import ( +from sampletones_application.categories.exports import ( + EXPORT_INSTRUMENT_FILTERS, + EXPORT_PROJECT_ELEMENTS, + EXPORT_PROJECT_MENU_LABELS, + EXPORT_SAMPLE_MENU_LABELS, INSTRUMENT_EXPORT_FORMATS, - TRACKER_INSTRUMENT_FILTERS, - TRACKER_PROJECT_ELEMENTS, - TRACKER_PROJECT_MENU_LABELS, - TRACKER_SAMPLE_MENU_LABELS, ) from sampletones_application.utils.gui.shortcuts.ids import ( PROJECT_EXPORT_SHORTCUT_IDS, SAMPLE_EXPORT_SHORTCUT_IDS, ) -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends +from sampletones_core.exports.scope import ExportScope @pytest.fixture(name="backends") -def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: +def backends_fixture() -> Dict[ExportFormat, ExportBackend]: return build_tracker_backends() def formats_supporting( - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], scope: ExportScope, -) -> Set[TrackerFormat]: - return {tracker_format for tracker_format, backend in backends.items() if scope in backend.supported_scopes} +) -> Set[ExportFormat]: + return {export_format for export_format, backend in backends.items() if scope in backend.supported_scopes} class TestEveryOfferedFormatHasABackend: @@ -39,10 +39,10 @@ class TestEveryOfferedFormatHasABackend: "offered", [ frozenset(PROJECT_EXPORT_SHORTCUT_IDS), - frozenset(TRACKER_PROJECT_MENU_LABELS), - frozenset(TRACKER_PROJECT_ELEMENTS), + frozenset(EXPORT_PROJECT_MENU_LABELS), + frozenset(EXPORT_PROJECT_ELEMENTS), frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), - frozenset(TRACKER_SAMPLE_MENU_LABELS), + frozenset(EXPORT_SAMPLE_MENU_LABELS), frozenset(INSTRUMENT_EXPORT_FORMATS), ], ids=[ @@ -56,8 +56,8 @@ class TestEveryOfferedFormatHasABackend: ) def test_the_registry_builds_every_offered_format( self, - backends: Dict[TrackerFormat, TrackerBackend], - offered: FrozenSet[TrackerFormat], + backends: Dict[ExportFormat, ExportBackend], + offered: FrozenSet[ExportFormat], ) -> None: assert offered <= frozenset(backends) @@ -72,19 +72,19 @@ class TestTheMenusMatchTheSupportedScopes: def test_the_project_export_menu_lists_the_formats_that_write_a_project( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], ) -> None: - assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) + assert set(EXPORT_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) def test_the_instruments_menu_offers_formats_that_write_a_whole_sample( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], ) -> None: - assert set(TRACKER_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE) + assert set(EXPORT_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE) def test_the_instrument_button_offers_formats_that_write_one_slice( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], ) -> None: assert set(INSTRUMENT_EXPORT_FORMATS) <= formats_supporting(backends, ExportScope.INSTRUMENT) @@ -94,10 +94,10 @@ class TestEveryMenuEntryCarriesAnAction: cover the same formats.""" def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: - assert set(TRACKER_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) + assert set(EXPORT_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: - assert set(TRACKER_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) + assert set(EXPORT_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) class TestEveryOfferedFormatIsNamedInItsDialog: @@ -105,5 +105,5 @@ class TestEveryOfferedFormatIsNamedInItsDialog: without a type name would reach the dialog unnamed.""" def test_every_instrument_export_format_carries_a_file_type(self) -> None: - offered = set(INSTRUMENT_EXPORT_FORMATS) | set(TRACKER_SAMPLE_MENU_LABELS) - assert offered <= set(TRACKER_INSTRUMENT_FILTERS) + offered = set(INSTRUMENT_EXPORT_FORMATS) | set(EXPORT_SAMPLE_MENU_LABELS) + assert offered <= set(EXPORT_INSTRUMENT_FILTERS) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index b23318406..7b80d538a 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -13,7 +13,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -270,7 +270,7 @@ def test_a_complete_instrument_export_shows_the_success_message( ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), - tracker_format=TrackerFormat.FAMITRACKER, + export_format=ExportFormat.FAMITRACKER, truncation=None, ) ) @@ -285,7 +285,7 @@ def test_a_shortened_instrument_export_names_both_frame_counts( ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("lead.fti"), - tracker_format=TrackerFormat.FAMITRACKER, + export_format=ExportFormat.FAMITRACKER, truncation=EnvelopeTruncation(frames=252, source_frames=300, instruments=1), ) ) @@ -303,7 +303,7 @@ def test_a_shortened_reconstruction_export_counts_the_instruments( ExportSuccess( kind=ExportKind.SAMPLE, filepath=Path("instruments"), - tracker_format=TrackerFormat.FAMITRACKER, + export_format=ExportFormat.FAMITRACKER, truncation=EnvelopeTruncation( frames=252, source_frames=410, @@ -324,7 +324,7 @@ def test_a_wav_export_shows_its_own_message( ExportSuccess( kind=ExportKind.WAV, filepath=Path("track.wav"), - tracker_format=None, + export_format=None, truncation=None, ) ) diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index e811e956b..a32fd6344 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -24,7 +24,7 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), MagicMock(), MagicMock(), - tracker_backends={}, + export_backends={}, dialogs=MagicMock(), language_manager=MagicMock(), on_tab_switch=MagicMock(), diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 7e41399e3..d3ebc2bb5 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -20,10 +20,10 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, ChannelName +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends from sampletones_core.instructions import TriangleInstruction from sampletones_core.reconstructions import Reconstruction -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, @@ -38,13 +38,13 @@ @dataclass(frozen=True) class FormatCase: extension: str - tracker_format: TrackerFormat + export_format: ExportFormat INSTRUMENT_FORMAT_CASES: Final[List[FormatCase]] = [ - FormatCase(extension=EXT_FILE_INSTRUMENT, tracker_format=TrackerFormat.FAMITRACKER), - FormatCase(extension=EXT_FILE_BITPHASE, tracker_format=TrackerFormat.BITPHASE), - FormatCase(extension=EXT_FILE_JSON, tracker_format=TrackerFormat.BITPHASE_PRESET), + FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER), + FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE), + FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET), ] UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] @@ -76,29 +76,29 @@ def panel_logic( session_manager: MagicMock, mock_reconstruction_manager: MagicMock, mock_export_service: MagicMock, - mock_tracker_backends: Dict[TrackerFormat, MagicMock], + mock_export_backends: Dict[ExportFormat, MagicMock], ) -> ReconstructionPanelLogic: return ReconstructionPanelLogic( session_manager, mock_reconstruction_manager, mock_export_service, - mock_tracker_backends, + mock_export_backends, ) @pytest.fixture -def mock_tracker_backends() -> Dict[TrackerFormat, MagicMock]: +def mock_export_backends() -> Dict[ExportFormat, MagicMock]: """Stands in for the real backends while declaring the scopes and extensions they do. The logic reads the destination's extension to pick a backend, so each stub mirrors what the registry's backend declares and leaves only the writing to the mock. """ - backends: Dict[TrackerFormat, MagicMock] = {} - for tracker_format, backend in build_tracker_backends().items(): + backends: Dict[ExportFormat, MagicMock] = {} + for export_format, backend in build_tracker_backends().items(): stub = MagicMock() stub.supported_scopes = backend.supported_scopes stub.extension.side_effect = backend.extension - backends[tracker_format] = stub + backends[export_format] = stub return backends @@ -640,7 +640,7 @@ def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_na mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, mock_export_service: MagicMock, - mock_tracker_backends: Dict[TrackerFormat, MagicMock], + mock_export_backends: Dict[ExportFormat, MagicMock], tmp_path: Path, case: FormatCase, ) -> None: @@ -650,7 +650,7 @@ def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_na ChannelName.PULSE1, ) backend = mock_export_service.export_instrument.call_args.args[1] - assert backend is mock_tracker_backends[case.tracker_format] + assert backend is mock_export_backends[case.export_format] @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_writes( @@ -678,7 +678,7 @@ def test_request_export_instruments_dialog_with_no_data_raises_assertion_error( panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + panel_logic.request_export_instruments_dialog(ExportFormat.FAMITRACKER) def test_request_export_instruments_dialog_fires_dialog_callback( self, @@ -689,7 +689,7 @@ def test_request_export_instruments_dialog_fires_dialog_callback( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instruments_dialog = callback - panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + panel_logic.request_export_instruments_dialog(ExportFormat.FAMITRACKER) callback.assert_called_once() def test_request_export_instruments_dialog_suggests_the_reconstruction_name( @@ -704,7 +704,7 @@ def test_request_export_instruments_dialog_suggests_the_reconstruction_name( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instruments_dialog = callback - panel_logic.request_export_instruments_dialog(TrackerFormat.FAMITRACKER) + panel_logic.request_export_instruments_dialog(ExportFormat.FAMITRACKER) assert callback.call_args.args[0] == f"{loaded_data.name}{EXT_FILE_INSTRUMENT}" def test_request_export_instruments_dialog_carries_the_chosen_tracker( @@ -717,8 +717,8 @@ def test_request_export_instruments_dialog_carries_the_chosen_tracker( mock_reconstruction_manager.current_reconstruction = loaded_data callback = MagicMock() panel_logic.on_open_export_instruments_dialog = callback - panel_logic.request_export_instruments_dialog(TrackerFormat.BITPHASE_PRESET) - assert callback.call_args.args[2] == TrackerFormat.BITPHASE_PRESET + panel_logic.request_export_instruments_dialog(ExportFormat.BITPHASE_PRESET) + assert callback.call_args.args[2] == ExportFormat.BITPHASE_PRESET def test_handle_export_instruments_confirmed_with_no_data_is_no_op( self, @@ -728,7 +728,7 @@ def test_handle_export_instruments_confirmed_with_no_data_is_no_op( ) -> None: panel_logic.handle_export_instruments_confirmed( tmp_path / "sample.fti", - TrackerFormat.FAMITRACKER, + ExportFormat.FAMITRACKER, ) mock_export_service.export_sample.assert_not_called() @@ -743,7 +743,7 @@ def test_handle_export_instruments_confirmed_calls_export_sample( mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instruments_confirmed( tmp_path / "sample.fti", - TrackerFormat.FAMITRACKER, + ExportFormat.FAMITRACKER, ) mock_export_service.export_sample.assert_called_once() @@ -758,7 +758,7 @@ def test_handle_export_instruments_confirmed_names_the_batch_after_the_destinati mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instruments_confirmed( tmp_path / "Clap.fti", - TrackerFormat.FAMITRACKER, + ExportFormat.FAMITRACKER, ) request = mock_export_service.export_sample.call_args.args[2] assert request.name == "Clap" @@ -775,7 +775,7 @@ def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, mock_export_service: MagicMock, - mock_tracker_backends: Dict[TrackerFormat, MagicMock], + mock_export_backends: Dict[ExportFormat, MagicMock], tmp_path: Path, case: FormatCase, ) -> None: @@ -785,10 +785,10 @@ def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker( mock_reconstruction_manager.current_reconstruction = loaded_data panel_logic.handle_export_instruments_confirmed( tmp_path / f"sample{case.extension}", - case.tracker_format, + case.export_format, ) backend = mock_export_service.export_sample.call_args.args[1] - assert backend is mock_tracker_backends[case.tracker_format] + assert backend is mock_export_backends[case.export_format] class TestReconstructionPanelLogicExportWav: diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py index 715763e14..f4222ef4a 100644 --- a/tests/unit/sampletones_application/services/export/test_result.py +++ b/tests/unit/sampletones_application/services/export/test_result.py @@ -7,7 +7,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat class TestExportSuccess: @@ -16,22 +16,22 @@ def test_stores_kind_and_filepath(self) -> None: success = ExportSuccess( kind=ExportKind.WAV, filepath=filepath, - tracker_format=None, + export_format=None, truncation=None, ) assert success.kind == ExportKind.WAV assert success.filepath == filepath - assert success.tracker_format is None + assert success.export_format is None assert success.truncation is None def test_stores_the_tracker_format(self) -> None: success = ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("/x"), - tracker_format=TrackerFormat.BITPHASE, + export_format=ExportFormat.BITPHASE, truncation=None, ) - assert success.tracker_format == TrackerFormat.BITPHASE + assert success.export_format == ExportFormat.BITPHASE def test_stores_the_truncation(self) -> None: truncation = EnvelopeTruncation( @@ -42,7 +42,7 @@ def test_stores_the_truncation(self) -> None: success = ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=Path("/x"), - tracker_format=TrackerFormat.FAMITRACKER, + export_format=ExportFormat.FAMITRACKER, truncation=truncation, ) assert success.truncation == truncation @@ -51,7 +51,7 @@ def test_frozen(self) -> None: success = ExportSuccess( kind=ExportKind.WAV, filepath=Path("/x"), - tracker_format=None, + export_format=None, truncation=None, ) with pytest.raises(FrozenInstanceError): @@ -62,23 +62,23 @@ def test_equality(self) -> None: assert ExportSuccess( kind=ExportKind.WAV, filepath=path, - tracker_format=None, + export_format=None, truncation=None, ) == ExportSuccess( kind=ExportKind.WAV, filepath=path, - tracker_format=None, + export_format=None, truncation=None, ) assert ExportSuccess( kind=ExportKind.WAV, filepath=path, - tracker_format=None, + export_format=None, truncation=None, ) != ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, - tracker_format=None, + export_format=None, truncation=None, ) @@ -87,12 +87,12 @@ def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None: assert ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, - tracker_format=TrackerFormat.FAMITRACKER, + export_format=ExportFormat.FAMITRACKER, truncation=None, ) != ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, - tracker_format=TrackerFormat.BITPHASE, + export_format=ExportFormat.BITPHASE, truncation=None, ) @@ -102,17 +102,17 @@ def test_stores_kind_and_exception(self) -> None: exception = OSError("disk full") error = ExportError( kind=ExportKind.INSTRUMENT, - tracker_format=TrackerFormat.FAMITRACKER, + export_format=ExportFormat.FAMITRACKER, exception=exception, ) assert error.kind == ExportKind.INSTRUMENT - assert error.tracker_format == TrackerFormat.FAMITRACKER + assert error.export_format == ExportFormat.FAMITRACKER assert error.exception is exception def test_frozen(self) -> None: error = ExportError( kind=ExportKind.WAV, - tracker_format=None, + export_format=None, exception=OSError(), ) with pytest.raises(FrozenInstanceError): @@ -122,12 +122,12 @@ def test_eq_false_same_exception_instances_differ(self) -> None: exception = OSError("same") error_a = ExportError( kind=ExportKind.WAV, - tracker_format=None, + export_format=None, exception=exception, ) error_b = ExportError( kind=ExportKind.WAV, - tracker_format=None, + export_format=None, exception=exception, ) assert error_a != error_b @@ -135,7 +135,7 @@ def test_eq_false_same_exception_instances_differ(self) -> None: def test_same_instance_equals_itself(self) -> None: error = ExportError( kind=ExportKind.WAV, - tracker_format=None, + export_format=None, exception=OSError(), ) assert error == error # noqa: PLR0124 diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index aed169b37..bc66e4d8d 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -12,15 +12,15 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.project.project import Project -from sampletones_core.trackers.artifact import ExportArtifact -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import ( +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.scope import ExportScope +from sampletones_core.project.project import Project NES_FREQUENCY: Final[int] = 60 @@ -42,8 +42,8 @@ def __init__( self.calls: List[Tuple[str, Path, Any]] = [] @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.FAMITRACKER + def export_format(self) -> ExportFormat: + return ExportFormat.FAMITRACKER @property def supported_scopes(self) -> frozenset: @@ -356,7 +356,7 @@ def test_a_tracker_export_names_the_format_it_was_written_in( build_instrument(), ) - assert results[0].tracker_format == TrackerFormat.FAMITRACKER + assert results[0].export_format == ExportFormat.FAMITRACKER def test_a_failed_tracker_export_names_the_format_it_was_written_in( self, @@ -371,7 +371,7 @@ def test_a_failed_tracker_export_names_the_format_it_was_written_in( build_sample(), ) - assert results[0].tracker_format == TrackerFormat.FAMITRACKER + assert results[0].export_format == ExportFormat.FAMITRACKER def test_a_wav_export_names_no_format(self, service, tmp_path) -> None: export_service, results = service @@ -383,7 +383,7 @@ def test_a_wav_export_names_no_format(self, service, tmp_path) -> None: np.zeros(100), ) - assert results[0].tracker_format is None + assert results[0].export_format is None class TestExportTruncationReporting: diff --git a/tests/unit/sampletones_core/trackers/__init__.py b/tests/unit/sampletones_core/exports/__init__.py similarity index 100% rename from tests/unit/sampletones_core/trackers/__init__.py rename to tests/unit/sampletones_core/exports/__init__.py diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py similarity index 95% rename from tests/unit/sampletones_core/trackers/test_bitphase.py rename to tests/unit/sampletones_core/exports/test_bitphase.py index 4bf4edeb4..e844ca877 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -9,19 +9,19 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features -from sampletones_core.project.project import Project -from sampletones_core.project.settings import ProjectSettings -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.implementation.bitphase import ( +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.implementation.bitphase import ( BitphaseBackend, BitphasePresetBackend, ) -from sampletones_core.trackers.request import ( +from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.scope import ExportScope +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON NES_FREQUENCY: Final[int] = 60 @@ -82,7 +82,7 @@ def project_fixture() -> Project: class TestFormatDeclaration: def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None: - assert backend.tracker_format == TrackerFormat.BITPHASE + assert backend.export_format == ExportFormat.BITPHASE def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None: assert backend.supported_scopes == frozenset(ExportScope) @@ -171,7 +171,7 @@ def test_the_document_takes_the_project_title( class TestThePresetBackend: def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None: - assert preset_backend.tracker_format == TrackerFormat.BITPHASE_PRESET + assert preset_backend.export_format == ExportFormat.BITPHASE_PRESET def test_a_preset_holds_instruments_rather_than_a_song(self, preset_backend: BitphasePresetBackend) -> None: assert preset_backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/exports/test_extensions.py similarity index 73% rename from tests/unit/sampletones_core/trackers/test_extensions.py rename to tests/unit/sampletones_core/exports/test_extensions.py index b9cc907d1..675b7480d 100644 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ b/tests/unit/sampletones_core/exports/test_extensions.py @@ -3,11 +3,11 @@ import pytest -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.extensions import format_for_extension -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends -from sampletones_core.trackers.scope import ExportScope +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.extensions import format_for_extension +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends +from sampletones_core.exports.scope import ExportScope from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, @@ -23,34 +23,34 @@ class ExtensionCase: scope: ExportScope extension: str - expected: Optional[TrackerFormat] + expected: Optional[ExportFormat] EXTENSION_CASES: Final[List[ExtensionCase]] = [ ExtensionCase( scope=ExportScope.INSTRUMENT, extension=EXT_FILE_INSTRUMENT, - expected=TrackerFormat.FAMITRACKER, + expected=ExportFormat.FAMITRACKER, ), ExtensionCase( scope=ExportScope.INSTRUMENT, extension=EXT_FILE_BITPHASE, - expected=TrackerFormat.BITPHASE, + expected=ExportFormat.BITPHASE, ), ExtensionCase( scope=ExportScope.INSTRUMENT, extension=EXT_FILE_JSON, - expected=TrackerFormat.BITPHASE_PRESET, + expected=ExportFormat.BITPHASE_PRESET, ), ExtensionCase( scope=ExportScope.SAMPLE, extension=EXT_FILE_JSON, - expected=TrackerFormat.BITPHASE_PRESET, + expected=ExportFormat.BITPHASE_PRESET, ), ExtensionCase( scope=ExportScope.PROJECT, extension=EXT_FILE_MODULE, - expected=TrackerFormat.FAMITRACKER, + expected=ExportFormat.FAMITRACKER, ), ExtensionCase( scope=ExportScope.INSTRUMENT, @@ -66,7 +66,7 @@ class ExtensionCase: @pytest.fixture(name="backends") -def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: +def backends_fixture() -> Dict[ExportFormat, ExportBackend]: return build_tracker_backends() @@ -78,23 +78,23 @@ class TestFormatForExtension: ) def test_the_extension_names_the_format_that_writes_it( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], case: ExtensionCase, ) -> None: assert format_for_extension(backends, case.scope, case.extension) == case.expected def test_an_extension_typed_in_capitals_reaches_the_same_format( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], ) -> None: assert ( format_for_extension(backends, ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT.upper()) - == TrackerFormat.FAMITRACKER + == ExportFormat.FAMITRACKER ) def test_a_format_that_cannot_express_the_scope_stays_unmatched( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], ) -> None: """A preset holds one instrument, so a project named with its extension resolves to no format at all. @@ -104,13 +104,13 @@ def test_a_format_that_cannot_express_the_scope_stays_unmatched( @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) def test_every_extension_a_backend_writes_resolves_back_to_it( self, - backends: Dict[TrackerFormat, TrackerBackend], + backends: Dict[ExportFormat, ExportBackend], scope: ExportScope, ) -> None: """A dialog offers the extension of each format it can reach, so a destination taking one of them names the backend that put it in the selector. Each scope's extensions are therefore distinct across formats, which is what the resolution reads them as. """ - for tracker_format, backend in backends.items(): + for export_format, backend in backends.items(): if scope in backend.supported_scopes: - assert format_for_extension(backends, scope, backend.extension(scope)) == tracker_format + assert format_for_extension(backends, scope, backend.extension(scope)) == export_format diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py similarity index 94% rename from tests/unit/sampletones_core/trackers/test_famitracker.py rename to tests/unit/sampletones_core/exports/test_famitracker.py index b17584be0..bd181e948 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -7,13 +7,13 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.scope import ExportScope from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend -from sampletones_core.trackers.request import InstrumentExport, SampleExport -from sampletones_core.trackers.scope import ExportScope from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE NES_FREQUENCY: Final[int] = 60 @@ -52,7 +52,7 @@ def backend_fixture() -> FamiTrackerBackend: class TestFormatDeclaration: def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None: - assert backend.tracker_format == TrackerFormat.FAMITRACKER + assert backend.export_format == ExportFormat.FAMITRACKER def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None: assert backend.supported_scopes == frozenset(ExportScope) diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index 567372954..e1fa3c85c 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features -from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.exports.request import InstrumentExport, SampleExport NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py b/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py index 2145ce5b8..b66c9d09e 100644 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py +++ b/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py @@ -8,12 +8,12 @@ ) from tests.suite.source import parse_source -FILTER_TYPES: Final[Tuple[str, ...]] = ("TrackerFormat", "FileFilterElements") +FILTER_TYPES: Final[Tuple[str, ...]] = ("ExportFormat", "FileFilterElements") ANNOTATED_SOURCE: Final[str] = """ from typing import Dict, Final, List -FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {} +FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {} NAMES: Final[List[str]] = [] PLAIN = {} @@ -71,8 +71,8 @@ def test_a_computed_iterable_reads_no_container(self) -> None: class TestIteratedTypes: def test_walking_items_types_the_key_and_the_value_target(self) -> None: - assert typed_names("tracker_format, element", "items", FILTER_TYPES) == [ - ("tracker_format", "TrackerFormat"), + assert typed_names("export_format, element", "items", FILTER_TYPES) == [ + ("export_format", "ExportFormat"), ("element", "FileFilterElements"), ] @@ -88,20 +88,20 @@ def test_walking_values_types_the_target_from_the_value_type(self) -> None: ] def test_walking_keys_types_the_target_from_the_key_type(self) -> None: - assert typed_names("tracker_format", "keys", FILTER_TYPES) == [ + assert typed_names("export_format", "keys", FILTER_TYPES) == [ ( - "tracker_format", - "TrackerFormat", + "export_format", + "ExportFormat", ) ] def test_walking_a_container_directly_types_the_target_from_the_key_type( self, ) -> None: - assert typed_names("tracker_format", None, FILTER_TYPES) == [ + assert typed_names("export_format", None, FILTER_TYPES) == [ ( - "tracker_format", - "TrackerFormat", + "export_format", + "ExportFormat", ) ] diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py index 22ee5cc57..aa4b6d2aa 100644 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py +++ b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py @@ -7,7 +7,7 @@ PANEL_SOURCE: Final[str] = """ from typing import Dict, Final -FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {} +FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {} class Panel: @@ -29,8 +29,8 @@ def label(element: SequencerTrackerElements) -> str: self._labels = [label(item) for item in FILTERS.values()] def _filters(self) -> None: - for tracker_format, element in FILTERS.items(): - print(tracker_format, element) + for export_format, element in FILTERS.items(): + print(export_format, element) def _names(self) -> None: for name in FILTERS: @@ -39,23 +39,23 @@ def _names(self) -> None: IMPORTED_SOURCE: Final[str] = """ def create() -> None: - for tracker_format, element in TRACKER_FILTERS.items(): - print(tracker_format, element) + for export_format, element in EXPORT_FILTERS.items(): + print(export_format, element) """ LOCAL_OVER_IMPORTED_SOURCE: Final[str] = """ from typing import Dict, Final -TRACKER_FILTERS: Final[Dict[str, MenuElements]] = {} +EXPORT_FILTERS: Final[Dict[str, MenuElements]] = {} def create() -> None: - for element in TRACKER_FILTERS.values(): + for element in EXPORT_FILTERS.values(): print(element) """ IMPORTED_FILTERS: Final[Mapping[str, Tuple[str, ...]]] = { - "TRACKER_FILTERS": ("TrackerFormat", "FileFilterElements"), + "EXPORT_FILTERS": ("ExportFormat", "FileFilterElements"), } @@ -137,10 +137,10 @@ class TestLoopTargets: def test_walking_items_states_the_key_and_the_value_type(self) -> None: environment = panel_environment("_filters") assert ( - environment.type_of("tracker_format"), + environment.type_of("export_format"), environment.type_of("element"), ) == ( - "TrackerFormat", + "ExportFormat", "FileFilterElements", ) @@ -148,7 +148,7 @@ def test_walking_values_states_the_value_type(self) -> None: assert panel_environment("_load").type_of("item") == "FileFilterElements" def test_walking_a_mapping_states_the_key_type(self) -> None: - assert panel_environment("_names").type_of("name") == "TrackerFormat" + assert panel_environment("_names").type_of("name") == "ExportFormat" def test_an_imported_container_states_its_item_types(self) -> None: assert ( diff --git a/tests/unit/sampletones_shared/meta/source/test_annotations.py b/tests/unit/sampletones_shared/meta/source/test_annotations.py index e08917df6..36b3de5a7 100644 --- a/tests/unit/sampletones_shared/meta/source/test_annotations.py +++ b/tests/unit/sampletones_shared/meta/source/test_annotations.py @@ -118,13 +118,13 @@ class TestCase(BaseRegularTestCase): test_cases = ( TestCase( label="mapping_states_key_then_value", - annotation="Dict[TrackerFormat, FileFilterElements]", - expected=("TrackerFormat", "FileFilterElements"), + annotation="Dict[ExportFormat, FileFilterElements]", + expected=("ExportFormat", "FileFilterElements"), ), TestCase( label="wrapped_mapping", - annotation="Final[Dict[TrackerFormat, FileFilterElements]]", - expected=("TrackerFormat", "FileFilterElements"), + annotation="Final[Dict[ExportFormat, FileFilterElements]]", + expected=("ExportFormat", "FileFilterElements"), ), TestCase( label="homogeneous_tuple", diff --git a/tests/unit/sampletones_shared/meta/source/test_index.py b/tests/unit/sampletones_shared/meta/source/test_index.py index a2da5abb0..7e50e97c0 100644 --- a/tests/unit/sampletones_shared/meta/source/test_index.py +++ b/tests/unit/sampletones_shared/meta/source/test_index.py @@ -9,7 +9,7 @@ TAGS_SOURCE: Final[str] = """ from typing import Dict, Final -FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {} +FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {} TAG_MAIN_WINDOW = "main.window" """ @@ -32,7 +32,7 @@ def index_of(*sources: str) -> SourceIndex: class TestSourceIndex: def test_a_container_states_its_item_types(self) -> None: assert index_of(TAGS_SOURCE).item_types["FILTERS"] == ( - "TrackerFormat", + "ExportFormat", "FileFilterElements", ) From 39579ad972313dbeca2e1775ae84129fbbb54225 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 20 Aug 2026 23:58:55 +0200 Subject: [PATCH 026/142] Added: an NSF export backend --- docs/development/packages.md | 3 +- docs/formats/configuration.md | 3 +- scripts/checks/import_boundary.py | 1 + src/sampletones_application/application.py | 6 +- .../logic/reconstruction/reconstruction.py | 23 ++- src/sampletones_core/configs/config.py | 5 + src/sampletones_core/configs/library.py | 19 +- src/sampletones_core/exports/format.py | 1 + src/sampletones_core/exports/request.py | 5 + .../formats/bitphase/builder.py | 1 + src/sampletones_core/timers/utils.py | 26 +-- src/sampletones_player/builder.py | 82 ++++++++- src/sampletones_player/export.py | 104 +++++++++++ src/sampletones_shared/constants/music.py | 2 + src/sampletones_shared/music.py | 51 +++++ tests/integration/nsf/console/session.py | 19 +- tests/integration/nsf/test_backend.py | 170 +++++++++++++++++ tests/integration/nsf/test_driver_audio.py | 2 +- .../services/test_export.py | 9 +- tests/suite/player.py | 59 ++++++ .../reconstruction/test_reconstruction.py | 53 ++++++ .../services/export/test_service.py | 3 + .../sampletones_core/configs/test_library.py | 39 ++++ .../sampletones_core/exports/test_bitphase.py | 9 +- .../exports/test_famitracker.py | 9 +- .../formats/bitphase/conftest.py | 9 +- .../timers/test_arithmetic.py | 15 +- tests/unit/sampletones_player/test_builder.py | 115 +++++++++++- tests/unit/sampletones_player/test_export.py | 174 ++++++++++++++++++ tests/unit/sampletones_shared/test_music.py | 58 ++++++ 30 files changed, 1036 insertions(+), 39 deletions(-) create mode 100644 src/sampletones_player/export.py create mode 100644 src/sampletones_shared/music.py create mode 100644 tests/integration/nsf/test_backend.py create mode 100644 tests/unit/sampletones_core/configs/test_library.py create mode 100644 tests/unit/sampletones_player/test_export.py create mode 100644 tests/unit/sampletones_shared/test_music.py diff --git a/docs/development/packages.md b/docs/development/packages.md index 4fdce0f19..d62f9c79d 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -76,11 +76,12 @@ them. | `clock/` | `PlaySchedule` and `FixedPointStep` — the engine ticks one play call advances a stream by | `specification/` | | `registers/` | The per-tick register values each channel plays, and the four streams together | `specification/` | | `song.py` | `Song` — the streams, the schedule and the loop point as one value | `clock/`, `registers/` | -| `builder.py` | The song a reconstruction plays as, its instructions encoded and its rate scheduled | `song.py`, `registers/`, `clock/` | +| `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded and its rate scheduled | `song.py`, `registers/`, `clock/` | | `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` | | `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | | `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` | +| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files | `builder.py`, `nsf/` | ### The build toolchain is a developer tool diff --git a/docs/formats/configuration.md b/docs/formats/configuration.md index 6ec8c1f06..674da04ac 100644 --- a/docs/formats/configuration.md +++ b/docs/formats/configuration.md @@ -38,7 +38,8 @@ change any of these and a different library is selected or generated. | `sample_rate` | audio sample rate in Hz | 8000–192000 | | `spectrum_method` | how the spectrum is computed | `fft` / `logfft` / `cqt` | | `transformation_gamma` | feature-space scaling (0 keeps the power spectrum, 100 is logarithmic) | 0–100 | -| `a4_frequency`, `a4_pitch` | tuning reference: the frequency of A4 and its pitch number | — | +| `a4_frequency` | tuning reference: what A4 sounds at, in Hz | 311 < value < 623 | +| `a4_pitch` | tuning reference: the pitch number A4 names | 24–127 | ## `generation` diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 7eb4af750..563432990 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -73,6 +73,7 @@ "builder.py": ("song.py", "registers", "clock"), "trace": ("song.py", "specification"), "nsf": ("song.py", "registers", "specification", "driver"), + "export.py": ("builder.py", "nsf"), "driver": ("specification",), "driver/assembler": ("driver", "specification"), } diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index f150b2798..ceb0aacef 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -149,6 +149,7 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode from sampletones_core.types.feature import FeatureValue +from sampletones_player.export import NSFBackend from sampletones_shared.application import ( SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, @@ -239,7 +240,10 @@ def __init__( self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) - self.export_backends: Dict[ExportFormat, ExportBackend] = build_tracker_backends() + self.export_backends: Dict[ExportFormat, ExportBackend] = { + **build_tracker_backends(), + ExportFormat.NSF: NSFBackend(), + } self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 6e39c884e..87c03ab0f 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -17,6 +17,7 @@ ) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.waveform_data import WaveformData +from sampletones_core.configs.library import InstructionsLibraryConfig from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name @@ -26,6 +27,7 @@ from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.exports.scope import ExportScope from sampletones_shared.logger import logger +from sampletones_shared.music import Tuning from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.paths import ( @@ -332,6 +334,7 @@ def handle_export_instruments_confirmed( if feature.has_frames ), nes_frequency=self._nes_frequency(), + tuning=self._tuning(), ) self._session_manager.set_instrument_path(destination.parent) self._export_service.export_sample( @@ -383,15 +386,33 @@ def _instrument_export( features=feature, loop=False, nes_frequency=self._nes_frequency(), + tuning=self._tuning(), ) def _nes_frequency(self) -> int: """The rate the loaded reconstruction's envelopes advance at, in Hz.""" + return self._library_config().nes_frequency + + def _tuning(self) -> Tuning: + """Where concert pitch sat for the loaded reconstruction. + + A backend sounding the export on its own — the console player's driver reaching pitches + through timer values — measures them from the tuning the reconstruction was built with, + which keeps what it plays in tune with the reconstruction's own approximation. + """ + return self._library_config().tuning + + def _library_config(self) -> InstructionsLibraryConfig: + """The instruction settings the loaded reconstruction was built with. + + Raises: + AssertionError: If no reconstruction is loaded. + """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be present") - return reconstruction_data.config.library.nes_frequency + return reconstruction_data.config.library def handle_export_wav_confirmed(self, filepath: Path) -> None: reconstruction_data = self._reconstruction_data diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index 1be319733..af501b8cf 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -11,6 +11,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.data.metadata import Metadata +from sampletones_shared.music import Tuning from sampletones_shared.paths.user import CONFIG_PATH from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_json, save_json @@ -129,6 +130,10 @@ def nes_frequency(self) -> int: def sample_rate(self) -> int: return self.library.sample_rate + @property + def tuning(self) -> Tuning: + return self.library.tuning + @property def frame_length(self) -> int: return self.library.frame_length diff --git a/src/sampletones_core/configs/library.py b/src/sampletones_core/configs/library.py index ce6b0981f..de9bc596d 100644 --- a/src/sampletones_core/configs/library.py +++ b/src/sampletones_core/configs/library.py @@ -11,12 +11,13 @@ from sampletones_core.constants.general import MIN_FREQUENCY from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY from sampletones_core.data import DataModel -from sampletones_shared.constants.music import A4_FREQUENCY, A4_PITCH, LIMIT_MAX_PITCH +from sampletones_shared.constants.music import A4_FREQUENCY, A4_PITCH from sampletones_shared.constants.nes import ( DEFAULT_NES_FREQUENCY, MAX_NES_FREQUENCY, MIN_NES_FREQUENCY, ) +from sampletones_shared.music import ReferenceFrequency, ReferencePitch, Tuning class InstructionsLibraryConfig(DataModel): @@ -42,20 +43,24 @@ class InstructionsLibraryConfig(DataModel): ge=0, le=MAX_TRANSFORMATION_GAMMA, ) - a4_frequency: float = Field( + a4_frequency: ReferenceFrequency = Field( default=A4_FREQUENCY, - gt=20.0, - lt=20000.0, ) - a4_pitch: int = Field( + a4_pitch: ReferencePitch = Field( default=A4_PITCH, - ge=1, - le=LIMIT_MAX_PITCH, ) spectrum_method: SpectrumMethod = Field( default=SpectrumMethod.CQT, ) + @property + def tuning(self) -> Tuning: + """The tuning the reconstruction's pitches are measured from.""" + return Tuning( + a4_frequency=self.a4_frequency, + a4_pitch=self.a4_pitch, + ) + @property def frame_length(self) -> int: return round(self.sample_rate / self.nes_frequency) diff --git a/src/sampletones_core/exports/format.py b/src/sampletones_core/exports/format.py index 5c49e1b50..3b893f269 100644 --- a/src/sampletones_core/exports/format.py +++ b/src/sampletones_core/exports/format.py @@ -7,3 +7,4 @@ class ExportFormat(StrEnum): FAMITRACKER = "famitracker" BITPHASE = "bitphase" BITPHASE_PRESET = "bitphase_preset" + NSF = "nsf" diff --git a/src/sampletones_core/exports/request.py b/src/sampletones_core/exports/request.py index 3daab57de..2aaa8153f 100644 --- a/src/sampletones_core/exports/request.py +++ b/src/sampletones_core/exports/request.py @@ -4,6 +4,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.project.project import Project +from sampletones_shared.music import Tuning @dataclass(frozen=True) @@ -16,6 +17,7 @@ class InstrumentExport: features: The per-dimension envelopes describing the slice. loop: Whether the instrument repeats its envelopes while its note is held. nes_frequency: Rate in Hz the envelopes advance at, one item per tick. + tuning: Where concert pitch sat for the reconstruction the slice came from. """ name: str @@ -23,6 +25,7 @@ class InstrumentExport: features: Features loop: bool nes_frequency: int + tuning: Tuning @dataclass(frozen=True) @@ -33,11 +36,13 @@ class SampleExport: name: Name of the reconstruction the slices came from. instruments: One entry per channel the reconstruction covers. nes_frequency: Rate in Hz the envelopes advance at, one item per tick. + tuning: Where concert pitch sat for the reconstruction the slices came from. """ name: str instruments: Tuple[InstrumentExport, ...] nes_frequency: int + tuning: Tuning @dataclass(frozen=True) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 38af3e067..fb66f2093 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -319,6 +319,7 @@ def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: name=request.name, instruments=(request,), nes_frequency=request.nes_frequency, + tuning=request.tuning, ) return sample_to_bitphase(sample) diff --git a/src/sampletones_core/timers/utils.py b/src/sampletones_core/timers/utils.py index d6db24bab..e46efce4b 100644 --- a/src/sampletones_core/timers/utils.py +++ b/src/sampletones_core/timers/utils.py @@ -1,7 +1,8 @@ from typing import Dict from sampletones_core.configs import Config -from sampletones_shared.utils.frequencies import pitch_to_frequency +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH +from sampletones_shared.music import Tuning from .arithmetic import frequency_to_timer from .implementation.phase import PhaseTimer @@ -12,30 +13,29 @@ def get_frequency_table(config: Config) -> Dict[int, float]: sample_rate=config.library.sample_rate, nes_frequency=config.library.nes_frequency, ) + tuning = config.library.tuning frequencies = {} for note in range(config.general.min_pitch, config.general.max_pitch + 1): - frequency = pitch_to_frequency( - note, - config.library.a4_frequency, - config.library.a4_pitch, - ) - timer.frequency = frequency + timer.frequency = tuning.frequency(note) frequencies[note] = timer.frequency return frequencies -def get_timer_table(config: Config) -> Dict[int, int]: +def get_timer_table(tuning: Tuning) -> Dict[int, int]: """The timer register value each pitch sounds at. - States the frequency table the generators render from in the terms the hardware takes, so - a channel driven by these values sounds the pitch a reconstruction was built against. + A pitch reaches the hardware as the divider producing the frequency nearest to it, and the + tuning is the whole of what decides which frequency that is — the same relation the + generators render from, stated in the terms the registers take. The table spans every pitch + the project sounds, so a stream naming any of them resolves. Args: - config: The configuration the reconstruction was built with. + tuning: Where concert pitch sits for the reconstruction being written. Returns: - Dict[int, int]: The timer value for every pitch the configuration covers. + Dict[int, int]: The timer value for every pitch from ``LIMIT_MIN_PITCH`` to + ``LIMIT_MAX_PITCH``. """ - return {pitch: frequency_to_timer(frequency) for pitch, frequency in get_frequency_table(config).items()} + return {pitch: frequency_to_timer(tuning.frequency(pitch)) for pitch in range(LIMIT_MIN_PITCH, LIMIT_MAX_PITCH + 1)} diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index bd2e93514..8c7cc3021 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -1,6 +1,8 @@ -from typing import Dict, List, Mapping, Optional, Sequence, Type +from typing import Dict, Final, List, Mapping, Optional, Sequence, Type from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import ( InstructionT, InstructionUnion, @@ -17,6 +19,8 @@ from sampletones_player.registers.triangle import TriangleRegisters from sampletones_player.song import Song +SONG_START: Final[int] = 0 + def channel_instructions( instructions: Sequence[InstructionUnion], @@ -123,8 +127,82 @@ def song_from_reconstruction( return Song( streams=streams_from_instructions( reconstruction.instructions, - get_timer_table(reconstruction.config), + get_timer_table(reconstruction.config.tuning), ), schedule=PlaySchedule.from_parameters(reconstruction.config.nes_frequency), loop_tick=loop_tick, ) + + +def instructions_from_instruments( + instruments: Sequence[InstrumentExport], +) -> Dict[ChannelName, Sequence[InstructionUnion]]: + """Reads every channel slice of an export request back as instructions. + + A request describes each slice as the envelopes an instrument carries, which is the form a + tracker reads it in. The console sounds instructions instead, so each slice is walked back + through the exporter belonging to the channel it was reconstructed for. + + Args: + instruments: The slices to sound. + + Returns: + Dict[ChannelName, Sequence[InstructionUnion]]: The stream each channel carries. + + Raises: + ValueError: If two slices name the same channel, which the console sounds one of. + """ + instructions: Dict[ChannelName, Sequence[InstructionUnion]] = {} + for instrument in instruments: + if instrument.channel in instructions: + raise ValueError(f"Channel '{instrument.channel}' carries two slices, and the console sounds one") + + exporter = CHANNEL_TO_EXPORTER_MAP[instrument.channel] + instructions[instrument.channel] = exporter.from_features(instrument.features) + + return instructions + + +def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optional[int]: + """The tick a request's song returns to once it ends. + + A song repeats from its first tick where every slice it carries repeats, and ends at its + last tick where any slice plays its envelopes once. + + Args: + instruments: The slices the song carries. + + Returns: + Optional[int]: The tick to return to, or ``None`` where the song stops at its end. + """ + if instruments and all(instrument.loop for instrument in instruments): + return SONG_START + + return None + + +def song_from_sample(request: SampleExport) -> Song: + """Builds the song the console plays an export request as. + + Every slice sounds at once on the channel it was reconstructed for, and the request states + both halves of what that takes: the tuning its pitches are measured from, and the rate its + envelopes advance at, which the driver re-clocks to the rate the console calls it at. + + Args: + request: The slices to play together. + + Returns: + Song: The streams, the clock and the loop point as the player holds them. + + Raises: + TypeError: If a channel's stream holds an instruction another channel sounds. + ValueError: If two slices name the same channel. + """ + return Song( + streams=streams_from_instructions( + instructions_from_instruments(request.instruments), + get_timer_table(request.tuning), + ), + schedule=PlaySchedule.from_parameters(request.nes_frequency), + loop_tick=loop_tick_from_instruments(request.instruments), + ) diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py new file mode 100644 index 000000000..bf026d1cc --- /dev/null +++ b/src/sampletones_player/export.py @@ -0,0 +1,104 @@ +from pathlib import Path +from typing import Final, FrozenSet + +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope +from sampletones_player.builder import song_from_sample +from sampletones_player.nsf.file import write_nsf +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.paths.extensions import EXT_FILE_NSF + +SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset( + { + ExportScope.INSTRUMENT, + ExportScope.SAMPLE, + } +) + +NO_ARTIST: Final[str] = "" +WHOLE_ENVELOPE: None = None + + +class NSFBackend: + """Writes the ``.nsf`` files NES sound players and the console itself play. + + An NSF carries its own driver, so the file plays the reconstruction rather than describing + it to a program that does: every channel slice sounds at once on the channel it was + reconstructed for, at the rate it was built at and in the tuning it was built with. One file + holds one song, so a reconstruction and a single slice each become a program of their own, + the slice sounding on its channel alone. + + The console's program area bounds how long a song may run, and one outgrowing it is reported + rather than written short. + """ + + @property + def export_format(self) -> ExportFormat: + return ExportFormat.NSF + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return SUPPORTED_SCOPES + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_NSF + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + ) -> ExportArtifact: + """Writes a program playing one channel slice. + + Raises: + SongTooLargeError: If the slice runs longer than the program area holds. + OSError: If the destination cannot be written. + """ + sample = SampleExport( + name=request.name, + instruments=(request,), + nes_frequency=request.nes_frequency, + tuning=request.tuning, + ) + return self.write_sample(destination, sample) + + def write_sample( + self, + destination: Path, + request: SampleExport, + ) -> ExportArtifact: + """Writes a program playing every channel slice of one reconstruction together. + + Raises: + SongTooLargeError: If the reconstruction runs longer than the program area holds. + OSError: If the destination cannot be written. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + write_nsf( + destination, + song_from_sample(request), + NSFInformation( + title=request.name, + artist=NO_ARTIST, + ), + ) + + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) + + def write_project( + self, + destination: Path, + request: ProjectExport, + ) -> ExportArtifact: + """Reports that a program plays one reconstruction. + + Raises: + NotImplementedError: Always, until a song flattens to the four streams a program plays. + """ + raise NotImplementedError("An NSF plays one reconstruction; a whole song reaches the console later") diff --git a/src/sampletones_shared/constants/music.py b/src/sampletones_shared/constants/music.py index 2395a6ed8..dbea837c3 100644 --- a/src/sampletones_shared/constants/music.py +++ b/src/sampletones_shared/constants/music.py @@ -8,3 +8,5 @@ A4_FREQUENCY: Final[float] = 440.0 A4_PITCH: Final[int] = 69 +MIN_A4_FREQUENCY: Final[float] = 311.0 +MAX_A4_FREQUENCY: Final[float] = 623.0 diff --git a/src/sampletones_shared/music.py b/src/sampletones_shared/music.py new file mode 100644 index 000000000..e94165387 --- /dev/null +++ b/src/sampletones_shared/music.py @@ -0,0 +1,51 @@ +from typing import Annotated + +from pydantic import BaseModel, Field + +from sampletones_shared.constants.music import ( + A4_FREQUENCY, + A4_PITCH, + LIMIT_MAX_PITCH, + LIMIT_MIN_PITCH, + MAX_A4_FREQUENCY, + MIN_A4_FREQUENCY, +) +from sampletones_shared.utils.frequencies import pitch_to_frequency + +ReferenceFrequency = Annotated[float, Field(gt=MIN_A4_FREQUENCY, lt=MAX_A4_FREQUENCY)] +ReferencePitch = Annotated[int, Field(ge=LIMIT_MIN_PITCH, le=LIMIT_MAX_PITCH)] + + +class Tuning(BaseModel, frozen=True, extra="forbid"): + """Where concert pitch sits, which is what fixes the frequency every pitch sounds at. + + Equal temperament measures the whole scale from one reference, so stating that reference + states every pitch. A reconstruction is built against a tuning of its own, and anything + sounding or exporting its pitches reads that tuning to stay in tune with it. + + Attributes: + a4_frequency: Frequency in Hz the reference pitch sounds at. + a4_pitch: The pitch the reference frequency names. + """ + + a4_frequency: ReferenceFrequency = Field( + default=A4_FREQUENCY, + ) + a4_pitch: ReferencePitch = Field( + default=A4_PITCH, + ) + + def frequency(self, pitch: int) -> float: + """The frequency a pitch sounds at under this tuning. + + Args: + pitch: The pitch to sound. + + Returns: + float: The frequency in Hz. + + Raises: + TypeError: If the pitch is not an integer. + ValueError: If the pitch lies outside the range the project covers. + """ + return pitch_to_frequency(pitch, self.a4_frequency, self.a4_pitch) diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py index ae8be79b9..2ed1c5c3f 100644 --- a/tests/integration/nsf/console/session.py +++ b/tests/integration/nsf/console/session.py @@ -30,6 +30,21 @@ def play_calls_covering(song: Song) -> int: return calls + TRAILING_CALLS +def captured_file_trace(data: bytes, song: Song) -> RegisterTrace: + """Runs an exported file on a 6502 and answers with every APU write it made. + + Args: + data: The whole ``.nsf`` file, header included. + song: The song the file plays, which states how far the run reaches. + + Returns: + RegisterTrace: The writes of the initialisation and of every play call in the run. + """ + image = DriverImage.load() + console = Console(data, image.addresses) + return console.trace(play_calls_covering(song)) + + def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: """Exports a song, runs the file on a 6502 and answers with every APU write it made. @@ -40,6 +55,4 @@ def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: Returns: RegisterTrace: The writes of the initialisation and of every play call in the run. """ - image = DriverImage.load() - console = Console(nsf_to_bytes(song, information), image.addresses) - return console.trace(play_calls_covering(song)) + return captured_file_trace(nsf_to_bytes(song, information), song) diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py new file mode 100644 index 000000000..64fce808c --- /dev/null +++ b/tests/integration/nsf/test_backend.py @@ -0,0 +1,170 @@ +from pathlib import Path +from typing import Dict, List + +import numpy as np +import pytest + +from sampletones_core.audio.mixing import mix +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.generators.render import render_channels +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.builder import instructions_from_instruments, song_from_sample +from sampletones_player.export import NSFBackend +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.integration.nsf.console.instructions import instructions_from_trace +from tests.integration.nsf.console.session import captured_file_trace + +ChannelInstructions = Dict[ChannelName, List[InstructionUnion]] + + +def resting(instruction: InstructionUnion) -> InstructionUnion: + """A sounding instruction as it stands, and a rest as the canonical silent one. + + A stream holds a channel's pitch and timbre through a rest so the driver leaves the timer's + high byte alone, so what a rest carries beyond its silence is the channel's own history. + """ + if instruction.on: + return instruction + + silent: InstructionUnion = type(instruction).null_instruction() + return silent + + +def sample_request(sample: Sample) -> SampleExport: + """The request the application hands a backend for one loaded reconstruction. + + A reconstruction reaches an export as the envelopes each of its playing channels carries, + which is the same reading the instruments panel and every tracker format are given. + """ + config = sample.reconstruction.config + features_by_channel = sample.reconstruction.export() + + return SampleExport( + name=sample.name, + instruments=tuple( + InstrumentExport( + name=instrument_slice_name(sample.name, channel), + channel=channel, + features=features, + loop=sample.loop, + nes_frequency=config.nes_frequency, + tuning=config.tuning, + ) + for channel, features in features_by_channel.items() + if features.has_frames + ), + nes_frequency=config.nes_frequency, + tuning=config.tuning, + ) + + +@pytest.fixture(scope="module") +def backend() -> NSFBackend: + return NSFBackend() + + +@pytest.fixture(scope="module") +def requests(instrument_catalog: Dict[str, Sample]) -> Dict[str, SampleExport]: + """The export request each catalog sample reaches a backend as.""" + return {name: sample_request(sample) for name, sample in instrument_catalog.items()} + + +@pytest.fixture(scope="module") +def exported( + backend: NSFBackend, + requests: Dict[str, SampleExport], + tmp_path_factory: pytest.TempPathFactory, +) -> Dict[str, Path]: + """Every catalog sample written to disk through the backend the application registers.""" + directory = tmp_path_factory.mktemp("nsf-backend") + paths: Dict[str, Path] = {} + for name, request in requests.items(): + destination = directory / f"{name}{EXT_FILE_NSF}" + backend.write_sample(destination, request) + paths[name] = destination + + return paths + + +@pytest.fixture(scope="module") +def played( + exported: Dict[str, Path], + requests: Dict[str, SampleExport], + instrument_catalog: Dict[str, Sample], +) -> Dict[str, ChannelInstructions]: + """What the console sounds when it plays each written file, read back out of its register writes.""" + played: Dict[str, ChannelInstructions] = {} + for name, destination in exported.items(): + song = song_from_sample(requests[name]) + trace = captured_file_trace(destination.read_bytes(), song) + played[name] = instructions_from_trace( + trace, + get_timer_table(instrument_catalog[name].reconstruction.config.tuning), + ) + + return played + + +class TestTheBackendWritesAPlayableProgram: + """What reaches disk when the application exports a reconstruction to the console.""" + + def test_every_sample_reaches_a_file_a_player_recognizes(self, exported: Dict[str, Path]) -> None: + for destination in exported.values(): + assert destination.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC + + def test_the_catalog_sounds_all_four_channels(self, played: Dict[str, ChannelInstructions]) -> None: + sounded = { + channel + for instructions in played.values() + for channel, stream in instructions.items() + if any(instruction.on for instruction in stream) + } + assert sounded == set(ChannelName.items()) + + +class TestTheConsoleSoundsTheRequest: + """The envelopes an export request carries, read back off the APU the file drives.""" + + def test_every_slice_sounds_the_instructions_its_envelopes_describe( + self, + played: Dict[str, ChannelInstructions], + requests: Dict[str, SampleExport], + ) -> None: + for name, request in requests.items(): + for channel, instructions in instructions_from_instruments(request.instruments).items(): + sounded = played[name][channel][: len(instructions)] + assert [resting(instruction) for instruction in sounded] == [ + resting(instruction) for instruction in instructions + ] + + def test_a_channel_the_request_leaves_out_rests_throughout( + self, + played: Dict[str, ChannelInstructions], + requests: Dict[str, SampleExport], + ) -> None: + for name, request in requests.items(): + carried = {instrument.channel for instrument in request.instruments} + for channel in set(ChannelName.items()) - carried: + assert not any(instruction.on for instruction in played[name][channel]) + + def test_the_console_sounds_the_reconstructions_own_waveform( + self, + played: Dict[str, ChannelInstructions], + instrument_catalog: Dict[str, Sample], + ) -> None: + """The envelopes state the span a reconstruction is audible over, so the console sounds the + very waveform it was built as there, and what lies past that span is silence on both sides. + """ + for name, sample in instrument_catalog.items(): + rendered = mix(list(render_channels(played[name], sample.reconstruction.config).values())) + approximation = sample.reconstruction.approximation + audible = min(len(rendered), len(approximation)) + + assert np.array_equal(rendered[:audible], approximation[:audible]) + assert not np.any(rendered[audible:]) + assert not np.any(approximation[audible:]) diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py index 098d6c716..13c992ae2 100644 --- a/tests/integration/nsf/test_driver_audio.py +++ b/tests/integration/nsf/test_driver_audio.py @@ -21,7 +21,7 @@ def played_by_console(sample: Sample) -> ChannelInstructions: """The per-tick instructions the console sounds, read back out of the registers it wrote.""" song = song_from_reconstruction(sample.reconstruction, loop_tick=None) trace = captured_trace(song, exported_information(sample.name)) - return instructions_from_trace(trace, get_timer_table(sample.reconstruction.config)) + return instructions_from_trace(trace, get_timer_table(sample.reconstruction.config.tuning)) def resting(instruction: InstructionUnion) -> InstructionUnion: diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 2574101c4..ce2cc81aa 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -12,6 +12,7 @@ from sampletones_core.exporters import Features from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 @@ -28,11 +29,17 @@ def instrument_export(name: str, features: Features) -> InstrumentExport: features=features, loop=False, nes_frequency=NES_FREQUENCY, + tuning=Tuning(), ) def sample_export(name: str, *instruments: InstrumentExport) -> SampleExport: - return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + return SampleExport( + name=name, + instruments=instruments, + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) class TestExportWavIntegration: diff --git a/tests/suite/player.py b/tests/suite/player.py index 0b915800b..effeb2802 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -7,6 +7,8 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.exporters import Features +from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.arithmetic import frequency_to_timer @@ -26,6 +28,7 @@ TRIANGLE_SILENT_RELOAD, TRIANGLE_SOUNDING_RELOAD, ) +from sampletones_shared.music import Tuning from sampletones_shared.utils.frequencies import pitch_to_frequency PLAYER_REFERENCE_TIMER: Final[int] = 0x154 @@ -150,3 +153,59 @@ def player_reconstruction( coefficient=1.0, audio_filepath=Path(os.devnull), ) + + +PLAYER_TUNING: Final[Tuning] = Tuning() + + +def player_features( + frames: int, + pitch: int, + *, + duty_cycle: bool, +) -> Features: + """Envelopes sounding one pitch at full volume for ``frames`` ticks.""" + return Features( + initial_pitch=pitch, + volume=np.full(frames, PLAYER_FULL_VOLUME, dtype=int), + arpeggio=np.zeros(frames, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=np.zeros(frames, dtype=int) if duty_cycle else None, + ) + + +def player_instrument( + name: str, + channel: ChannelName, + features: Features, + *, + nes_frequency: int, + loop: bool, + tuning: Tuning = PLAYER_TUNING, +) -> InstrumentExport: + """One channel slice of an export request.""" + return InstrumentExport( + name=name, + channel=channel, + features=features, + loop=loop, + nes_frequency=nes_frequency, + tuning=tuning, + ) + + +def player_sample( + name: str, + instruments: Sequence[InstrumentExport], + *, + nes_frequency: int, + tuning: Tuning = PLAYER_TUNING, +) -> SampleExport: + """Every channel slice of one reconstruction, as an export request carries them.""" + return SampleExport( + name=name, + instruments=tuple(instruments), + nes_frequency=nes_frequency, + tuning=tuning, + ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index d3ebc2bb5..796650ace 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -24,6 +24,7 @@ from sampletones_core.exports.registry import build_tracker_backends from sampletones_core.instructions import TriangleInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, @@ -33,6 +34,7 @@ from tests.suite.case import BaseRegularTestCase NO_EXTENSION: Final[str] = "" +RETUNED_A4_FREQUENCY: Final[float] = 432.0 @dataclass(frozen=True) @@ -113,6 +115,20 @@ def loaded_data( ) +@pytest.fixture +def retuned_data( + reconstruction_factory: Callable[[], Reconstruction], +) -> ReconstructionData: + """A reconstruction built against a concert pitch other than the standard one.""" + reconstruction = reconstruction_factory() + library = reconstruction.config.library.model_copy(update={"a4_frequency": RETUNED_A4_FREQUENCY}) + config = reconstruction.config.model_copy(update={"library": library}) + return ReconstructionData.from_reconstruction( + reconstruction.model_copy(update={"config": config}), + name="Sample", + ) + + @pytest.fixture def data_with_original_audio( reconstruction_factory: Callable[[], Reconstruction], @@ -652,6 +668,25 @@ def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_na backend = mock_export_service.export_instrument.call_args.args[1] assert backend is mock_export_backends[case.export_format] + def test_handle_export_instrument_confirmed_carries_the_reconstructions_tuning( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + retuned_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + """A backend sounding the export itself measures its pitches from the tuning the + reconstruction was built with, so the request states that tuning rather than the standard. + """ + mock_reconstruction_manager.current_reconstruction = retuned_data + panel_logic.handle_export_instrument_confirmed( + tmp_path / "instrument.fti", + ChannelName.PULSE1, + ) + request = mock_export_service.export_instrument.call_args.args[2] + assert request.tuning == Tuning(a4_frequency=RETUNED_A4_FREQUENCY) + @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_writes( self, @@ -764,6 +799,24 @@ def test_handle_export_instruments_confirmed_names_the_batch_after_the_destinati assert request.name == "Clap" assert [instrument.name for instrument in request.instruments] == ["Clap (pulse1)"] + def test_handle_export_instruments_confirmed_carries_the_reconstructions_tuning( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + retuned_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = retuned_data + panel_logic.handle_export_instruments_confirmed( + tmp_path / "Clap.fti", + ExportFormat.FAMITRACKER, + ) + request = mock_export_service.export_sample.call_args.args[2] + retuned = Tuning(a4_frequency=RETUNED_A4_FREQUENCY) + assert request.tuning == retuned + assert [instrument.tuning for instrument in request.instruments] == [retuned] + @pytest.mark.parametrize( "case", INSTRUMENT_FORMAT_CASES, diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index bc66e4d8d..2b49f81a9 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -21,6 +21,7 @@ ) from sampletones_core.exports.scope import ExportScope from sampletones_core.project.project import Project +from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 @@ -100,6 +101,7 @@ def build_instrument(name: str = "Lead") -> InstrumentExport: ), loop=False, nes_frequency=NES_FREQUENCY, + tuning=Tuning(), ) @@ -108,6 +110,7 @@ def build_sample(count: int = 2) -> SampleExport: name="Kick", instruments=tuple(build_instrument(f"Kick {index}") for index in range(count)), nes_frequency=NES_FREQUENCY, + tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/configs/test_library.py b/tests/unit/sampletones_core/configs/test_library.py new file mode 100644 index 000000000..cff94d3c3 --- /dev/null +++ b/tests/unit/sampletones_core/configs/test_library.py @@ -0,0 +1,39 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.configs import Config +from sampletones_core.configs.library import InstructionsLibraryConfig +from sampletones_shared.constants.music import ( + LIMIT_MAX_PITCH, + LIMIT_MIN_PITCH, + MAX_A4_FREQUENCY, + MIN_A4_FREQUENCY, +) +from sampletones_shared.music import Tuning + +RETUNED_A4_FREQUENCY: Final[float] = 432.0 + + +class TestTuning: + def test_the_tuning_states_the_configured_reference(self) -> None: + library = InstructionsLibraryConfig(a4_frequency=RETUNED_A4_FREQUENCY, a4_pitch=57) + assert library.tuning == Tuning(a4_frequency=RETUNED_A4_FREQUENCY, a4_pitch=57) + + def test_the_configuration_reads_its_librarys_tuning(self) -> None: + config = Config(library=InstructionsLibraryConfig(a4_frequency=RETUNED_A4_FREQUENCY)) + assert config.tuning == config.library.tuning + assert config.tuning.a4_frequency == RETUNED_A4_FREQUENCY + + +class TestReference: + @pytest.mark.parametrize("a4_frequency", [MIN_A4_FREQUENCY, MAX_A4_FREQUENCY]) + def test_a_reference_frequency_outside_the_tuning_band_raises(self, a4_frequency: float) -> None: + with pytest.raises(ValidationError): + InstructionsLibraryConfig(a4_frequency=a4_frequency) + + @pytest.mark.parametrize("a4_pitch", [LIMIT_MIN_PITCH - 1, LIMIT_MAX_PITCH + 1]) + def test_a_reference_pitch_beyond_the_projects_range_raises(self, a4_pitch: int) -> None: + with pytest.raises(ValidationError): + InstructionsLibraryConfig(a4_pitch=a4_pitch) diff --git a/tests/unit/sampletones_core/exports/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py index e844ca877..8a07af857 100644 --- a/tests/unit/sampletones_core/exports/test_bitphase.py +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -22,6 +22,7 @@ from sampletones_core.exports.scope import ExportScope from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON NES_FREQUENCY: Final[int] = 60 @@ -53,11 +54,17 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: features=build_features(frames), loop=False, nes_frequency=NES_FREQUENCY, + tuning=Tuning(), ) def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: - return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + return SampleExport( + name=name, + instruments=instruments, + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) def read_document(destination: Path) -> Dict[str, Any]: diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index bd181e948..bf05fb85e 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -14,6 +14,7 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) +from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE NES_FREQUENCY: Final[int] = 60 @@ -38,11 +39,17 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: features=build_features(frames), loop=False, nes_frequency=NES_FREQUENCY, + tuning=Tuning(), ) def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: - return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + return SampleExport( + name=name, + instruments=instruments, + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) @pytest.fixture(name="backend") diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index e1fa3c85c..314d7fc4a 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -5,6 +5,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 @@ -42,8 +43,14 @@ def build_instrument( features=features, loop=loop, nes_frequency=NES_FREQUENCY, + tuning=Tuning(), ) def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: - return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) + return SampleExport( + name=name, + instruments=instruments, + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) diff --git a/tests/unit/sampletones_core/timers/test_arithmetic.py b/tests/unit/sampletones_core/timers/test_arithmetic.py index 5cf637471..40b8bf4bc 100644 --- a/tests/unit/sampletones_core/timers/test_arithmetic.py +++ b/tests/unit/sampletones_core/timers/test_arithmetic.py @@ -10,6 +10,8 @@ timer_to_frequency, ) from sampletones_core.timers.utils import get_frequency_table, get_timer_table +from sampletones_shared.constants.music import A4_PITCH, LIMIT_MAX_PITCH, LIMIT_MIN_PITCH +from sampletones_shared.music import Tuning from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -85,14 +87,19 @@ def test_frequency_maps_back_to_its_timer(self, timer: int) -> None: class TestGetTimerTable: - def test_covers_the_same_pitches_as_the_frequency_table(self) -> None: - config = Config() - assert get_timer_table(config).keys() == get_frequency_table(config).keys() + def test_covers_every_pitch_the_project_sounds(self) -> None: + timers = get_timer_table(Tuning()) + assert set(timers) == set(range(LIMIT_MIN_PITCH, LIMIT_MAX_PITCH + 1)) def test_every_timer_sounds_its_pitch_frequency(self) -> None: config = Config() frequencies = get_frequency_table(config) - timers = get_timer_table(config) + timers = get_timer_table(config.tuning) for pitch, frequency in frequencies.items(): assert timer_to_frequency(timers[pitch]) == frequency + + def test_a_retuned_table_follows_concert_pitch(self) -> None: + standard = get_timer_table(Tuning()) + retuned = get_timer_table(Tuning(a4_frequency=432.0)) + assert retuned[A4_PITCH] > standard[A4_PITCH] diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index cb44106b2..bb3dd702a 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -3,6 +3,7 @@ import pytest from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.request import InstrumentExport from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -11,8 +12,12 @@ ) from sampletones_core.timers.utils import get_timer_table from sampletones_player.builder import ( + SONG_START, channel_instructions, + instructions_from_instruments, + loop_tick_from_instruments, song_from_reconstruction, + song_from_sample, streams_from_instructions, ) from sampletones_player.clock.schedule import PlaySchedule @@ -20,11 +25,15 @@ TRIANGLE_COUNTER_CONTROL, TRIANGLE_SOUNDING_RELOAD, ) +from sampletones_shared.music import Tuning from tests.suite.player import ( PLAYER_FULL_VOLUME, PLAYER_REFERENCE_PITCH, PLAYER_TIMER_TABLE, + player_features, + player_instrument, player_reconstruction, + player_sample, silent_pulse, sounding_pulse, ) @@ -35,6 +44,7 @@ BASS_PITCH: Final[int] = 45 NOISE_PERIOD: Final[int] = 10 NOISE_VOLUME: Final[int] = 8 +RETUNED_A4_FREQUENCY: Final[float] = 432.0 def melody() -> List[InstructionUnion]: @@ -45,6 +55,26 @@ def one_channel(generator: ChannelName) -> Dict[ChannelName, List[InstructionUni return {generator: melody()} +def lead(*, loop: bool) -> InstrumentExport: + return player_instrument( + "lead", + ChannelName.PULSE1, + player_features(SOUNDING_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=loop, + ) + + +def bass(*, loop: bool) -> InstrumentExport: + return player_instrument( + "bass", + ChannelName.TRIANGLE, + player_features(SOUNDING_TICKS, BASS_PITCH, duty_cycle=False), + nes_frequency=NTSC_FREQUENCY, + loop=loop, + ) + + class TestChannelInstructions: """A channel's stream read as the instruction type its encoder takes.""" @@ -122,10 +152,93 @@ def test_a_loop_beyond_the_songs_ticks_raises(self) -> None: def test_the_timers_come_from_the_reconstructions_own_configuration(self) -> None: reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) song = song_from_reconstruction(reconstruction, loop_tick=None) - timer = get_timer_table(reconstruction.config)[PLAYER_REFERENCE_PITCH] + timer = get_timer_table(reconstruction.config.tuning)[PLAYER_REFERENCE_PITCH] + assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + + def test_a_retuned_reconstruction_plays_retuned_timers(self) -> None: + """The console reaches a pitch through a timer, so a reconstruction built against another + concert pitch plays the divider that concert pitch names. + """ + reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) + library = reconstruction.config.library.model_copy(update={"a4_frequency": RETUNED_A4_FREQUENCY}) + retuned = reconstruction.model_copy( + update={"config": reconstruction.config.model_copy(update={"library": library})} + ) + timer = get_timer_table(retuned.config.tuning)[PLAYER_REFERENCE_PITCH] + song = song_from_reconstruction(retuned, loop_tick=None) + + assert timer > PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) def test_a_reconstruction_describing_no_frame_plays_one_resting_tick(self) -> None: reconstruction = player_reconstruction({ChannelName.PULSE1: [silent_pulse()]}, NTSC_FREQUENCY) song = song_from_reconstruction(reconstruction, loop_tick=None) assert song.ticks == 1 + + +class TestInstructionsFromInstruments: + """An export request's channel slices read back as the instructions the console sounds.""" + + def test_a_slice_reaches_its_own_channel(self) -> None: + instructions = instructions_from_instruments((lead(loop=False), bass(loop=False))) + assert set(instructions) == {ChannelName.PULSE1, ChannelName.TRIANGLE} + + def test_a_slice_reads_back_as_the_instruction_its_channel_sounds(self) -> None: + instructions = instructions_from_instruments((lead(loop=False), bass(loop=False))) + assert all(isinstance(item, PulseInstruction) for item in instructions[ChannelName.PULSE1]) + assert all(isinstance(item, TriangleInstruction) for item in instructions[ChannelName.TRIANGLE]) + + def test_a_slice_carries_a_frame_per_envelope_item(self) -> None: + instructions = instructions_from_instruments((lead(loop=False),)) + assert len(instructions[ChannelName.PULSE1]) == SOUNDING_TICKS + + def test_two_slices_naming_one_channel_raise(self) -> None: + with pytest.raises(ValueError): + instructions_from_instruments((lead(loop=False), lead(loop=False))) + + +class TestLoopTickFromInstruments: + """Where a request's song returns to once it ends.""" + + def test_slices_that_all_repeat_return_to_the_songs_start(self) -> None: + assert loop_tick_from_instruments((lead(loop=True), bass(loop=True))) == SONG_START + + def test_a_slice_playing_once_ends_the_song(self) -> None: + assert loop_tick_from_instruments((lead(loop=True), bass(loop=False))) is None + + def test_a_request_carrying_no_slice_ends_the_song(self) -> None: + assert loop_tick_from_instruments(()) is None + + +class TestSongFromSample: + """An export request read as the song the console plays it as.""" + + def test_every_slice_sounds_on_the_channel_it_was_reconstructed_for(self) -> None: + song = song_from_sample( + player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY) + ) + assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 + assert song.streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD + assert len(song.streams.pulse2) == 1 + + def test_the_schedule_follows_the_rate_the_request_states(self) -> None: + song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=HALF_RATE_FREQUENCY)) + assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) + + def test_a_request_whose_slices_repeat_loops(self) -> None: + song = song_from_sample(player_sample("demo", (lead(loop=True),), nes_frequency=NTSC_FREQUENCY)) + assert song.loop_tick == SONG_START + + def test_the_timers_come_from_the_tuning_the_request_carries(self) -> None: + song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY)) + timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] + assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + + def test_a_retuned_request_plays_retuned_timers(self) -> None: + """The console reaches a pitch through a timer, so a request built against another concert + pitch plays the divider that concert pitch names. + """ + tuning = Tuning(a4_frequency=RETUNED_A4_FREQUENCY) + song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY, tuning=tuning)) + timer = get_timer_table(tuning)[PLAYER_REFERENCE_PITCH] + assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py new file mode 100644 index 000000000..69811b2b2 --- /dev/null +++ b/tests/unit/sampletones_player/test_export.py @@ -0,0 +1,174 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentExport, ProjectExport +from sampletones_core.exports.scope import ExportScope +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_player.export import NSFBackend +from sampletones_player.specification.nsf import ( + ARTIST_OFFSET, + HEADER_SIZE, + PROGRAM_SIZE, + STRING_FIELD_SIZE, + TITLE_OFFSET, +) +from sampletones_shared.exceptions import SongTooLargeError +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.player import ( + PLAYER_REFERENCE_PITCH, + player_features, + player_instrument, + player_sample, +) + +NTSC_FREQUENCY: Final[int] = 60 +SOUNDING_TICKS: Final[int] = 8 +BASS_PITCH: Final[int] = 45 +OVERLONG_TICKS: Final[int] = PROGRAM_SIZE +FILENAME: Final[str] = "reconstruction.nsf" +SAMPLE_NAME: Final[str] = "Amen" +PROJECT_TITLE: Final[str] = "Demo" + + +def lead_slice(name: str, frames: int) -> InstrumentExport: + return player_instrument( + name, + ChannelName.PULSE1, + player_features(frames, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ) + + +def bass_slice(name: str, frames: int) -> InstrumentExport: + return player_instrument( + name, + ChannelName.TRIANGLE, + player_features(frames, BASS_PITCH, duty_cycle=False), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ) + + +def read_field(data: bytes, offset: int) -> str: + return data[offset : offset + STRING_FIELD_SIZE].rstrip(b"\x00").decode() + + +@pytest.fixture(name="backend") +def backend_fixture() -> NSFBackend: + return NSFBackend() + + +class TestSeam: + """What the backend answers the export seam with.""" + + def test_the_backend_writes_the_nsf_format(self, backend: NSFBackend) -> None: + assert backend.export_format == ExportFormat.NSF + + def test_a_program_plays_an_instrument_and_a_reconstruction(self, backend: NSFBackend) -> None: + assert backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + + @pytest.mark.parametrize("scope", [ExportScope.INSTRUMENT, ExportScope.SAMPLE]) + def test_every_scope_the_backend_writes_carries_the_nsf_extension( + self, + backend: NSFBackend, + scope: ExportScope, + ) -> None: + assert backend.extension(scope) == EXT_FILE_NSF + + def test_the_backend_stands_where_the_seam_expects_one(self, backend: NSFBackend) -> None: + export_backend: ExportBackend = backend + assert export_backend.export_format == ExportFormat.NSF + + +class TestWriteSample: + """A reconstruction written as one program playing every slice together.""" + + def test_the_run_writes_the_destination_alone(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + request = player_sample( + SAMPLE_NAME, + (lead_slice("lead", SOUNDING_TICKS), bass_slice("bass", SOUNDING_TICKS)), + nes_frequency=NTSC_FREQUENCY, + ) + artifact = backend.write_sample(destination, request) + assert artifact.paths == (destination,) + + def test_the_program_carries_its_driver(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + backend.write_sample(destination, request) + assert len(destination.read_bytes()) > HEADER_SIZE + + def test_the_reconstructions_name_lists_the_program(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + backend.write_sample(destination, request) + assert read_field(destination.read_bytes(), TITLE_OFFSET) == SAMPLE_NAME + + def test_an_export_is_credited_to_nobody(self, backend: NSFBackend, tmp_path: Path) -> None: + """A reconstruction names no artist, so the field reaches the file empty and a player + listing the file leaves the line blank. + """ + destination = tmp_path / FILENAME + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + backend.write_sample(destination, request) + assert read_field(destination.read_bytes(), ARTIST_OFFSET) == "" + + def test_the_envelopes_cross_over_whole(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + assert backend.write_sample(destination, request).truncation is None + + def test_a_destination_reaches_a_directory_the_run_creates(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / "exports" / FILENAME + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + backend.write_sample(destination, request) + assert destination.is_file() + + def test_a_reconstruction_outgrowing_the_program_area_reports_its_size( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + request = player_sample(SAMPLE_NAME, (lead_slice("lead", OVERLONG_TICKS),), nes_frequency=NTSC_FREQUENCY) + with pytest.raises(SongTooLargeError): + backend.write_sample(destination, request) + + +class TestWriteInstrument: + """One channel slice written as a program sounding it alone.""" + + def test_the_slice_is_listed_under_its_own_name(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + backend.write_instrument(destination, lead_slice("lead", SOUNDING_TICKS)) + assert read_field(destination.read_bytes(), TITLE_OFFSET) == "lead" + + def test_a_slice_plays_the_program_its_reconstruction_would(self, backend: NSFBackend, tmp_path: Path) -> None: + """A slice sounds on its own channel and the other three rest, which is the reconstruction + it belongs to with every other slice standing by. + """ + instrument = lead_slice(SAMPLE_NAME, SOUNDING_TICKS) + alone = tmp_path / "alone.nsf" + together = tmp_path / "together.nsf" + + backend.write_instrument(alone, instrument) + backend.write_sample(together, player_sample(SAMPLE_NAME, (instrument,), nes_frequency=NTSC_FREQUENCY)) + + assert alone.read_bytes() == together.read_bytes() + + +class TestWriteProject: + """What a whole composition meets at the console's door.""" + + def test_a_project_reaches_the_console_later(self, backend: NSFBackend, tmp_path: Path) -> None: + project = Project.create(title=PROJECT_TITLE, settings=ProjectSettings()) + with pytest.raises(NotImplementedError): + backend.write_project(tmp_path / FILENAME, ProjectExport(project=project)) diff --git a/tests/unit/sampletones_shared/test_music.py b/tests/unit/sampletones_shared/test_music.py new file mode 100644 index 000000000..535aa5b5a --- /dev/null +++ b/tests/unit/sampletones_shared/test_music.py @@ -0,0 +1,58 @@ +import pytest +from pydantic import ValidationError + +from sampletones_shared.constants.music import ( + A4_FREQUENCY, + A4_PITCH, + LIMIT_MAX_PITCH, + LIMIT_MIN_PITCH, + MAX_A4_FREQUENCY, + MIN_A4_FREQUENCY, + OCTAVE_SEMITONES, +) +from sampletones_shared.music import Tuning + + +class TestDefaults: + def test_the_default_is_standard_concert_pitch(self) -> None: + tuning = Tuning() + assert (tuning.a4_frequency, tuning.a4_pitch) == (A4_FREQUENCY, A4_PITCH) + + +class TestFrequency: + def test_the_reference_pitch_sounds_the_reference_frequency(self) -> None: + assert Tuning().frequency(A4_PITCH) == A4_FREQUENCY + + def test_an_octave_below_halves_the_frequency(self) -> None: + assert Tuning().frequency(A4_PITCH - OCTAVE_SEMITONES) == A4_FREQUENCY / 2 + + def test_a_retuned_reference_carries_the_whole_scale(self) -> None: + tuning = Tuning(a4_frequency=432.0) + assert tuning.frequency(A4_PITCH) == 432.0 + assert tuning.frequency(A4_PITCH - OCTAVE_SEMITONES) == 216.0 + + def test_moving_the_reference_pitch_moves_every_frequency(self) -> None: + tuning = Tuning(a4_pitch=A4_PITCH - OCTAVE_SEMITONES) + assert tuning.frequency(A4_PITCH - OCTAVE_SEMITONES) == A4_FREQUENCY + assert tuning.frequency(A4_PITCH) == A4_FREQUENCY * 2 + + @pytest.mark.parametrize("pitch", [LIMIT_MIN_PITCH - 1, LIMIT_MAX_PITCH + 1]) + def test_a_pitch_beyond_the_projects_range_raises(self, pitch: int) -> None: + with pytest.raises(ValueError): + Tuning().frequency(pitch) + + +class TestValidation: + @pytest.mark.parametrize("a4_frequency", [0.0, -440.0, MIN_A4_FREQUENCY, MAX_A4_FREQUENCY, 880.0]) + def test_a_reference_frequency_outside_the_tuning_band_raises(self, a4_frequency: float) -> None: + with pytest.raises(ValidationError): + Tuning(a4_frequency=a4_frequency) + + @pytest.mark.parametrize("a4_pitch", [LIMIT_MIN_PITCH - 1, LIMIT_MAX_PITCH + 1]) + def test_a_reference_pitch_beyond_the_projects_range_raises(self, a4_pitch: int) -> None: + with pytest.raises(ValidationError): + Tuning(a4_pitch=a4_pitch) + + def test_the_tuning_holds_still(self) -> None: + with pytest.raises(ValidationError): + Tuning().a4_frequency = 432.0 From 1cd21fec8ee246bc6fbd770f37e9f06d7842d6fe Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 00:46:48 +0200 Subject: [PATCH 027/142] Added: stems application card --- docs/concepts/stems.md | 49 ++- .../categories/elements/reconstructions.py | 5 + .../coordinators/tabs/reconstruction.py | 15 +- .../logic/reconstruction/data.py | 164 +++++++-- .../logic/reconstruction/reconstruction.py | 137 +++++++- src/sampletones_application/tags/general.py | 1 + .../tags/reconstructions.py | 25 ++ .../ui/panels/reconstruction/stems.py | 182 ++++++++++ .../utils/gui/dialogs/__init__.py | 6 + .../gui/{dialogs.py => dialogs/renderer.py} | 320 +++--------------- .../utils/gui/dialogs/windows/__init__.py | 0 .../utils/gui/dialogs/windows/confirmation.py | 173 ++++++++++ .../utils/gui/dialogs/windows/error.py | 139 ++++++++ .../gui/dialogs/windows/save_confirmation.py | 143 ++++++++ .../view_model/reconstruction/stems.py | 34 ++ .../view_model/shared/waveform_data.py | 12 +- src/sampletones_config/lang/en.yaml | 5 + .../reconstruction/stems/filter.py | 31 ++ .../reconstructor/reconstructor.py | 127 +++++-- tests/integration/assets/reconstruction.py | 73 +++- .../test_stems_reconstruction.py | 145 +++++++- .../services/conftest.py | 2 +- .../logic/reconstruction/test_data.py | 120 ++++++- .../reconstruction/test_reconstruction.py | 101 ++++++ .../panels/reconstruction/test_stems_panel.py | 183 ++++++++++ .../utils/gui/dialogs/windows/__init__.py | 0 .../utils/gui/dialogs/windows/conftest.py | 38 +++ .../gui/dialogs/windows/test_confirmation.py | 105 ++++++ .../utils/gui/dialogs/windows/test_error.py | 73 ++++ .../dialogs/windows/test_save_confirmation.py | 96 ++++++ .../reconstruction/test_reconstruction.py | 31 ++ .../view_model/shared/test_waveform_data.py | 30 ++ .../reconstruction/test_stems_filter.py | 107 ++++++ 33 files changed, 2330 insertions(+), 342 deletions(-) create mode 100644 src/sampletones_application/ui/panels/reconstruction/stems.py create mode 100644 src/sampletones_application/utils/gui/dialogs/__init__.py rename src/sampletones_application/utils/gui/{dialogs.py => dialogs/renderer.py} (63%) create mode 100644 src/sampletones_application/utils/gui/dialogs/windows/__init__.py create mode 100644 src/sampletones_application/utils/gui/dialogs/windows/confirmation.py create mode 100644 src/sampletones_application/utils/gui/dialogs/windows/error.py create mode 100644 src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py create mode 100644 src/sampletones_application/view_model/reconstruction/stems.py create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/filter.py create mode 100644 tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py create mode 100644 tests/unit/sampletones_application/utils/gui/dialogs/windows/__init__.py create mode 100644 tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py create mode 100644 tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py create mode 100644 tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py create mode 100644 tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index bfd6e11cc..97d40b4ef 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -3,9 +3,9 @@ This document explains how one reconstruction is assigned across several stems. Consult it when changing the stems assignment algorithm, its configuration, the per-stem record a reconstruction carries, or the way the application loads, -names, and reveals the recorded stems. The single-sample pipeline this builds on -is described in [Reconstruction](reconstruction.md), and the stored record in -[Reconstructions](../formats/reconstructions.md). +names, reveals, and plays the recorded stems. The single-sample pipeline this +builds on is described in [Reconstruction](reconstruction.md), and the stored +record in [Reconstructions](../formats/reconstructions.md). A stems reconstruction converts several audio stems at once. The stems are mixed and the mix is matched against the instruction library; within each frame, @@ -104,3 +104,46 @@ recorded path according to the capability matrix in [Desktop capabilities](../development/desktop-capabilities.md): one file-manager window with every stem selected where the file manager supports it, one window per directory otherwise. + +## The stems card + +The reconstruction tab's Stems card turns the recorded assignment into a +listener the user can steer. Each row carries one stem: a checkbox, the recorded +file name, and the channels the stem holds, in entry order. A setup line above +the rows names the assignment's hierarchy mode and channel cap. Checking a stem +admits its frames to everything the tab plays and exports; unchecking silences +them. + +### Principles + +1. **Selection filters what plays.** A checked set projects the document rather + than mutating it: the waveform shows the checked stems' frames alone, the + reconstruction toggle plays their frames mixed, original playback plays + their recordings mixed, and WAV export writes the same filtered projection. + Each answer derives from the recorded per-channel assignment, so a stem that + holds a frame owns its samples everywhere at once. +2. **Every stem starts checked.** A freshly opened stems reconstruction selects + every recorded stem, which answers the full waveform and the full original — + the unfiltered document. +3. **The selection follows the open document.** The card lives with the + reconstruction it describes: opening a document seeds the rows and the + checked set, a regenerated reconstruction keeps the checked stems and admits + the newly recorded ones, and closing the document empties the card. +4. **Listening choices stay out of the document.** The checked set is session + state, like every choice that shapes what is heard — see + [Playback](../development/playback.md). Saving the reconstruction records + the assignment, never the selection. + +### Mechanics + +`ReconstructionData.partials_for` and `ReconstructionData.waveform_data` take +the checked ids and zero the unselected stems' frames per channel before +mixing — `filter_approximations` in +`sampletones_core.reconstructions.reconstruction.stems` — keeping every array +at its unfiltered length, so a filtered mix aligns with the unfiltered one +sample for sample. `ReconstructionPanelLogic` holds the checked set and +re-answers the stems view model, the waveform, and the audio data whenever it +changes; the coordinator wires the card's `on_stems_changed` hook to that +handler. A reconstruction that records one source presents a single implicit +stem for its recording, and one that records no source shows the card's empty +state. diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index ffc5fe051..1a06f0e63 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -36,6 +36,11 @@ class ReconstructionPanelElements(AbstractElement): LOCATE_AUDIO_FAILED = "locate_audio_failed" EXPORT_WAV_SUCCESS = "export_wav_success" EXPORT_WAV_FAILED = "export_wav_failed" + STEMS = "stems" + STEMS_EMPTY = "stems_empty" + STEMS_MODE_ROUND_ROBIN = "stems_mode_round_robin" + STEMS_MODE_STRICT = "stems_mode_strict" + STEMS_SETUP = "stems_setup" class ReconstructionsInstrumentsElements(AbstractElement): diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 8fa8243a3..9b2c57ebb 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -52,6 +52,7 @@ TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS, ) from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns from sampletones_application.ui.elements.layout.responsive import expanded_side_width @@ -68,6 +69,9 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) +from sampletones_application.ui.panels.reconstruction.stems import ( + GUIReconstructionStemsPanel, +) from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path @@ -196,8 +200,13 @@ def __init__( language_manager=language_manager, status_bar=status_bar, ) + self._reconstruction_stems_panel: GUIReconstructionStemsPanel = GUIReconstructionStemsPanel( + language_manager=language_manager, + initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS), + ) self._reconstruction_audio_panel.set_collapse_handler(self._on_card_collapse_changed) self._reconstruction_plot_panel.set_collapse_handler(self._on_card_collapse_changed) + self._reconstruction_stems_panel.set_collapse_handler(self._on_card_collapse_changed) self._reconstruction_player_logic.on_position_changed = self._reconstruction_plot_panel.set_playback_position self._reconstruction_panel_logic: ReconstructionPanelLogic = ReconstructionPanelLogic( session_manager, @@ -227,9 +236,11 @@ def __init__( self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels + self._reconstruction_stems_panel.on_stems_changed = self._reconstruction_panel_logic.set_selected_stems self._browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view + self._reconstruction_panel_logic.on_stems_view_changed = self._reconstruction_stems_panel.update_view self._reconstruction_panel_logic.on_audio_data_changed = self._on_audio_data_changed self._reconstruction_panel_logic.on_waveform_load_changed = self._reconstruction_plot_panel.load_waveform_data self._reconstruction_panel_logic.on_waveform_update_changed = ( @@ -475,10 +486,12 @@ def create_tab(self) -> None: self._sync_instruments_width() def _build_reconstruction_column(self, parent: str) -> None: - """Stacks the audio and plot cards down the centre column.""" + """Stacks the audio, plot, and stems cards down the centre column.""" self._reconstruction_audio_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._reconstruction_plot_panel.create_panel(parent) + dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) + self._reconstruction_stems_panel.create_panel(parent) def _on_card_collapse_changed( self, diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 09ae31663..bd9ab7f8e 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, replace +from functools import cached_property from pathlib import Path -from typing import List, Optional, Self +from typing import AbstractSet, Dict, List, Optional, Self, Tuple import numpy as np @@ -11,6 +12,10 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.naming.derive import derive_name +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.filter import ( + filter_approximations, +) from sampletones_shared.logger import logger @@ -19,7 +24,7 @@ class ReconstructionData: name: str config: Config reconstruction: Reconstruction - original_audio: Optional[np.ndarray] + stem_audios: Tuple[np.ndarray, ...] feature_data: FeatureData filepath: Optional[Path] @@ -53,8 +58,8 @@ def detached_copy(self, filepath: Path) -> Self: Save As writes the reconstruction to its own file and adopts this copy as the open document. The copy owns a fresh reconstruction object, so a document that was a project sample becomes a standalone entity: later edits reach only the saved file, leaving the - project's sample unchanged. The already-loaded original audio is reused, since the copy - shares the same source. + project's sample unchanged. The copy shares the loaded recordings, so the original + audio carries over without a reload. """ reconstruction = self.reconstruction.model_copy(deep=True) return replace( @@ -88,13 +93,13 @@ def _assemble( filepath: Optional[Path], name: str, ) -> Self: - original_audio = cls._load_original_audio(reconstruction) + stem_audios = cls._load_stem_audios(reconstruction) feature_data = FeatureData.load(reconstruction) return cls( config=reconstruction.config, reconstruction=reconstruction, - original_audio=original_audio, + stem_audios=stem_audios, feature_data=feature_data, filepath=filepath, name=name, @@ -115,20 +120,20 @@ def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str: return filepath.stem @staticmethod - def _load_original_audio( + def _load_stem_audios( reconstruction: Reconstruction, - ) -> Optional[np.ndarray]: - """Loads the source audio, yielding ``None`` when no usable original exists. - - A reconstruction detached from its origin (a project sample) records no source path, and a - file-backed reconstruction may point at audio absent or unreadable on this machine. Several - recorded paths (stems) mix into one recording, so one unreadable stem costs the whole - original. Every such case yields ``None``; the approximation then stands on its own in - playback and the display. + ) -> Tuple[np.ndarray, ...]: + """Loads the recorded source, one recording per path, in path order. + + A reconstruction detached from its origin (a project sample) records no source + path, and a file-backed reconstruction may point at audio absent or unreadable on + this machine. One unreadable stem costs the whole original, so the recordings + come back as one empty tuple in either case; the approximation then stands on its + own in playback and the display. """ source_paths = reconstruction.source_paths if not source_paths: - return None + return () config = reconstruction.config recordings: List[np.ndarray] = [] @@ -144,19 +149,134 @@ def _load_original_audio( ) except (FileNotFoundError, IsADirectoryError, PermissionError, OSError): logger.warning(f"Could not load original audio from '{path}'. The original is unavailable") - return None + return () + + return tuple(recordings) + + @cached_property + def original_audio(self) -> Optional[np.ndarray]: + """The recorded source mixed into one waveform, ``None`` while no source loads.""" + return mix(list(self.stem_audios)) if self.stem_audios else None + + @cached_property + def _stem_recording_indexes(self) -> Dict[int, int]: + """Maps each stem id to the index of its recording in ``stem_audios``. + + A stems reconstruction maps the entries' ids to their recordings in entry order, a + single source presents one implicit stem (id 0) holding its recording, and source + audio absent or unreadable maps nothing. + """ + if not self.stem_audios: + return {} + + stems_data = self.reconstruction.stems_data + if stems_data is not None: + return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} + + return {0: 0} + + def original_mix_for(self, selected_stem_ids: AbstractSet[int]) -> np.ndarray: + """The original audio of the selected stems, silence once none are selected.""" + indexes = self._stem_recording_indexes + recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids] + if not recordings: + return np.zeros_like(self.reconstruction.approximation) return mix(recordings) - def waveform_data(self) -> WaveformData: - """Projects the slice of this data the waveform display renders.""" + def waveform_data( + self, + selected_stem_ids: Optional[AbstractSet[int]] = None, + ) -> WaveformData: + """Projects the slice of this data the waveform display renders. + + With a stems selection, the projection carries the selected stems' frames alone + and their original mix, so the waveform answers exactly what plays. + """ + if selected_stem_ids is None: + return self._unfiltered_waveform() + + stems_data = self.reconstruction.stems_data + if stems_data is None: + return self._single_source_waveform(selected_stem_ids) + + return self._filtered_waveform(selected_stem_ids, stems_data) + + def _unfiltered_waveform(self) -> WaveformData: + """The whole document: every channel's stored approximation and the full original.""" + return self._waveform_data( + self.original_audio, + dict(self.reconstruction.approximations), + self.reconstruction.approximation, + ) + + def _single_source_waveform(self, selected_stem_ids: AbstractSet[int]) -> WaveformData: + """The projection of a reconstruction that records no stems assignment. + + A recorded source with its one implicit stem unselected projects silence; every other + selection projects the whole document. + """ + if self.reconstruction.source_paths and not selected_stem_ids: + return self._silenced_waveform() + + return self._unfiltered_waveform() + + def _filtered_waveform( + self, + selected_stem_ids: AbstractSet[int], + stems_data: StemsData, + ) -> WaveformData: + """The selected stems' frames and their original mix, in the unfiltered shape.""" + approximations = filter_approximations( + stems_data, + self.reconstruction.approximations, + selected_stem_ids, + self.reconstruction.config.frame_length, + ) + return self._waveform_data( + self.original_mix_for(selected_stem_ids), + approximations, + mix(list(approximations.values())), + ) + + def _waveform_data( + self, + original_audio: Optional[np.ndarray], + approximations: Dict[ChannelName, np.ndarray], + approximation: np.ndarray, + ) -> WaveformData: + return WaveformData( + original_audio=original_audio, + approximation=approximation, + approximations=approximations, + coefficient=self.reconstruction.coefficient, + frame_length=self.reconstruction.config.frame_length, + ) + + def _silenced_waveform(self) -> WaveformData: + """A projection of silence in the shape of the reconstruction.""" + approximation = self.reconstruction.approximation return WaveformData( - original_audio=self.original_audio, - approximation=self.reconstruction.approximation, - approximations=dict(self.reconstruction.approximations), + original_audio=np.zeros_like(approximation), + approximation=np.zeros_like(approximation), + approximations={ + channel: np.zeros_like(audio) for channel, audio in self.reconstruction.approximations.items() + }, coefficient=self.reconstruction.coefficient, frame_length=self.reconstruction.config.frame_length, ) def get_partials(self, channel_names: List[ChannelName]) -> np.ndarray: return self.waveform_data().partials(channel_names) + + def partials_for( + self, + channel_names: List[ChannelName], + selected_stem_ids: AbstractSet[int], + ) -> np.ndarray: + """Sums the selected channels with the unselected stems' frames silenced. + + A single source with its one stem unselected is silence, and a reconstruction + recording no source answers its full approximation. + """ + return self.waveform_data(selected_stem_ids).partials(channel_names) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 890c38b6f..b80efc6bf 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -15,6 +15,10 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.stems import ( + ReconstructionStemsViewModel, + StemViewModel, +) from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, ChannelName @@ -80,6 +84,8 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._playing_channels: FrozenSet[ChannelName] = frozenset() self._selected_channels: List[ChannelName] = [] + self._available_stems: FrozenSet[int] = frozenset() + self._selected_stems: FrozenSet[int] = frozenset() self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -93,6 +99,7 @@ def __init__( self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None self.on_locate_audio_not_found: Optional[PathCallback] = None + self.on_stems_view_changed: Optional[Callable[[ReconstructionStemsViewModel], None]] = None def display_reconstruction(self) -> None: reconstruction_data = self._reconstruction_data @@ -101,16 +108,22 @@ def display_reconstruction(self) -> None: self._playing_channels = frozenset(reconstruction_data.reconstruction.playing_channels) self._selected_channels = self._in_channel_order(self._playing_channels) + self._available_stems = self._all_stem_ids(reconstruction_data) + self._selected_stems = self._available_stems view_model = self._build_view_model(reconstruction_data) if not view_model.audio_source_enabled: self._current_audio_source = AudioSourceType.RECONSTRUCTION self.call(self.on_view_changed, view_model) + self.call( + self.on_stems_view_changed, + self._build_stems_view_model(reconstruction_data), + ) self.call(self.on_waveform_source_changed, self._current_audio_source) self.call( self.on_waveform_load_changed, - reconstruction_data.waveform_data(), + reconstruction_data.waveform_data(self._selected_stems), self._selected_channels, ) self._emit_audio_data() @@ -121,11 +134,16 @@ def update_reconstruction(self) -> None: return self._adopt_playing_channels(frozenset(reconstruction_data.reconstruction.playing_channels)) + self._adopt_selected_stems(self._all_stem_ids(reconstruction_data)) self.call(self.on_view_changed, self._build_view_model(reconstruction_data)) + self.call( + self.on_stems_view_changed, + self._build_stems_view_model(reconstruction_data), + ) self.call( self.on_waveform_update_changed, - reconstruction_data.waveform_data(), + reconstruction_data.waveform_data(self._selected_stems), self._selected_channels, ) if self._current_audio_source != AudioSourceType.ORIGINAL: @@ -166,8 +184,17 @@ def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION self._playing_channels = frozenset() self._selected_channels = [] + self._available_stems = frozenset() + self._selected_stems = frozenset() self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) + self.call( + self.on_stems_view_changed, + ReconstructionStemsViewModel( + reconstruction_loaded=False, + stems=(), + ), + ) empty_path = ReconstructionPathViewModel( state=ReconstructionPathState.EMPTY, paths=(), @@ -196,11 +223,104 @@ def set_selected_channels(self, channels: List[ChannelName]) -> None: self.call( self.on_waveform_load_changed, - reconstruction_data.waveform_data(), + reconstruction_data.waveform_data(self._selected_stems), channels, ) self._emit_audio_data() + def set_selected_stems(self, stem_ids: FrozenSet[int]) -> None: + """Adopts the reader's stem choice and re-answers playback and the waveform. + + The choice is listening state, so it filters what plays and what the waveform + shows without touching the document. + """ + self._selected_stems = stem_ids + reconstruction_data = self._reconstruction_data + if not reconstruction_data: + return + + self.call( + self.on_stems_view_changed, + self._build_stems_view_model(reconstruction_data), + ) + self.call( + self.on_waveform_load_changed, + reconstruction_data.waveform_data(self._selected_stems), + self._selected_channels, + ) + self._emit_audio_data() + + def _adopt_selected_stems(self, stem_ids: FrozenSet[int]) -> None: + """Carries the reader's stem choice across an edit. + + A stem that keeps existing keeps whatever the reader chose for it, and one + appearing for the first time joins selected, so a deliberate choice survives + while the new stems' content is heard. + """ + selected = (set(self._selected_stems) & stem_ids) | (stem_ids - self._available_stems) + self._available_stems = stem_ids + self._selected_stems = frozenset(selected) + + @staticmethod + def _all_stem_ids( + reconstruction_data: ReconstructionData, + ) -> FrozenSet[int]: + stems_data = reconstruction_data.reconstruction.stems_data + if stems_data is not None: + return frozenset(entry.id for entry in stems_data.config.entries) + + if reconstruction_data.reconstruction.source_paths: + return frozenset({0}) + + return frozenset() + + def _build_stems_view_model( + self, + reconstruction_data: ReconstructionData, + ) -> ReconstructionStemsViewModel: + reconstruction = reconstruction_data.reconstruction + stems_data = reconstruction.stems_data + if stems_data is not None: + assigned_stem_ids = { + stem_id for stem_ids in stems_data.assignments_by_channel.values() for stem_id in stem_ids + } + rows = tuple( + StemViewModel( + stem_id=entry.id, + label=reconstruction.source_paths[index].name, + channels=tuple(entry.channels), + enabled=entry.id in assigned_stem_ids, + selected=entry.id in self._selected_stems, + ) + for index, entry in enumerate(stems_data.config.entries) + ) + return ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=rows, + hierarchy_mode=stems_data.config.hierarchy.mode, + channel_cap=stems_data.config.channel_cap, + ) + + source_paths = reconstruction.source_paths + if source_paths: + return ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=( + StemViewModel( + stem_id=0, + label=source_paths[0].name, + channels=tuple(reconstruction.playing_channels), + enabled=True, + selected=0 in self._selected_stems, + ), + ), + ) + + return ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=(), + ) + def request_export_instrument_dialog( self, channel_name: ChannelName, @@ -399,7 +519,10 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: logger.warning("No reconstruction data available for WAV export") return - audio_snapshot = reconstruction_data.get_partials(self._selected_channels) + audio_snapshot = reconstruction_data.partials_for( + self._selected_channels, + self._selected_stems, + ) sample_rate = reconstruction_data.reconstruction.config.sample_rate self._session_manager.set_audio_path(filepath) self._export_service.export_wav(filepath, sample_rate, audio_snapshot) @@ -449,9 +572,13 @@ def _compute_audio_data(self) -> Optional[AudioData]: if original_audio is None: return None + original_audio = reconstruction_data.original_mix_for(self._selected_stems) return AudioData.from_array(original_audio, sample_rate) - partial_approximation = reconstruction_data.get_partials(self._selected_channels) + partial_approximation = reconstruction_data.partials_for( + self._selected_channels, + self._selected_stems, + ) return AudioData.from_array(partial_approximation, sample_rate) def _build_path_view_models( diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 05eac7e15..0fc2d507b 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -699,6 +699,7 @@ SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback") SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement") SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment") +SUF_CHANNELS = "channels" SUF_GROUP = "group" SUF_GROUP_TRACEBACK = compose_tag(SUF_GROUP, "traceback") SUF_HANDLER_REGISTRY = compose_tag("handler", "registry") diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index cabbad612..91ecfa178 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -104,6 +104,30 @@ Widget.PATH, "original_audio", ) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.PANEL, + "stems", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.GROUP, + "stems", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TEXT, + "stems_setup", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TEXT, + "stems_empty", +) TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL = TagName( Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, @@ -130,6 +154,7 @@ ) PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel") +PRE_RECONSTRUCTION_STEM = compose_tag("reconstruction", "stem") SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message" SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size" SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window" diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py new file mode 100644 index 000000000..111c1196f --- /dev/null +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -0,0 +1,182 @@ +from typing import Any, Callable, FrozenSet, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_CHANNELS, SUF_GROUP +from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_STEM, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.utils.gui.dpg import dpg_delete_item +from sampletones_application.view_model.reconstruction.stems import ( + ReconstructionStemsViewModel, + StemViewModel, +) +from sampletones_core.constants.enums import HierarchyMode +from sampletones_shared.types.application import Sender + + +class GUIReconstructionStemsPanel(GUIPanel): + """The stems of the loaded reconstruction, one checkbox row per stem. + + A checked stem keeps its frames in what plays; unchecking silences them across the + waveform, playback, and WAV export. Rows mirror the channel checkboxes: a stem with + no assigned frames is shown disabled, a single-source reconstruction shows one row + the same way, and a loaded reconstruction recording no source shows the empty state. + """ + + def __init__( + self, + *, + language_manager: LanguageManager, + initial_collapsed: bool = False, + ) -> None: + self._language_manager = language_manager + self._lbl_stems = language_manager["reconstructions.reconstruction.label.stems"] + self._lbl_empty = language_manager["reconstructions.reconstruction.label.stems_empty"] + self._setup_template = language_manager["reconstructions.reconstruction.template.stems_setup"] + self._mode_labels = { + HierarchyMode.ROUND_ROBIN: language_manager["reconstructions.reconstruction.label.stems_mode_round_robin"], + HierarchyMode.STRICT: language_manager["reconstructions.reconstruction.label.stems_mode_strict"], + } + self._stem_rows: List[Tuple[int, str]] = [] + + self.on_stems_changed: Optional[Callable[[FrozenSet[int]], None]] = None + + super().__init__(tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS) + self._enable_vertical_collapse( + initial_collapsed=initial_collapsed, + auto_height=True, + ) + + def create_panel(self, parent: str) -> None: + with self._collapsible_card( + parent, + self._lbl_stems, + glyph=self._glyphs.headers.source, + width=0, + no_scrollbar=True, + ): + dpg.add_text( + "", + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, + parent=self._body_container, + show=False, + ) + dpg.add_text( + self._lbl_empty, + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, + parent=self._body_container, + show=False, + ) + dpg.add_group( + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS, + parent=self._body_container, + ) + + def update_view(self, view_model: ReconstructionStemsViewModel) -> None: + self._sync_rows(view_model.stems) + self._render_setup_line(view_model) + dpg.configure_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, + show=view_model.show_empty_state, + ) + for row in view_model.stems: + self._render_row(row) + + def _render_setup_line( + self, + view_model: ReconstructionStemsViewModel, + ) -> None: + if view_model.show_setup_line: + mode = "" if view_model.hierarchy_mode is None else self._mode_labels[view_model.hierarchy_mode] + dpg.set_value( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, + self._setup_template.format( + mode=mode, + cap=view_model.channel_cap, + ), + ) + + dpg.configure_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, + show=view_model.show_setup_line, + ) + + def _sync_rows(self, rows: Tuple[StemViewModel, ...]) -> None: + """Rebuilds the checkbox rows when the stem set changes, keeps them otherwise.""" + expected_ids = [row.stem_id for row in rows] + current_ids = [stem_id for stem_id, _tag in self._stem_rows] + if current_ids != expected_ids: + for _stem_id, tag in self._stem_rows: + dpg_delete_item(tag) + + self._stem_rows = [(row.stem_id, self._create_stem_row(row)) for row in rows] + + def _create_stem_row(self, row: StemViewModel) -> str: + group_tag = self._stem_group_tag(row.stem_id) + checkbox_tag = self._stem_checkbox_tag(row.stem_id) + with dpg.group( + horizontal=True, + tag=group_tag, + parent=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS, + ): + dpg.add_checkbox( + label=row.label, + tag=checkbox_tag, + callback=self._on_stem_changed, + ) + dpg.add_text( + self._channels_text(row), + tag=self._stem_channels_tag(row.stem_id), + ) + + FontRegistry.bind_to_item(checkbox_tag, Font.REGULAR_SMALL) + return group_tag + + def _render_row(self, row: StemViewModel) -> None: + checkbox_tag = self._stem_checkbox_tag(row.stem_id) + dpg.configure_item(checkbox_tag, enabled=row.enabled) + dpg.set_value(checkbox_tag, row.selected) + dpg.set_value( + self._stem_channels_tag(row.stem_id), + self._channels_text(row), + ) + + def _on_stem_changed(self, _sender: Sender, _app_data: Any) -> None: + selected = frozenset( + stem_id for stem_id, _tag in self._stem_rows if dpg.get_value(self._stem_checkbox_tag(stem_id)) + ) + self.call(self.on_stems_changed, selected) + + def _channels_text(self, row: StemViewModel) -> str: + return ", ".join(channel_label(self._language_manager, channel) for channel in row.channels) + + @staticmethod + def _stem_checkbox_tag(stem_id: int) -> str: + return compose_tag(PRE_RECONSTRUCTION_STEM, str(stem_id)) + + @staticmethod + def _stem_group_tag(stem_id: int) -> str: + return compose_tag( + PRE_RECONSTRUCTION_STEM, + str(stem_id), + SUF_GROUP, + ) + + @staticmethod + def _stem_channels_tag(stem_id: int) -> str: + return compose_tag( + PRE_RECONSTRUCTION_STEM, + str(stem_id), + SUF_CHANNELS, + ) diff --git a/src/sampletones_application/utils/gui/dialogs/__init__.py b/src/sampletones_application/utils/gui/dialogs/__init__.py new file mode 100644 index 000000000..539ec4da2 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/__init__.py @@ -0,0 +1,6 @@ +from .renderer import DialogsRenderer, get_dialog_tag + +__all__ = [ + "DialogsRenderer", + "get_dialog_tag", +] diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs/renderer.py similarity index 63% rename from src/sampletones_application/utils/gui/dialogs.py rename to src/sampletones_application/utils/gui/dialogs/renderer.py index 9cc89a16f..7a73d487e 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -9,11 +9,7 @@ from sampletones_application.layout.general import GeneralLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( - SUF_BUTTON_CANCEL, SUF_BUTTON_OK, - SUF_BUTTON_SAVE, - SUF_BUTTON_SHOW_TRACEBACK, - SUF_CHECKBOX, SUF_DIALOG_INFO, SUF_GROUP, SUF_PATH, @@ -30,17 +26,22 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.path import GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.trace import GUITraceback from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.align import center_when_settled, table_wrapper +from sampletones_application.utils.gui.align import center_when_settled from sampletones_application.utils.gui.dialog_navigation import ( DialogKeyboardNavigator, FocusStop, ) -from sampletones_application.utils.gui.dpg import ( - dpg_configure_item, - dpg_delete_item, +from sampletones_application.utils.gui.dialogs.windows.confirmation import ( + GUIConfirmationWindow, ) +from sampletones_application.utils.gui.dialogs.windows.error import ( + GUIErrorDialogWindow, +) +from sampletones_application.utils.gui.dialogs.windows.save_confirmation import ( + GUISaveConfirmationWindow, +) +from sampletones_application.utils.gui.dpg import dpg_delete_item from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.shortcuts.source import ShortcutSource @@ -165,7 +166,7 @@ def __init__( self._msg_path = language_manager["global.status.message.path"] self._lbl_ok = language_manager["global.dialog.label.ok"] self._lbl_cancel = language_manager["global.dialog.label.cancel"] - self._lbl_traceback_show = language_manager["global.traceback.label.show"] + self._lbl_save = language_manager["global.dialog.label.save"] def show_modal( self, @@ -322,98 +323,21 @@ def _render_template_bold( if trailing: dpg.add_text(trailing, parent=group_tag) - # TODO: refactor def show_error( self, exception: Exception, message: Optional[str] = None, ) -> None: - tag = get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR) - show_button_tag = compose_tag(tag, SUF_BUTTON_SHOW_TRACEBACK) - ok_button_tag = compose_tag(tag, SUF_BUTTON_OK) - navigator: Optional[DialogKeyboardNavigator] = None - - def close() -> None: - if navigator is not None: - navigator.dispose() - - dpg_delete_item(tag) - - with dpg.window( - label=self._language_manager["global.dialog.title.error"], - tag=tag, - modal=True, - min_size=(self._error_width, self._error_height), - autosize=True, - no_scrollbar=False, - on_close=close, - ): - _bind_dialog_theme(tag) - if message is not None: - dpg.add_text(message, parent=tag, wrap=self._error_wrap) - - group_tag = compose_tag(tag, SUF_GROUP) - with dpg.group(tag=group_tag, parent=tag): - name_text = dpg.add_text( - f"{type(exception).__name__!s}: ", - parent=group_tag, - ) - dpg_set_palette_color(name_text, self._col_text_error) - message_text = dpg.add_text( - str(exception), - parent=group_tag, - wrap=self._error_wrap, - ) - dpg_set_palette_color(message_text, self._col_text_error) - - traceback = GUITraceback( - parent=tag, - exception=exception, - language_manager=self._language_manager, - ) - - dpg.add_separator() - - def toggle_traceback() -> None: - traceback.toggle_visibility() - dpg_configure_item( - show_button_tag, - label=( - self._lbl_traceback_show - if not traceback.visible - else self._language_manager["global.traceback.label.hide"] - ), - ) - - @table_wrapper(columns=2) - def content(_: None) -> None: - GUIButton( - tag=show_button_tag, - label=self._lbl_traceback_show, - width=-1, - callback=toggle_traceback, - ) - GUIButton( - tag=ok_button_tag, - label=self._lbl_ok, - callback=close, - width=-1, - ) - - content(None) - - navigator = _install_navigation( - window_tag=tag, - stops=[ - FocusStop.button(show_button_tag, toggle_traceback), - FocusStop.button(ok_button_tag, close), - ], - on_escape=close, + GUIErrorDialogWindow( + tag=get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR), + width=self._error_width, + height=self._error_height, + wrap=self._error_wrap, + language_manager=self._language_manager, + error_color=self._col_text_error, key_router=self._router, shortcut_source=self._shortcuts, - initial_index=1, - ) - center_when_settled(tag) + ).show(exception, message) def show_file_not_found(self, filepath: Path, message: str) -> None: tag = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) @@ -438,7 +362,6 @@ def content(parent: str) -> None: height=self._default_height, ) - # TODO: refactor def show_confirmation( self, tag: str, @@ -462,102 +385,29 @@ def show_confirmation( When ``opt_out_label`` is given, a checkbox is shown; if it is ticked when the user confirms, ``on_opt_out`` runs as well — letting the caller suppress future prompts. """ - tag = get_dialog_tag(tag) - opt_out_tag = compose_tag(tag, SUF_CHECKBOX) - cancel_label = cancel_label if cancel_label is not None else self._lbl_cancel - ok_button_tag = compose_tag(tag, SUF_BUTTON_OK) - cancel_button_tag = compose_tag(tag, SUF_BUTTON_CANCEL) - navigator: Optional[DialogKeyboardNavigator] = None - - def disable() -> None: - dpg_configure_item(ok_button_tag, enabled=False) - dpg_configure_item(cancel_button_tag, enabled=False) - - def close() -> None: - if navigator is not None: - navigator.dispose() - - dpg_delete_item(tag) - - def _on_confirm() -> None: - disable() - if opt_out_label is not None and on_opt_out is not None and dpg.get_value(opt_out_tag): - on_opt_out() - - on_confirm() - close() - - def _on_cancel() -> None: - disable() - if on_cancel is not None: - on_cancel() - - close() - - def content(parent: str) -> None: - dpg.add_text(message, parent=parent, wrap=self._default_wrap) - - if path is not None: - GUIPathText( - tag=compose_tag(tag, SUF_PATH), - path=path, - parent=parent, - color=self._col_path, - hover_color=self._col_path_hover, - status_message=self._msg_path, - use_filename_only=True, - status_bar=self._status_bar, - ) - - if opt_out_label is not None: - dpg.add_checkbox( - label=opt_out_label, - tag=opt_out_tag, - parent=parent, - ) - - @table_wrapper(columns=2) - def buttons(_: None) -> None: - GUIButton( - tag=ok_button_tag, - label=ok_label, - callback=_on_confirm, - width=-1, - ) - GUIButton( - tag=cancel_button_tag, - label=cancel_label, - callback=_on_cancel, - width=-1, - ) - - buttons(None) - - with dpg.window( - label=title, - tag=tag, - modal=True, - min_size=(self._default_width, self._confirmation_height), - no_resize=True, - on_close=_on_cancel, - ): - _bind_dialog_theme(tag) - content(tag) - - navigator = _install_navigation( - window_tag=tag, - stops=[ - FocusStop.button(ok_button_tag, _on_confirm), - FocusStop.button(cancel_button_tag, _on_cancel), - ], - on_escape=_on_cancel, + GUIConfirmationWindow( + tag=get_dialog_tag(tag), + width=self._default_width, + height=self._confirmation_height, + wrap=self._default_wrap, + path_color=self._col_path, + path_hover_color=self._col_path_hover, + path_message=self._msg_path, + status_bar=self._status_bar, key_router=self._router, shortcut_source=self._shortcuts, - initial_index=1, + ).show( + message, + title, + on_confirm, + ok_label=ok_label, + cancel_label=cancel_label if cancel_label is not None else self._lbl_cancel, + path=path, + opt_out_label=opt_out_label, + on_opt_out=on_opt_out, + on_cancel=on_cancel, ) - center_when_settled(tag) - # TODO: refactor def show_save_confirmation( self, tag: str, @@ -575,90 +425,22 @@ def show_save_confirmation( prompt open for another attempt. The middle button discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt. """ - tag = get_dialog_tag(tag) - save_button_tag = compose_tag(tag, SUF_BUTTON_SAVE) - ok_button_tag = compose_tag(tag, SUF_BUTTON_OK) - cancel_button_tag = compose_tag(tag, SUF_BUTTON_CANCEL) - navigator: Optional[DialogKeyboardNavigator] = None - - def disable() -> None: - dpg_configure_item(save_button_tag, enabled=False) - dpg_configure_item(ok_button_tag, enabled=False) - dpg_configure_item(cancel_button_tag, enabled=False) - - def close() -> None: - if navigator is not None: - navigator.dispose() - - dpg_delete_item(tag) - - def _on_save() -> None: - if not on_save(): - return - - disable() - on_confirm() - close() - - def _on_confirm() -> None: - disable() - on_confirm() - close() - - def _on_cancel() -> None: - disable() - close() - - def content(parent: str) -> None: - dpg.add_text(message, parent=parent, wrap=self._default_wrap) - - @table_wrapper(columns=3) - def buttons(_: None) -> None: - GUIButton( - tag=save_button_tag, - label=self._language_manager["global.dialog.label.save"], - callback=_on_save, - width=-1, - ) - GUIButton( - tag=ok_button_tag, - label=ok_label, - callback=_on_confirm, - width=-1, - ) - GUIButton( - tag=cancel_button_tag, - label=self._lbl_cancel, - callback=_on_cancel, - width=-1, - ) - - buttons(None) - - with dpg.window( - label=title, - tag=tag, - modal=True, - min_size=(self._default_width, self._confirmation_height), - no_resize=True, - on_close=close, - ): - _bind_dialog_theme(tag) - content(tag) - - navigator = _install_navigation( - window_tag=tag, - stops=[ - FocusStop.button(save_button_tag, _on_save), - FocusStop.button(ok_button_tag, _on_confirm), - FocusStop.button(cancel_button_tag, _on_cancel), - ], - on_escape=_on_cancel, + GUISaveConfirmationWindow( + tag=get_dialog_tag(tag), + width=self._default_width, + height=self._confirmation_height, + wrap=self._default_wrap, + save_label=self._lbl_save, + cancel_label=self._lbl_cancel, key_router=self._router, shortcut_source=self._shortcuts, - initial_index=2, + ).show( + message, + title, + on_save, + on_confirm, + ok_label=ok_label, ) - center_when_settled(tag) def show_reconstruction_not_loaded(self) -> None: tag = get_dialog_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_NOT_LOADED) diff --git a/src/sampletones_application/utils/gui/dialogs/windows/__init__.py b/src/sampletones_application/utils/gui/dialogs/windows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py new file mode 100644 index 000000000..d72449cb9 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py @@ -0,0 +1,173 @@ +from pathlib import Path +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON_CANCEL, + SUF_BUTTON_OK, + SUF_CHECKBOX, + SUF_PATH, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_shared.types.callback import Callback + +CANCEL_FOCUS_STOP: Final[int] = 1 + + +class GUIConfirmationWindow(GUIDialogWindow): + """A modal asking one question, answered through OK, Cancel, or the title-bar close. + + ``on_confirm``/``on_cancel`` run on the respective choice, and the title bar reads as + the negative one, so every way out of the prompt reaches the caller. A checked opt-out + checkbox adds ``on_opt_out`` to a confirmation, letting the caller suppress future + prompts. Cancel — the initially focused button — keeps the prompt answerable by + keyboard alone. + """ + + def __init__( + self, + tag: str, + *, + width: int, + height: int, + wrap: int, + path_color: BaseColor, + path_hover_color: BaseColor, + path_message: str, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._wrap = wrap + self._path_color = path_color + self._path_hover_color = path_hover_color + self._path_message = path_message + self._status_bar = status_bar + + self._message: str + self._title: str + self._on_confirm: Callback + self._on_cancel: Optional[Callback] + self._on_opt_out: Optional[Callback] + self._ok_label: str + self._cancel_label: str + self._path: Optional[Path] + self._opt_out_label: Optional[str] + + super().__init__( + tag, + width, + height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare( + self, + message: str, + title: str, + on_confirm: Callback, + *, + ok_label: str, + cancel_label: str, + path: Optional[Path], + opt_out_label: Optional[str], + on_opt_out: Optional[Callback], + on_cancel: Optional[Callback], + ) -> None: + """Captures the question and its answers for the next appearance.""" + self._message = message + self._title = title + self._on_confirm = on_confirm + self._on_cancel = on_cancel + self._on_opt_out = on_opt_out + self._ok_label = ok_label + self._cancel_label = cancel_label + self._path = path + self._opt_out_label = opt_out_label + + def create_window(self) -> None: + opt_out_tag = compose_tag(self.tag, SUF_CHECKBOX) + ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK) + cancel_button_tag = compose_tag(self.tag, SUF_BUTTON_CANCEL) + + def disable() -> None: + dpg_configure_item(ok_button_tag, enabled=False) + dpg_configure_item(cancel_button_tag, enabled=False) + + def _on_confirm() -> None: + disable() + if self._opt_out_label is not None and self._on_opt_out is not None and dpg.get_value(opt_out_tag): + self._on_opt_out() + + self._on_confirm() + self.hide() + + def _on_cancel() -> None: + disable() + if self._on_cancel is not None: + self._on_cancel() + + self.hide() + + def content(parent: str) -> None: + dpg.add_text(self._message, parent=parent, wrap=self._wrap) + + if self._path is not None: + GUIPathText( + tag=compose_tag(self.tag, SUF_PATH), + path=self._path, + parent=parent, + color=self._path_color, + hover_color=self._path_hover_color, + status_message=self._path_message, + use_filename_only=True, + status_bar=self._status_bar, + ) + + if self._opt_out_label is not None: + dpg.add_checkbox( + label=self._opt_out_label, + tag=opt_out_tag, + parent=parent, + ) + + @table_wrapper(columns=2) + def buttons(_: None) -> None: + GUIButton( + tag=ok_button_tag, + label=self._ok_label, + callback=_on_confirm, + width=-1, + ) + GUIButton( + tag=cancel_button_tag, + label=self._cancel_label, + callback=_on_cancel, + width=-1, + ) + + buttons(None) + + with self.dialog_window(label=self._title, on_close=_on_cancel): + content(self.tag) + + self._install_navigation( + [ + FocusStop.button(ok_button_tag, _on_confirm), + FocusStop.button(cancel_button_tag, _on_cancel), + ], + on_escape=_on_cancel, + initial_index=CANCEL_FOCUS_STOP, + ) diff --git a/src/sampletones_application/utils/gui/dialogs/windows/error.py b/src/sampletones_application/utils/gui/dialogs/windows/error.py new file mode 100644 index 000000000..151e2c628 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/error.py @@ -0,0 +1,139 @@ +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON_OK, + SUF_BUTTON_SHOW_TRACEBACK, + SUF_GROUP, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.trace import GUITraceback +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.colors.base import BaseColor + +OK_FOCUS_STOP: Final[int] = 1 + + +class GUIErrorDialogWindow(GUIDialogWindow): + """A modal reporting an exception with its message and an optional traceback. + + The exception's name and text are drawn in the error colour, the traceback starts + hidden behind its toggle, and OK — the initially focused button — dismisses the + prompt. The title-bar close reads the same way. + """ + + def __init__( + self, + tag: str, + *, + width: int, + height: int, + wrap: int, + language_manager: LanguageManager, + error_color: BaseColor, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._language_manager = language_manager + self._wrap = wrap + self._error_color = error_color + self._exception: Exception + self._message: Optional[str] + + super().__init__( + tag, + width, + height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare(self, exception: Exception, message: Optional[str]) -> None: + """Captures the failure the next appearance reports.""" + self._exception = exception + self._message = message + + def create_window(self) -> None: + show_button_tag = compose_tag(self.tag, SUF_BUTTON_SHOW_TRACEBACK) + ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK) + traceback: GUITraceback + + with dpg.window( + tag=self.tag, + label=self._language_manager["global.dialog.title.error"], + modal=True, + min_size=(self.width, self.height), + autosize=True, + no_scrollbar=False, + on_close=self.hide, + ): + if self._message is not None: + dpg.add_text(self._message, parent=self.tag, wrap=self._wrap) + + group_tag = compose_tag(self.tag, SUF_GROUP) + with dpg.group(tag=group_tag, parent=self.tag): + name_text = dpg.add_text( + f"{type(self._exception).__name__!s}: ", + parent=group_tag, + ) + dpg_set_palette_color(name_text, self._error_color) + message_text = dpg.add_text( + str(self._exception), + parent=group_tag, + wrap=self._wrap, + ) + dpg_set_palette_color(message_text, self._error_color) + + traceback = GUITraceback( + parent=self.tag, + exception=self._exception, + language_manager=self._language_manager, + ) + + def toggle_traceback() -> None: + traceback.toggle_visibility() + dpg_configure_item( + show_button_tag, + label=( + self._language_manager["global.traceback.label.show"] + if not traceback.visible + else self._language_manager["global.traceback.label.hide"] + ), + ) + + dpg.add_separator() + + @table_wrapper(columns=2) + def buttons(_: None) -> None: + GUIButton( + tag=show_button_tag, + label=self._language_manager["global.traceback.label.show"], + width=-1, + callback=toggle_traceback, + ) + GUIButton( + tag=ok_button_tag, + label=self._language_manager["global.dialog.label.ok"], + callback=self.hide, + width=-1, + ) + + buttons(None) + + self._install_navigation( + [ + FocusStop.button(show_button_tag, toggle_traceback), + FocusStop.button(ok_button_tag, self.hide), + ], + on_escape=self.hide, + initial_index=OK_FOCUS_STOP, + ) diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py new file mode 100644 index 000000000..ed5f03e8b --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -0,0 +1,143 @@ +from typing import Callable, Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON_CANCEL, + SUF_BUTTON_OK, + SUF_BUTTON_SAVE, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_shared.types.callback import Callback + +CANCEL_FOCUS_STOP: Final[int] = 2 + + +class GUISaveConfirmationWindow(GUIDialogWindow): + """A modal save-or-proceed prompt for an unsaved document. + + ``on_save`` writes the document and reports whether it completed; the prompt runs + ``on_confirm`` and closes once the save reports success, so a cancelled save keeps the + prompt open for another attempt. The middle button discards the pending changes and + runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses + the prompt. + """ + + def __init__( + self, + tag: str, + *, + width: int, + height: int, + wrap: int, + save_label: str, + cancel_label: str, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._wrap = wrap + self._save_label = save_label + self._cancel_label = cancel_label + + self._message: str + self._title: str + self._on_save: Callable[[], bool] + self._on_confirm: Callback + self._ok_label: str + + super().__init__( + tag, + width, + height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare( + self, + message: str, + title: str, + on_save: Callable[[], bool], + on_confirm: Callback, + *, + ok_label: str, + ) -> None: + """Captures the pending document's write and the two ways forward.""" + self._message = message + self._title = title + self._on_save = on_save + self._on_confirm = on_confirm + self._ok_label = ok_label + + def create_window(self) -> None: + save_button_tag = compose_tag(self.tag, SUF_BUTTON_SAVE) + ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK) + cancel_button_tag = compose_tag(self.tag, SUF_BUTTON_CANCEL) + + def disable() -> None: + dpg_configure_item(save_button_tag, enabled=False) + dpg_configure_item(ok_button_tag, enabled=False) + dpg_configure_item(cancel_button_tag, enabled=False) + + def _on_save() -> None: + if not self._on_save(): + return + + disable() + self._on_confirm() + self.hide() + + def _on_confirm() -> None: + disable() + self._on_confirm() + self.hide() + + def _on_cancel() -> None: + disable() + self.hide() + + def content(parent: str) -> None: + dpg.add_text(self._message, parent=parent, wrap=self._wrap) + + @table_wrapper(columns=3) + def buttons(_: None) -> None: + GUIButton( + tag=save_button_tag, + label=self._save_label, + callback=_on_save, + width=-1, + ) + GUIButton( + tag=ok_button_tag, + label=self._ok_label, + callback=_on_confirm, + width=-1, + ) + GUIButton( + tag=cancel_button_tag, + label=self._cancel_label, + callback=_on_cancel, + width=-1, + ) + + buttons(None) + + with self.dialog_window(label=self._title, on_close=_on_cancel): + content(self.tag) + + self._install_navigation( + [ + FocusStop.button(save_button_tag, _on_save), + FocusStop.button(ok_button_tag, _on_confirm), + FocusStop.button(cancel_button_tag, _on_cancel), + ], + on_escape=_on_cancel, + initial_index=CANCEL_FOCUS_STOP, + ) diff --git a/src/sampletones_application/view_model/reconstruction/stems.py b/src/sampletones_application/view_model/reconstruction/stems.py new file mode 100644 index 000000000..b5a0f7c16 --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/stems.py @@ -0,0 +1,34 @@ +from typing import Optional, Tuple + +from pydantic import BaseModel + +from sampletones_core.constants.enums import ChannelName, HierarchyMode + + +class StemViewModel(BaseModel, frozen=True): + """One stem row: its identity, source file, channels, and selection.""" + + stem_id: int + label: str + channels: Tuple[ChannelName, ...] + enabled: bool + selected: bool + + +class ReconstructionStemsViewModel(BaseModel, frozen=True): + """What the stems card renders for the loaded reconstruction.""" + + reconstruction_loaded: bool + stems: Tuple[StemViewModel, ...] + hierarchy_mode: Optional[HierarchyMode] = None + channel_cap: Optional[int] = None + + @property + def show_setup_line(self) -> bool: + """The setup line states the hierarchy mode and cap a stems record carries.""" + return self.hierarchy_mode is not None + + @property + def show_empty_state(self) -> bool: + """The empty state explains a loaded reconstruction that records no source.""" + return self.reconstruction_loaded and not self.stems diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py index c3b22c98e..09f6ac6af 100644 --- a/src/sampletones_application/view_model/shared/waveform_data.py +++ b/src/sampletones_application/view_model/shared/waveform_data.py @@ -18,7 +18,8 @@ def partials(self, channel_names: List[ChannelName]) -> np.ndarray: """Sums the selected generators' approximations, silent when none apply. The approximation sets the length, so the silent result matches the waveform even when - no original audio is present. + no original audio is present. Each selected approximation pads to that length before + the sum, so a channel that ends early leaves the tail in silence. """ if not channel_names: return np.zeros_like(self.approximation) @@ -30,5 +31,10 @@ def partials(self, channel_names: List[ChannelName]) -> np.ndarray: if not selected_approximations: return np.zeros_like(self.approximation) - partials: np.ndarray = np.sum(selected_approximations, axis=0) - return partials + length = len(self.approximation) + dtype: np.dtype = np.result_type(*[audio.dtype for audio in selected_approximations]) + summed = np.zeros(length, dtype=dtype) + for audio in selected_approximations: + summed[: len(audio)] += audio # does audio.mixing applies here? if no, remove the comment; otherwises apply + + return summed diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 56bd4fbfc..b6dbd0bdf 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -407,6 +407,11 @@ reconstructions.reconstruction.tooltip.autoscale_tooltip: "Include reconstructio reconstructions.reconstruction.message.locate_audio_failed: "Original audio file could not be found." reconstructions.reconstruction.message.export_wav_success: "Reconstruction rendered successfully." reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed to save." +reconstructions.reconstruction.label.stems: "Stems" +reconstructions.reconstruction.label.stems_empty: "This reconstruction records no source" +reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin" +reconstructions.reconstruction.label.stems_mode_strict: "Strict" +reconstructions.reconstruction.template.stems_setup: "Mode: {mode} · Channel cap: {cap}" # ============================================================================= # Reconstructions tab — Instruments diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py new file mode 100644 index 000000000..6f27770d4 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py @@ -0,0 +1,31 @@ +from typing import AbstractSet, Dict, Mapping + +import numpy as np + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData + + +def filter_approximations( + stems_data: StemsData, + approximations: Mapping[ChannelName, np.ndarray], + selected_stem_ids: AbstractSet[int], + frame_length: int, +) -> Dict[ChannelName, np.ndarray]: + """Returns the per-channel approximations with the unselected stems' frames zeroed. + + Stem id ``i`` names frame ``i`` of its channel — the same index the channel's stored + approximation slices hold — so a frame whose stem is unselected becomes silence while + every other frame keeps its samples. The arrays keep their lengths, which is what + aligns a filtered mix with the unfiltered one sample for sample. Every selected stem + answers the original arrays, and channels the stems data names come back filtered. + """ + filtered: Dict[ChannelName, np.ndarray] = {} + for channel, stem_ids in stems_data.assignments_by_channel.items(): + approximation = approximations[channel] + keep = np.isin(np.array(stem_ids, dtype=int), list(selected_stem_ids)) + masked = np.array(approximation, copy=True) + masked[~np.repeat(keep, frame_length)] = 0 + filtered[channel] = masked + + return filtered diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 8194ae2ab..930275567 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List, Optional, Sequence +from typing import Dict, List, Optional, Sequence, Tuple import numpy as np @@ -127,8 +127,6 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: self.reconstruct(fragmented_audio) return Reconstruction.from_state(self.state, self.config, coefficient, path) - # TODO: refactor: split into steps so this function reads as prose - # each step should be a separate function that has a single concrete objective def reconstruct_stems( self, paths: Sequence[Pathlike], @@ -150,6 +148,39 @@ def reconstruct_stems( Returns: Optional[Reconstruction]: The reconstruction built from the mix. + Raises: + ValueError: If the entries count differently than ``paths``. + TypeError: If a path is not a string or ``Path``. + """ + checked_paths = self._check_stem_paths(paths, stems_config) + mixed = self._mix_stem_audios(checked_paths) + fragmented_audio, coefficient = self._prepare_stem_frames(mixed, stems_config) + stems, hierarchy = self._build_stem_models(stems_config) + worker, matcher = self._build_stem_matcher(mixed) + assignments = self._assign_stem_frames( + fragmented_audio, + stems, + hierarchy, + worker, + matcher, + stems_config.channel_cap, + ) + stems_data = self._build_stems_data(stems_config, assignments) + return Reconstruction.from_state( + self.state, + self.config, + coefficient, + tuple(checked_paths), + stems_data=stems_data, + ) + + @staticmethod + def _check_stem_paths( + paths: Sequence[Pathlike], + stems_config: StemsConfig, + ) -> List[Path]: + """Validates the stem paths against the entries and converts them to ``Path``. + Raises: ValueError: If the entries count differently than ``paths``. TypeError: If a path is not a string or ``Path``. @@ -161,22 +192,55 @@ def reconstruct_stems( for path in paths: if not isinstance(path, (str, Path)): raise TypeError("Input must be a path to an audio file") + checked_paths.append(to_path(path)) + return checked_paths + + def _mix_stem_audios(self, checked_paths: List[Path]) -> np.ndarray: + """Loads every stem and returns their mix, the target the frames match against.""" audios = [self.load_audio(path) for path in checked_paths] - mixed = mix(audios) + return mix(audios) + + def _prepare_stem_frames( + self, + mixed: np.ndarray, + stems_config: StemsConfig, + ) -> Tuple[FragmentedAudio, float]: + """Scales the mix to the working level and frames it over the stems' channels. + + Returns the framed target together with the coefficient it was scaled by, so + the assembled reconstruction records the level it was matched at. + """ coefficient = self.get_coefficient(mixed) self.reset_generators() covered = {channel for entry in stems_config.entries for channel in entry.channels} self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered]) - fragmented_audio = self.get_fragments(mixed / coefficient) + return self.get_fragments(mixed / coefficient), coefficient + @staticmethod + def _build_stem_models( + stems_config: StemsConfig, + ) -> Tuple[Dict[int, Stem], StemHierarchy]: + """Converts the stems setup into the runtime stem and hierarchy models.""" + stems = {entry.id: Stem(id=entry.id, channels=frozenset(entry.channels)) for entry in stems_config.entries} + hierarchy = StemHierarchy( + levels=tuple(tuple(level) for level in stems_config.hierarchy.levels), + mode=stems_config.hierarchy.mode, + ) + return stems, hierarchy + + def _build_stem_matcher( + self, + signal: np.ndarray, + ) -> Tuple[ReconstructorWorker, FrameMatcher]: + """Builds the worker and the frame matcher that score the stems' candidates.""" worker = ReconstructorWorker( config=self.config, window=self.window, channels=self.channels, library_data=self.library_data, - signal_length=mixed.shape[0], + signal_length=signal.shape[0], ) matcher = FrameMatcher( config=worker.config, @@ -184,18 +248,22 @@ def reconstruct_stems( scorer=worker.scorer, phase_aligner=worker.phase_aligner, ) - stems = { - entry.id: Stem( - id=entry.id, - channels=frozenset(entry.channels), - ) - for entry in stems_config.entries - } - hierarchy = StemHierarchy( - levels=tuple(tuple(level) for level in stems_config.hierarchy.levels), - mode=stems_config.hierarchy.mode, - ) + return worker, matcher + def _assign_stem_frames( + self, + fragmented_audio: FragmentedAudio, + stems: Dict[int, Stem], + hierarchy: StemHierarchy, + worker: ReconstructorWorker, + matcher: FrameMatcher, + channel_cap: int, + ) -> Dict[ChannelName, List[int]]: + """Assigns every frame's channels to the stems and records both sides of the outcome. + + Each choice updates the reconstruction state and the per-channel stem record, + the two lists staying parallel so stem id ``i`` names frame ``i`` of its channel. + """ assignments: Dict[ChannelName, List[int]] = {} for fragment_id in fragmented_audio.fragments_ids: fragment = fragmented_audio[fragment_id] @@ -206,7 +274,7 @@ def reconstruct_stems( self.channels, matcher, worker.feature_extractor, - stems_config.channel_cap, + channel_cap, ) for choice in frame_assignment.choices: self.update_state( @@ -218,23 +286,20 @@ def reconstruct_stems( ) assignments.setdefault(choice.channel_name, []).append(choice.stem_id) - stems_data = StemsData( + return assignments + + @staticmethod + def _build_stems_data( + stems_config: StemsConfig, + assignments: Dict[ChannelName, List[int]], + ) -> StemsData: + """Assembles the per-channel per-frame stem record into serializable stems data.""" + return StemsData( config=stems_config, assignments=[ - ChannelAssignment( - channel_name=channel, - stem_ids=stem_ids, - ) - for channel, stem_ids in assignments.items() + ChannelAssignment(channel_name=channel, stem_ids=stem_ids) for channel, stem_ids in assignments.items() ], ) - return Reconstruction.from_state( - self.state, - self.config, - coefficient, - tuple(checked_paths), - stems_data=stems_data, - ) def load_audio(self, path: Path) -> np.ndarray: """Loads and preconditions the audio at ``path`` for reconstruction. diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 190032d29..21b81e83c 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, Final, FrozenSet, List +from typing import Any, Dict, Final, FrozenSet, List, Tuple import numpy as np @@ -7,7 +7,7 @@ from sampletones_core.audio.processing import normalize from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.configs.generation import GenerationConfig -from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.constants.enums import ChannelName, HierarchyMode, SpectrumMethod from sampletones_core.fft import Window from sampletones_core.fft.features import get_feature_extractor from sampletones_core.generators import get_generators_by_channels @@ -19,6 +19,9 @@ ) from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_yaml from tests.integration.assets.synth_config import SynthConfig @@ -30,8 +33,72 @@ ChannelName.NOISE, ] +STEM_A_ID: Final[int] = 0 +STEM_B_ID: Final[int] = 1 +STEM_C_ID: Final[int] = 2 +THREE_STEM_CHANNELS: Final[List[ChannelName]] = [ + ChannelName.PULSE1, + ChannelName.PULSE2, + ChannelName.TRIANGLE, + ChannelName.NOISE, +] +THREE_STEM_ENTRY_CHANNELS: Final[Dict[int, List[ChannelName]]] = { + STEM_A_ID: [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE], + STEM_B_ID: [ChannelName.PULSE2, ChannelName.TRIANGLE], + STEM_C_ID: [ChannelName.PULSE1, ChannelName.NOISE], +} +STEM_RECORDING_DURATION_SECONDS: Final[float] = 0.5 + + +def three_stem_config() -> StemsConfig: + """Builds the three-stem setup the stems tests share. + + Stems a (pulse 1, triangle, noise) and b (pulse 2, triangle) pick on the first + hierarchy level, stem c (pulse 1, noise) on the second. + """ + return StemsConfig( + entries=[StemEntry(id=stem_id, channels=channels) for stem_id, channels in THREE_STEM_ENTRY_CHANNELS.items()], + hierarchy=StemsHierarchy( + levels=[[STEM_A_ID, STEM_B_ID], [STEM_C_ID]], + mode=HierarchyMode.STRICT, + ), + channel_cap=1, + ) + + +def three_stem_reconstruction_config() -> Config: + """Builds a reconstruction config with both pulses enabled for the three-stem example.""" + return Config(generation=GenerationConfig(channels=THREE_STEM_CHANNELS)) + -def build_mini_library(config: Config, *, per_generator: int = INSTRUCTIONS_PER_GENERATOR) -> InstructionLibrary: +def write_three_stem_recordings( + config: Config, + tmp_dir: Pathlike, +) -> Tuple[Path, Path, Path]: + """Writes three distinct stem recordings a, b, c and returns their paths in order.""" + sample_rate = config.library.sample_rate + count = int(sample_rate * STEM_RECORDING_DURATION_SECONDS) + time = np.arange(count) / sample_rate + recordings = { + "a": 0.5 * np.sin(2 * np.pi * 440.0 * time), + "b": 0.4 * np.sin(2 * np.pi * 220.0 * time), + "c": np.random.default_rng(93).uniform(-0.3, 0.3, count), + } + + paths: List[Path] = [] + for name, audio in recordings.items(): + path = Path(tmp_dir) / f"stem_{name}.wav" + write_wave(path, sample_rate, audio) + paths.append(path) + + return paths[0], paths[1], paths[2] + + +def build_mini_library( + config: Config, + *, + per_generator: int = INSTRUCTIONS_PER_GENERATOR, +) -> InstructionLibrary: """Builds a small in-memory instruction library covering pulse/triangle/noise. Candidates are sampled with an even stride across each channel's instruction diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index d5f8bd357..991ef0676 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final +from typing import AbstractSet, Dict, Final import numpy as np import pytest @@ -8,14 +8,23 @@ from sampletones_core.audio import load_audio, mix, write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_core.reconstructions import Reconstructor +from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy -from tests.integration.assets.reconstruction import build_mini_library +from tests.integration.assets.reconstruction import ( + STEM_A_ID, + STEM_B_ID, + STEM_C_ID, + build_mini_library, + three_stem_config, + three_stem_reconstruction_config, + write_three_stem_recordings, +) _TONE_FREQUENCY: Final[float] = 440.0 _DURATION_SECONDS: Final[float] = 0.5 +_MIX_TOLERANCE: Final[float] = 1e-6 # float32 sums drift with accumulation order def _stems_config() -> StemsConfig: @@ -88,6 +97,136 @@ def test_requires_one_path_per_entry(self, tmp_path: Path) -> None: ) +class TestThreeStemHierarchy: + """The shared three-stem example: a (pulse 1, triangle, noise) and b (pulse 2, + triangle) pick on the first hierarchy level, c (pulse 1, noise) on the second.""" + + def test_builds_a_reconstruction_over_the_three_stems(self, tmp_path: Path) -> None: + config = three_stem_reconstruction_config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + stems_config = three_stem_config() + paths = write_three_stem_recordings(config, tmp_path) + + reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + + assert reconstruction is not None + assert reconstruction.audio_filepath == paths + stems_data = reconstruction.stems_data + assert stems_data is not None + assert stems_data.config == stems_config + + assignments = stems_data.assignments_by_channel + assert assignments + assert set(assignments.get(ChannelName.PULSE2, [])) == {STEM_B_ID} + assert set(assignments.get(ChannelName.TRIANGLE, [])) <= {STEM_A_ID, STEM_B_ID} + assert set(assignments.get(ChannelName.PULSE1, [])) <= {STEM_A_ID, STEM_C_ID} + assert set(assignments.get(ChannelName.NOISE, [])) <= {STEM_A_ID, STEM_C_ID} + for channel, stem_ids in assignments.items(): + assert len(reconstruction.instructions[channel]) == len(stem_ids) + + def test_round_trips_through_the_file(self, tmp_path: Path) -> None: + config = three_stem_reconstruction_config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + stems_config = three_stem_config() + paths = write_three_stem_recordings(config, tmp_path) + reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + assert reconstruction is not None + + save_path = tmp_path / "three_stems.stn" + reconstruction.save(save_path) + + loaded = Reconstruction.load(save_path) + + assert loaded.source_paths == paths + assert loaded.stems_data is not None + assert loaded.stems_data.config == stems_config + assert loaded.stems_data.assignments_by_channel == reconstruction.stems_data.assignments_by_channel + + def test_selection_filters_the_waveform_and_partials(self, tmp_path: Path) -> None: + """Selecting stems zeroes the unselected stems' frames end to end. + + The loaded document projects the selection: each channel's waveform carries only the + selected stems' frames, the partials sum them, the original plays the selected + recordings, and a full selection answers the unfiltered audio. + """ + config = three_stem_reconstruction_config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + stems_config = three_stem_config() + paths = write_three_stem_recordings(config, tmp_path) + reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + assert reconstruction is not None + + save_path = tmp_path / "three_stems.stn" + reconstruction.save(save_path) + data = ReconstructionData.load(save_path) + stems_data = data.reconstruction.stems_data + assert stems_data is not None + + frame_length = config.library.frame_length + all_stem_ids = {entry.id for entry in stems_data.config.entries} + channels = list(stems_data.assignments_by_channel) + + def masked_channels(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]: + """The per-channel ground truth: the frames whose recorded stem id is selected.""" + masked: Dict[ChannelName, np.ndarray] = {} + for channel, stem_ids in stems_data.assignments_by_channel.items(): + channel_audio = data.reconstruction.approximations[channel] + frames = np.zeros_like(channel_audio) + for frame_index, stem_id in enumerate(stem_ids): + if stem_id in selected: + start = frame_index * frame_length + frames[start : start + frame_length] = channel_audio[start : start + frame_length] + masked[channel] = frames + return masked + + unfiltered = data.waveform_data() + full = data.waveform_data(frozenset(all_stem_ids)) + np.testing.assert_allclose(full.approximation, unfiltered.approximation, atol=_MIX_TOLERANCE) + np.testing.assert_allclose(full.original_audio, unfiltered.original_audio, atol=_MIX_TOLERANCE) + np.testing.assert_allclose( + data.partials_for(channels, frozenset(all_stem_ids)), + data.get_partials(channels), + atol=_MIX_TOLERANCE, + ) + + for selected_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID): + selected = frozenset({selected_id}) + expected = masked_channels(selected) + waveform = data.waveform_data(selected) + + for channel, expected_audio in expected.items(): + np.testing.assert_array_equal(waveform.approximations[channel], expected_audio) + np.testing.assert_allclose(waveform.approximation, mix(list(expected.values())), atol=_MIX_TOLERANCE) + np.testing.assert_allclose( + data.partials_for(channels, selected), + mix(list(expected.values())), + atol=_MIX_TOLERANCE, + ) + np.testing.assert_allclose( + data.original_mix_for(selected), data.stem_audios[selected_id], atol=_MIX_TOLERANCE + ) + + selected = frozenset() + waveform = data.waveform_data(selected) + for channel in stems_data.assignments_by_channel: + np.testing.assert_array_equal( + waveform.approximations[channel], + np.zeros_like(data.reconstruction.approximations[channel]), + ) + np.testing.assert_allclose(waveform.approximation, np.zeros_like(data.reconstruction.approximation)) + np.testing.assert_allclose( + data.partials_for(channels, selected), + np.zeros_like(data.reconstruction.approximation), + ) + np.testing.assert_allclose( + data.original_mix_for(selected), + np.zeros_like(data.reconstruction.approximation), + ) + + class TestStemsOriginalAudio: def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> None: config = Config() diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index d1b9768b9..f72361893 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -59,7 +59,7 @@ def reconstruction_data(default_config, minimal_reconstruction) -> Reconstructio return ReconstructionData( config=default_config, reconstruction=minimal_reconstruction, - original_audio=np.zeros(256, dtype=np.float32), + stem_audios=(), feature_data=feature_data, filepath=Path("/dev/null"), name="null", diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 8439cdcb3..60e998818 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -7,7 +7,12 @@ from sampletones_core.audio import load_audio, mix, write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry class TestFromReconstruction: @@ -264,8 +269,121 @@ def test_reuses_the_already_loaded_original_audio( copy = data.detached_copy(tmp_path / "lead.stn") + assert copy.stem_audios is data.stem_audios assert data.original_audio is not None - assert copy.original_audio is data.original_audio + np.testing.assert_allclose(copy.original_audio, data.original_audio) + + +class TestStemFilteredProjections: + def _stems_data( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + config = Config() + frame_length = config.library.frame_length + length = 2 * frame_length + approximation = np.arange(length, dtype=np.float32) + stems_config = StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.PULSE1]), + ], + ) + reconstruction = Reconstruction.create( + approximation=approximation, + approximations={ChannelName.PULSE1: approximation.copy()}, + instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] * 2}, + config=config, + coefficient=1.0, + audio_filepath=(tmp_path / "a.wav", tmp_path / "b.wav"), + stems_data=StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0, 1], + ) + ], + ), + ) + return ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + def test_partials_keep_only_the_selected_stems_frames( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._stems_data(reconstruction_factory, tmp_path) + frame_length = data.reconstruction.config.frame_length + expected = data.reconstruction.approximation.copy() + expected[frame_length:] = 0 + + partials = data.partials_for([ChannelName.PULSE1], frozenset({0})) + + np.testing.assert_allclose(partials, expected) + np.testing.assert_allclose( + data.partials_for([ChannelName.PULSE1], frozenset({0, 1})), + data.reconstruction.approximation, + ) + + def test_original_mix_mixes_the_selected_recordings( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + first = tmp_path / "kick.wav" + second = tmp_path / "snare.wav" + write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + write_wave(second, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.25) + stems_config = StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.PULSE1]), + ], + ) + reconstruction = reconstruction_factory().model_copy( + update={ + "audio_filepath": (first, second), + "stems_data": StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0, 1], + ) + ], + ), + } + ) + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + np.testing.assert_allclose(data.original_mix_for(frozenset({0})), data.stem_audios[0]) + assert data.original_audio is not None + np.testing.assert_allclose(data.original_mix_for(frozenset({0, 1})), data.original_audio) + np.testing.assert_array_equal( + data.original_mix_for(frozenset()), + np.zeros_like(data.reconstruction.approximation), + ) + + def test_a_single_source_with_no_selection_is_silence( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + source_audio = tmp_path / "source.wav" + write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio}) + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + np.testing.assert_array_equal( + data.partials_for([ChannelName.PULSE1], frozenset()), + np.zeros_like(data.reconstruction.approximation), + ) + assert data.original_audio is not None + np.testing.assert_allclose(data.original_mix_for(frozenset({0})), data.original_audio) class TestWaveformData: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 7e41399e3..c3a0fd449 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -22,6 +22,10 @@ from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.instructions import TriangleInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends from sampletones_shared.paths.extensions import ( @@ -870,3 +874,100 @@ def test_missing_audio_file_fires_on_locate_audio_not_found( panel_logic.on_locate_audio_not_found = callback panel_logic.handle_locate_original_audio() callback.assert_called_once_with(missing) + + +class TestReconstructionPanelLogicStemSelection: + @pytest.fixture(name="stems_data") + def stems_data_fixture( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + reconstruction = reconstruction_factory() + frame_count = len(reconstruction.approximations[ChannelName.PULSE1]) // reconstruction.config.frame_length + stems_config = StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.PULSE1]), + ], + ) + stems_reconstruction = reconstruction.model_copy( + update={ + "audio_filepath": (tmp_path / "a.wav", tmp_path / "b.wav"), + "stems_data": StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0, 1] * (frame_count // 2) + ([0] if frame_count % 2 else []), + ) + ], + ), + } + ) + return ReconstructionData.from_reconstruction(stems_reconstruction, name="Sample") + + def test_display_selects_every_stem( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = stems_data + stems_views = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + assert panel_logic._selected_stems == frozenset({0, 1}) + assert len(stems_views) == 1 + assert {row.stem_id for row in stems_views[0].stems} == {0, 1} + assert all(row.selected for row in stems_views[0].stems) + assert stems_views[0].hierarchy_mode is None or stems_views[0].show_setup_line + + def test_set_selected_stems_filters_waveform_and_playback( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = stems_data + panel_logic.display_reconstruction() + waveform_updates = [] + audio_updates = [] + panel_logic.on_waveform_load_changed = lambda waveform, channels: waveform_updates.append(waveform) + panel_logic.on_audio_data_changed = lambda audio: audio_updates.append(audio) + + panel_logic.set_selected_stems(frozenset({0})) + + expected = stems_data.partials_for( + panel_logic._selected_channels, + frozenset({0}), + ) + assert len(waveform_updates) == 1 + np.testing.assert_allclose(waveform_updates[0].partials(panel_logic._selected_channels), expected) + assert len(audio_updates) == 1 + assert audio_updates[0] is not None + np.testing.assert_allclose(audio_updates[0].sample, expected) + + def test_export_wav_uses_the_stems_filter( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + mock_export_service: MagicMock, + stems_data: ReconstructionData, + tmp_path: Path, + ) -> None: + mock_reconstruction_manager.current_reconstruction = stems_data + panel_logic.display_reconstruction() + panel_logic.set_selected_stems(frozenset({0})) + + panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") + + mock_export_service.export_wav.assert_called_once() + exported_audio = mock_export_service.export_wav.call_args.args[2] + expected = stems_data.partials_for( + panel_logic._selected_channels, + frozenset({0}), + ) + np.testing.assert_allclose(exported_audio, expected) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py new file mode 100644 index 000000000..ee9d5d1ee --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -0,0 +1,183 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, +) +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.ui.panels.reconstruction.stems import ( + GUIReconstructionStemsPanel, +) +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.reconstruction.stems import ( + ReconstructionStemsViewModel, + StemViewModel, +) +from sampletones_core.constants.enums import ChannelName, HierarchyMode + +ROOT_TAG = "test_root" + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: + """Stands up the context, fonts, themes, and section-header geometry the panel resolves on construction.""" + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) + GUIPanel.configure_section_header( + layout_config.glyphs, + layout_config.general.section_header, + layout_config.general.collapse, + ) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture +def panel(dpg_context: None) -> GUIReconstructionStemsPanel: + return GUIReconstructionStemsPanel(language_manager=LanguageManager(LANG_EN)) + + +def render(panel: GUIReconstructionStemsPanel) -> None: + with dpg.window(tag=ROOT_TAG): + panel.create_panel(ROOT_TAG) + + +def _stem_row(stem_id: int, *, label: str, selected: bool, enabled: bool) -> StemViewModel: + return StemViewModel( + stem_id=stem_id, + label=label, + channels=(ChannelName.PULSE1, ChannelName.NOISE), + enabled=enabled, + selected=selected, + ) + + +def _view_model(*rows: StemViewModel, hierarchy_mode=None) -> ReconstructionStemsViewModel: + return ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=rows, + hierarchy_mode=hierarchy_mode, + channel_cap=2 if hierarchy_mode is not None else None, + ) + + +class TestStemsPanelRows: + def test_one_checkbox_row_per_stem(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + + panel.update_view( + _view_model( + _stem_row(0, label="kick.wav", selected=True, enabled=True), + _stem_row(1, label="snare.wav", selected=True, enabled=True), + ) + ) + + kick_tag = GUIReconstructionStemsPanel._stem_checkbox_tag(0) + snare_tag = GUIReconstructionStemsPanel._stem_checkbox_tag(1) + assert dpg.does_item_exist(kick_tag) + assert dpg.does_item_exist(snare_tag) + assert dpg.get_value(kick_tag) + assert dpg.get_value(snare_tag) + assert dpg.get_item_label(kick_tag) == "kick.wav" + + def test_a_stem_without_assigned_frames_is_disabled(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + + panel.update_view( + _view_model( + _stem_row(0, label="kick.wav", selected=False, enabled=False), + ) + ) + + assert not dpg.is_item_enabled(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) + + def test_rows_follow_a_changed_stem_set(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + panel.update_view( + _view_model( + _stem_row(0, label="kick.wav", selected=True, enabled=True), + _stem_row(1, label="snare.wav", selected=True, enabled=True), + ) + ) + + panel.update_view( + _view_model( + _stem_row(1, label="snare.wav", selected=True, enabled=True), + ) + ) + + assert not dpg.does_item_exist(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) + assert dpg.does_item_exist(GUIReconstructionStemsPanel._stem_checkbox_tag(1)) + + +class TestStemsPanelSelection: + def test_unchecking_a_stem_reports_the_remaining_selection(self, panel: GUIReconstructionStemsPanel) -> None: + selections = [] + panel.on_stems_changed = selections.append + render(panel) + panel.update_view( + _view_model( + _stem_row(0, label="kick.wav", selected=True, enabled=True), + _stem_row(1, label="snare.wav", selected=True, enabled=True), + ) + ) + + kick_tag = GUIReconstructionStemsPanel._stem_checkbox_tag(0) + dpg.set_value(kick_tag, False) + dpg.get_item_callback(kick_tag)(kick_tag, False) + + assert selections == [frozenset({1})] + + +class TestStemsPanelStates: + def test_the_setup_line_states_mode_and_cap(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + + panel.update_view( + _view_model( + _stem_row(0, label="kick.wav", selected=True, enabled=True), + hierarchy_mode=HierarchyMode.STRICT, + ) + ) + + assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) + assert "Strict" in dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) + assert "2" in dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) + + def test_the_empty_state_shows_for_a_loaded_reconstruction_without_source( + self, panel: GUIReconstructionStemsPanel + ) -> None: + render(panel) + + panel.update_view(_view_model()) + + assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY) + assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/__init__.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py new file mode 100644 index 000000000..514f516d7 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py @@ -0,0 +1,38 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: + """Stands up the context, fonts and themes a dialog resolves on construction, as startup does.""" + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py new file mode 100644 index 000000000..7dc40610e --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py @@ -0,0 +1,105 @@ +from pathlib import Path +from typing import Final, List +from unittest.mock import MagicMock + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_BUTTON_CANCEL, + SUF_BUTTON_OK, + SUF_CHECKBOX, + TAG_GLOBAL_DIALOG_PATH_MESSAGE, +) +from sampletones_application.utils.gui.dialogs import get_dialog_tag +from sampletones_application.utils.gui.dialogs.windows.confirmation import ( + GUIConfirmationWindow, +) +from sampletones_application.utils.gui.keyboard import KeyRouter +from tests.suite.shortcuts import shipped_source + +WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) +CONFIRMED: Final[str] = "confirmed" +CANCELLED: Final[str] = "cancelled" +OPTED_OUT: Final[str] = "opted_out" + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIConfirmationWindow: + return GUIConfirmationWindow( + tag=WINDOW_TAG, + width=layout_config.general.dialogs.default.width, + height=layout_config.general.dialogs.confirmation.height, + wrap=layout_config.general.dialogs.default.width - 10, + path_color=layout_config.general.colors.paths.default, + path_hover_color=layout_config.general.colors.paths.hover, + path_message="path", + status_bar=MagicMock(), + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def render( + window: GUIConfirmationWindow, + *, + path: Path | None = None, + opt_out_label: str | None = None, + answers: List[str] | None = None, +) -> None: + """Builds the prompt for the given question, the way ``show`` does without a live frame.""" + window.prepare( + "Save it?", + "Title", + lambda: answers.append(CONFIRMED) if answers is not None else None, + ok_label="Yes", + cancel_label="No", + path=path, + opt_out_label=opt_out_label, + on_opt_out=lambda: answers.append(OPTED_OUT) if answers is not None else None, + on_cancel=lambda: answers.append(CANCELLED) if answers is not None else None, + ) + window.create_window() + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +class TestConfirmationWindow: + def test_ok_runs_the_confirmation_and_closes(self, window: GUIConfirmationWindow) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + + assert answers == [CONFIRMED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_cancel_runs_the_negative_answer_and_closes(self, window: GUIConfirmationWindow) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL)) + + assert answers == [CANCELLED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_a_ticked_opt_out_rides_the_confirmation(self, window: GUIConfirmationWindow) -> None: + answers: List[str] = [] + render(window, opt_out_label="Do not ask again", answers=answers) + dpg.set_value(compose_tag(WINDOW_TAG, SUF_CHECKBOX), True) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + + assert answers == [OPTED_OUT, CONFIRMED] + + def test_the_path_is_shown_when_given(self, window: GUIConfirmationWindow, tmp_path: Path) -> None: + path = tmp_path / "song.stn" + + render(window, path=path) + + assert dpg.does_item_exist(compose_tag(WINDOW_TAG, "path")) diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py new file mode 100644 index 000000000..d65bb9bfd --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py @@ -0,0 +1,73 @@ +from typing import Final + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_BUTTON_OK, + SUF_BUTTON_SHOW_TRACEBACK, + SUF_GROUP, + TAG_GLOBAL_DIALOG_ERROR, +) +from sampletones_application.utils.gui.dialogs import get_dialog_tag +from sampletones_application.utils.gui.dialogs.windows.error import ( + GUIErrorDialogWindow, +) +from sampletones_application.utils.gui.keyboard import KeyRouter +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIErrorDialogWindow: + return GUIErrorDialogWindow( + tag=WINDOW_TAG, + width=layout_config.general.dialogs.error.width, + height=layout_config.general.dialogs.error.height, + wrap=layout_config.general.dialogs.error.width - 10, + language_manager=LANGUAGE_MANAGER, + error_color=layout_config.general.colors.text.error, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def render(window: GUIErrorDialogWindow, message: str = "context") -> None: + """Builds the widget tree for the given failure, the way ``show`` does without a live frame.""" + window.prepare(RuntimeError("boom"), message) + window.create_window() + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +class TestErrorWindow: + def test_reports_the_exception_name_and_text(self, window: GUIErrorDialogWindow) -> None: + render(window) + + texts = [dpg.get_value(item) for item in dpg.get_item_children(compose_tag(WINDOW_TAG, SUF_GROUP), 1)] + assert any(text.startswith("RuntimeError") for text in texts) + assert "boom" in texts + + def test_the_traceback_toggle_flips_its_label(self, window: GUIErrorDialogWindow) -> None: + render(window) + show_inner_tag = compose_tag(WINDOW_TAG, SUF_BUTTON_SHOW_TRACEBACK, SUF_BUTTON) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_SHOW_TRACEBACK)) + + assert dpg.get_item_label(show_inner_tag) == LANGUAGE_MANAGER["global.traceback.label.hide"] + + def test_ok_dismisses_the_prompt(self, window: GUIErrorDialogWindow) -> None: + render(window) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + + assert not dpg.does_item_exist(WINDOW_TAG) diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py new file mode 100644 index 000000000..55458c17a --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -0,0 +1,96 @@ +from typing import Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_BUTTON_CANCEL, + SUF_BUTTON_OK, + SUF_BUTTON_SAVE, + TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, +) +from sampletones_application.utils.gui.dialogs import get_dialog_tag +from sampletones_application.utils.gui.dialogs.windows.save_confirmation import ( + GUISaveConfirmationWindow, +) +from sampletones_application.utils.gui.keyboard import KeyRouter +from tests.suite.shortcuts import shipped_source + +WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) +CONFIRMED: Final[str] = "confirmed" + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUISaveConfirmationWindow: + return GUISaveConfirmationWindow( + tag=WINDOW_TAG, + width=layout_config.general.dialogs.default.width, + height=layout_config.general.dialogs.confirmation.height, + wrap=layout_config.general.dialogs.default.width - 10, + save_label="Save", + cancel_label="Cancel", + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def render( + window: GUISaveConfirmationWindow, + *, + save_succeeds: bool, + answers: List[str], +) -> None: + """Builds the prompt for the given save, the way ``show`` does without a live frame.""" + window.prepare( + "Save first?", + "Title", + lambda: save_succeeds, + lambda: answers.append(CONFIRMED), + ok_label="Proceed", + ) + window.create_window() + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +class TestSaveConfirmationWindow: + def test_a_cancelled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None: + answers: List[str] = [] + render(window, save_succeeds=False, answers=answers) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_SAVE)) + + assert answers == [] + assert dpg.does_item_exist(WINDOW_TAG) + + def test_a_completed_save_proceeds_and_closes(self, window: GUISaveConfirmationWindow) -> None: + answers: List[str] = [] + render(window, save_succeeds=True, answers=answers) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_SAVE)) + + assert answers == [CONFIRMED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_the_middle_button_proceeds_without_saving(self, window: GUISaveConfirmationWindow) -> None: + answers: List[str] = [] + render(window, save_succeeds=False, answers=answers) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK)) + + assert answers == [CONFIRMED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_cancel_dismisses_the_prompt(self, window: GUISaveConfirmationWindow) -> None: + answers: List[str] = [] + render(window, save_succeeds=False, answers=answers) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL)) + + assert answers == [] + assert not dpg.does_item_exist(WINDOW_TAG) diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 715abb8f9..6692860c9 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -12,6 +12,10 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.stems import ( + ReconstructionStemsViewModel, +) +from sampletones_core.constants.enums import HierarchyMode @dataclass(frozen=True) @@ -126,3 +130,30 @@ def test_several_paths_are_multiple(self) -> None: paths = (Path("/a/one.wav"), Path("/b/two.wav")) assert ReconstructionPathState.from_source_paths(paths) is ReconstructionPathState.MULTIPLE + + +class TestReconstructionStemsViewModel: + def test_the_setup_line_follows_the_stems_record(self) -> None: + stems = ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=(), + hierarchy_mode=HierarchyMode.STRICT, + channel_cap=2, + ) + + assert stems.show_setup_line + assert stems.channel_cap == 2 + + def test_the_empty_state_names_a_loaded_reconstruction_with_no_source(self) -> None: + loaded = ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=(), + ) + closed = ReconstructionStemsViewModel( + reconstruction_loaded=False, + stems=(), + ) + + assert loaded.show_empty_state + assert not loaded.show_setup_line + assert not closed.show_empty_state diff --git a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py index be2ac965c..b6ae1186f 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py +++ b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py @@ -60,3 +60,33 @@ def test_unknown_generators_are_skipped_within_a_selection( result = waveform_data.partials([ChannelName.PULSE1, ChannelName.TRIANGLE]) assert np.array_equal(result, approximations[ChannelName.PULSE1]) + + def test_short_generators_leave_the_tail_in_silence(self) -> None: + approximation = np.array([1.0, 0.0, -1.0, 0.0]) + data = WaveformData( + original_audio=approximation, + approximation=approximation, + approximations={ChannelName.PULSE1: np.array([0.5, 0.5]), ChannelName.NOISE: approximation}, + coefficient=1.0, + frame_length=2, + ) + + result = data.partials([ChannelName.PULSE1, ChannelName.NOISE]) + + expected = np.array([1.5, 0.5, -1.0, 0.0]) + assert np.array_equal(result, expected) + + def test_a_short_single_generator_reaches_the_approximation_length(self) -> None: + approximation = np.array([1.0, 0.0, -1.0, 0.0]) + data = WaveformData( + original_audio=approximation, + approximation=approximation, + approximations={ChannelName.PULSE1: np.array([0.5, 0.5])}, + coefficient=1.0, + frame_length=2, + ) + + result = data.partials([ChannelName.PULSE1]) + + expected = np.array([0.5, 0.5, 0.0, 0.0]) + assert np.array_equal(result, expected) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py new file mode 100644 index 000000000..6488dc66a --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -0,0 +1,107 @@ +from typing import Final, List, Tuple + +import numpy as np + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.filter import ( + filter_approximations, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry + +FRAME_LENGTH: Final[int] = 2 + + +def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: + entries = [StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)] + return StemsData( + config=StemsConfig(entries=entries), + assignments=[ChannelAssignment(channel_name=channel, stem_ids=stem_ids) for channel, stem_ids in stem_lists], + ) + + +class TestFilterApproximations: + def test_unselected_frames_become_silence(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 1, 0, 2])) + approximations = { + ChannelName.PULSE1: np.arange(8, dtype=np.float32), + } + + filtered = filter_approximations( + stems_data, + approximations, + {0, 2}, + FRAME_LENGTH, + ) + + expected = np.array([0, 1, 0, 0, 4, 5, 6, 7], dtype=np.float32) + np.testing.assert_array_equal(filtered[ChannelName.PULSE1], expected) + assert len(filtered[ChannelName.PULSE1]) == len(approximations[ChannelName.PULSE1]) + + def test_every_selected_stem_answers_the_original_arrays(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 1, 2])) + approximations = { + ChannelName.PULSE1: np.arange(6, dtype=np.float32), + } + + filtered = filter_approximations( + stems_data, + approximations, + {0, 1, 2}, + FRAME_LENGTH, + ) + + np.testing.assert_array_equal(filtered[ChannelName.PULSE1], approximations[ChannelName.PULSE1]) + + def test_no_selected_stem_is_silence(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 1])) + approximations = { + ChannelName.PULSE1: np.ones(4, dtype=np.float32), + } + + filtered = filter_approximations( + stems_data, + approximations, + set(), + FRAME_LENGTH, + ) + + np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.zeros(4, dtype=np.float32)) + + def test_the_stored_arrays_keep_their_samples(self) -> None: + stems_data = _stems_data((ChannelName.PULSE1, [0, 1])) + approximations = { + ChannelName.PULSE1: np.ones(4, dtype=np.float32), + } + + filter_approximations( + stems_data, + approximations, + {0}, + FRAME_LENGTH, + ) + + np.testing.assert_array_equal(approximations[ChannelName.PULSE1], np.ones(4, dtype=np.float32)) + + def test_channels_the_stems_data_names_come_back_filtered(self) -> None: + stems_data = _stems_data( + (ChannelName.PULSE1, [0]), + (ChannelName.NOISE, [1]), + ) + approximations = { + ChannelName.PULSE1: np.ones(2, dtype=np.float32), + ChannelName.NOISE: np.ones(2, dtype=np.float32), + } + + filtered = filter_approximations( + stems_data, + approximations, + {1}, + FRAME_LENGTH, + ) + + assert set(filtered) == {ChannelName.PULSE1, ChannelName.NOISE} + np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.zeros(2, dtype=np.float32)) + np.testing.assert_array_equal(filtered[ChannelName.NOISE], np.ones(2, dtype=np.float32)) From e5841003b3c113c78b800f91dcf4f3d9e0d7f236 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 07:19:32 +0200 Subject: [PATCH 028/142] Added: application NSF export option --- README.md | 2 +- docs/guide/files.md | 22 +++--- docs/guide/interface.md | 3 +- docs/index.md | 4 +- src/sampletones_application/application.py | 8 +-- .../categories/elements/global_.py | 2 + .../categories/elements/settings.py | 1 + .../categories/exports.py | 3 + src/sampletones_application/exports.py | 23 +++++++ .../utils/gui/shortcuts/ids.py | 2 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 3 + .../services/test_export.py | 67 +++++++++++++++++++ .../categories/test_exports.py | 8 +-- .../reconstruction/test_reconstruction.py | 6 +- .../sampletones_application/ui/test_menu.py | 7 +- 17 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 src/sampletones_application/exports.py diff --git a/README.md b/README.md index d80a99a01..170ba6561 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ It supports: * `pulse2` * `triangle` * `noise` -* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, or `.wav` +* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, `.nsf` programs the NES itself plays, or `.wav` ## Installation diff --git a/docs/guide/files.md b/docs/guide/files.md index 06ec3d403..7c15b2c5b 100644 --- a/docs/guide/files.md +++ b/docs/guide/files.md @@ -28,27 +28,31 @@ You can point the library and output folders elsewhere from the **Main** tab's | `.ftm` | FamiTracker module (exported) | wherever you choose | | `.json` | Bitphase instrument preset (exported) | wherever you choose | | `.btp` | Bitphase project (exported) | wherever you choose | +| `.nsf` | NES sound file (exported) | wherever you choose | The `.fti` and `.ftm` files are what you load into -[FamiTracker](../formats/famitracker.md), and `.json` and `.btp` are what -[Bitphase](../formats/bitphase.md) reads; the rest are _SampleToNES_'s own formats. +[FamiTracker](../formats/famitracker.md), `.json` and `.btp` are what +[Bitphase](../formats/bitphase.md) reads, and an `.nsf` plays on its own in a NES +sound player or on the console; the rest are _SampleToNES_'s own formats. ## Exported files -The extension names the tracker an export is written for: `.fti` and `.ftm` go to -FamiTracker, `.json` and `.btp` to Bitphase. The save dialog offers the file types -that fit what you are exporting and fills in the extension of the type it is set to. -Exporting one channel offers both trackers, so switching the type there switches the -tracker; typing an extension yourself picks the tracker directly. +The extension names what an export is written for: `.fti` and `.ftm` go to +FamiTracker, `.json` and `.btp` to Bitphase, and `.nsf` to a NES sound player. The +save dialog offers the file types that fit what you are exporting and fills in the +extension of the type it is set to. Exporting one channel offers all three, so +switching the type there switches the target; typing an extension yourself picks it +directly. What you name in the dialog also names what a tracker lists: | Export | You name | What is written | | --- | --- | --- | | **Instruments** panel ▸ **Export instrument...** | the file | that file, its instrument carrying the name you gave | -| **Reconstruction ▸ Export instruments** | the batch | one file per channel beside that name, each named ` (channel)` | +| **Reconstruction ▸ Export instruments** | the batch | one file per channel beside that name, each named ` (channel)`, or a single file at that name for `.nsf` | | **File ▸ Export** | the file | that file, holding the whole song | So exporting a `Kick` reconstruction to FamiTracker instruments writes `Kick (pulse1).fti`, `Kick (triangle).fti`, and one file for every other channel the -reconstruction uses, all in the folder you chose. +reconstruction uses, all in the folder you chose. An `.nsf` gathers every channel into +one program, so it is the single file you named. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 948dfb9f5..9377a9a91 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -63,7 +63,8 @@ left it the next time you start the application. To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase -presets...** writes the same as `.json`, and **Export to WAV...** renders the +presets...** writes the same as `.json`, **NSF program...** writes a single `.nsf` +that plays the whole reconstruction on a NES, and **Export to WAV...** renders the audio. To use the reconstruction in a song, right-click it and choose **Add to Sequencer** (see the [sequencer guide](sequencer.md)). diff --git a/docs/index.md b/docs/index.md index dd12a7496..57e145fd1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,8 +2,8 @@ _SampleToNES_ approximates an audio sample using only the sound channels of the NES's 2A03 chip — two pulse waves, a triangle and noise — and lets you arrange -the results into a song and export them to [FamiTracker](glossary.md#famitracker) -or [Bitphase](glossary.md#bitphase). +the results into a song and export them to [FamiTracker](glossary.md#famitracker), +to [Bitphase](glossary.md#bitphase), or as an `.nsf` program the console itself plays. This is the documentation for using it, understanding how it works, and building on it. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ceb0aacef..6b9230eb8 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -33,6 +33,7 @@ ReconstructionTabCoordinator, ) from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator +from sampletones_application.exports import build_export_backends from sampletones_application.layout import LayoutConfig, load_layout_config from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager @@ -144,12 +145,10 @@ from sampletones_core.exporters import Features from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.registry import build_tracker_backends from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode from sampletones_core.types.feature import FeatureValue -from sampletones_player.export import NSFBackend from sampletones_shared.application import ( SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, @@ -240,10 +239,7 @@ def __init__( self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) - self.export_backends: Dict[ExportFormat, ExportBackend] = { - **build_tracker_backends(), - ExportFormat.NSF: NSFBackend(), - } + self.export_backends: Dict[ExportFormat, ExportBackend] = build_export_backends() self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 42ec652b4..4b8598e0b 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -94,6 +94,7 @@ class MenuElements(AbstractElement): GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS = "group_reconstruction_export_instruments" ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER = "item_reconstruction_export_instruments_famitracker" ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET = "item_reconstruction_export_instruments_bitphase_preset" + ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF = "item_reconstruction_export_instruments_nsf" GROUP_PLAYBACK = "group_playback" ITEM_PLAYBACK_PLAY = "item_playback_play" ITEM_PLAYBACK_PAUSE = "item_playback_pause" @@ -242,6 +243,7 @@ class FileFilterElements(AbstractElement): FAMITRACKER_INSTRUMENT = "famitracker_instrument" BITPHASE_PROJECT = "bitphase_project" BITPHASE_PRESET = "bitphase_preset" + NSF = "nsf" CONFIG = "config" AUDIO = "audio" WAVE = "wave" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index ec4b1af23..0d8455013 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -57,6 +57,7 @@ class KeybindingActionElements(AbstractElement): EXPORT_RECONSTRUCTION_WAV = "export_reconstruction_wav" EXPORT_INSTRUMENTS_FAMITRACKER = "export_instruments_famitracker" EXPORT_INSTRUMENTS_BITPHASE_PRESET = "export_instruments_bitphase_preset" + EXPORT_INSTRUMENTS_NSF = "export_instruments_nsf" ADD_RECONSTRUCTION_TO_SEQUENCER = "add_reconstruction_to_sequencer" OPEN_RECONSTRUCTION_IN_EXPLORER = "open_reconstruction_in_explorer" LOCATE_ORIGINAL_AUDIO = "locate_original_audio" diff --git a/src/sampletones_application/categories/exports.py b/src/sampletones_application/categories/exports.py index b2aac8c37..39ea25652 100644 --- a/src/sampletones_application/categories/exports.py +++ b/src/sampletones_application/categories/exports.py @@ -53,14 +53,17 @@ class ExportProjectElements: INSTRUMENT_EXPORT_FORMATS: Final[Tuple[ExportFormat, ...]] = ( ExportFormat.FAMITRACKER, ExportFormat.BITPHASE_PRESET, + ExportFormat.NSF, ) EXPORT_INSTRUMENT_FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = { ExportFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT, ExportFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET, + ExportFormat.NSF: FileFilterElements.NSF, } EXPORT_SAMPLE_MENU_LABELS: Final[Dict[ExportFormat, MenuElements]] = { ExportFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, ExportFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, + ExportFormat.NSF: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF, } diff --git a/src/sampletones_application/exports.py b/src/sampletones_application/exports.py new file mode 100644 index 000000000..f36e433d9 --- /dev/null +++ b/src/sampletones_application/exports.py @@ -0,0 +1,23 @@ +from typing import Dict + +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends +from sampletones_player.export import NSFBackend + + +def build_export_backends() -> Dict[ExportFormat, ExportBackend]: + """Builds every backend the application exports through. + + The reconstruction engine stands below the console player and owns the tracker formats + alone, so the backend writing a program the console runs joins them here, where both + packages are in reach. Everything the application offers to export in is keyed by its + format in the result, so a menu entry, a file type and a shortcut all reach one backend. + + Returns: + Dict[ExportFormat, ExportBackend]: Every backend, keyed by the format it writes. + """ + return { + **build_tracker_backends(), + ExportFormat.NSF: NSFBackend(), + } diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index dbf32c562..bb4862f90 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -64,6 +64,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: EXPORT_RECONSTRUCTION_WAV = ("ExportReconstructionWav", ShortcutCategory.APPLICATION) EXPORT_INSTRUMENTS_FAMITRACKER = ("ExportInstrumentsFamiTracker", ShortcutCategory.APPLICATION) EXPORT_INSTRUMENTS_BITPHASE_PRESET = ("ExportInstrumentsBitphasePreset", ShortcutCategory.APPLICATION) + EXPORT_INSTRUMENTS_NSF = ("ExportInstrumentsNSF", ShortcutCategory.APPLICATION) ADD_RECONSTRUCTION_TO_SEQUENCER = ("AddReconstructionToSequencer", ShortcutCategory.APPLICATION) OPEN_RECONSTRUCTION_IN_EXPLORER = ("OpenReconstructionInExplorer", ShortcutCategory.APPLICATION) LOCATE_ORIGINAL_AUDIO = ("LocateOriginalAudio", ShortcutCategory.APPLICATION) @@ -228,4 +229,5 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = { ExportFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, ExportFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, + ExportFormat.NSF: ShortcutId.EXPORT_INSTRUMENTS_NSF, } diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index e373d87a1..492c6ad4f 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -29,6 +29,7 @@ bindings: ExportReconstructionWav: {combination: "Ctrl+E"} ExportInstrumentsFamiTracker: {combination: "Ctrl+I"} ExportInstrumentsBitphasePreset: {combination: ~} + ExportInstrumentsNSF: {combination: ~} AddReconstructionToSequencer: {combination: ~} OpenReconstructionInExplorer: {combination: ~} LocateOriginalAudio: {combination: ~} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 207731372..e0e69fed5 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -29,6 +29,7 @@ bindings: ExportReconstructionWav: {combination: "Cmd+E"} ExportInstrumentsFamiTracker: {combination: "Cmd+I"} ExportInstrumentsBitphasePreset: {combination: ~} + ExportInstrumentsNSF: {combination: ~} AddReconstructionToSequencer: {combination: ~} OpenReconstructionInExplorer: {combination: ~} LocateOriginalAudio: {combination: ~} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 56bd4fbfc..eb7edbd73 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -52,6 +52,7 @@ global.dialog.filter.module: "FamiTracker module" global.dialog.filter.famitracker_instrument: "FamiTracker instrument" global.dialog.filter.bitphase_project: "Bitphase project" global.dialog.filter.bitphase_preset: "Bitphase instrument preset" +global.dialog.filter.nsf: "NES sound file" global.dialog.filter.config: "Configuration files" global.dialog.filter.audio: "Audio files" global.dialog.filter.wave: "WAV audio" @@ -203,6 +204,7 @@ global.menu.label.item_reconstruction_export_wav: "Export to WAV..." global.menu.label.group_reconstruction_export_instruments: "Export instruments" global.menu.label.item_reconstruction_export_instruments_famitracker: "FamiTracker instruments..." global.menu.label.item_reconstruction_export_instruments_bitphase_preset: "Bitphase presets..." +global.menu.label.item_reconstruction_export_instruments_nsf: "NSF program..." global.menu.label.group_playback: "Playback" global.menu.label.item_playback_play: "Play" global.menu.label.item_playback_pause: "Pause" @@ -770,6 +772,7 @@ settings.keybindings.label.close_reconstruction: "Close reconstruction" settings.keybindings.label.export_reconstruction_wav: "Export reconstruction to WAV" settings.keybindings.label.export_instruments_famitracker: "Export instruments to FamiTracker" settings.keybindings.label.export_instruments_bitphase_preset: "Export instruments to a Bitphase preset" +settings.keybindings.label.export_instruments_nsf: "Export instruments to an NSF program" settings.keybindings.label.add_reconstruction_to_sequencer: "Add reconstruction to the sequencer" settings.keybindings.label.open_reconstruction_in_explorer: "Show reconstruction in the file manager" settings.keybindings.label.locate_original_audio: "Locate the original audio" diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index ce2cc81aa..e7d814f24 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -12,9 +12,13 @@ from sampletones_core.exporters import Features from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_player.export import NSFBackend +from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 +MAX_VOLUME: Final[int] = 15 @pytest.fixture(name="backend") @@ -22,6 +26,23 @@ def backend_fixture() -> FamiTrackerBackend: return FamiTrackerBackend() +@pytest.fixture(name="console_backend") +def console_backend_fixture() -> NSFBackend: + return NSFBackend() + + +def overlong_features(initial_pitch: int) -> Features: + """Envelopes running longer than the console's program area has room for.""" + return Features( + initial_pitch=initial_pitch, + volume=np.full(PROGRAM_SIZE, MAX_VOLUME, dtype=int), + arpeggio=np.zeros(PROGRAM_SIZE, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=np.zeros(PROGRAM_SIZE, dtype=int), + ) + + def instrument_export(name: str, features: Features) -> InstrumentExport: return InstrumentExport( name=name, @@ -177,3 +198,49 @@ def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> No assert list(tmp_path.glob("*.fti")) == [] assert isinstance(results[0], ExportSuccess) + + +class TestExportToTheConsoleIntegration: + """The console player's backend writing through the same service the trackers do.""" + + def test_a_playable_program_is_created_on_disk(self, tmp_path, pulse_features, console_backend) -> None: + export_service = ExportService() + export_service.subscribe(lambda _: None) + + filepath = tmp_path / "sample.nsf" + export_service.export_sample( + filepath, console_backend, sample_export("sample", instrument_export("inst", pulse_features)) + ) + + assert filepath.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC + + def test_the_result_names_the_program_that_was_written(self, tmp_path, pulse_features, console_backend) -> None: + export_service = ExportService() + results: List[Any] = [] + export_service.subscribe(results.append) + + filepath = tmp_path / "sample.nsf" + export_service.export_sample( + filepath, console_backend, sample_export("sample", instrument_export("inst", pulse_features)) + ) + + assert len(results) == 1 + assert isinstance(results[0], ExportSuccess) + assert results[0].kind == ExportKind.SAMPLE + assert results[0].filepath == filepath + + def test_a_reconstruction_outgrowing_the_program_area_is_reported(self, tmp_path, console_backend) -> None: + """The console holds one program in 32 KB, so a reconstruction running past it reaches + the user as a failed export rather than as a file playing part of itself. + """ + export_service = ExportService() + results: List[Any] = [] + export_service.subscribe(results.append) + + filepath = tmp_path / "sample.nsf" + request = sample_export("sample", instrument_export("inst", overlong_features(REFERENCE_PITCH))) + export_service.export_sample(filepath, console_backend, request) + + assert len(results) == 1 + assert isinstance(results[0], ExportError) + assert results[0].kind == ExportKind.SAMPLE diff --git a/tests/unit/sampletones_application/categories/test_exports.py b/tests/unit/sampletones_application/categories/test_exports.py index 55524b380..b3518c506 100644 --- a/tests/unit/sampletones_application/categories/test_exports.py +++ b/tests/unit/sampletones_application/categories/test_exports.py @@ -9,19 +9,19 @@ EXPORT_SAMPLE_MENU_LABELS, INSTRUMENT_EXPORT_FORMATS, ) +from sampletones_application.exports import build_export_backends from sampletones_application.utils.gui.shortcuts.ids import ( PROJECT_EXPORT_SHORTCUT_IDS, SAMPLE_EXPORT_SHORTCUT_IDS, ) from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.registry import build_tracker_backends from sampletones_core.exports.scope import ExportScope @pytest.fixture(name="backends") def backends_fixture() -> Dict[ExportFormat, ExportBackend]: - return build_tracker_backends() + return build_export_backends() def formats_supporting( @@ -32,8 +32,8 @@ def formats_supporting( class TestEveryOfferedFormatHasABackend: - """A menu entry reaches a backend through the registry, so an entry the registry has no - backend for would raise a ``KeyError`` the moment it is chosen.""" + """A menu entry reaches a backend through the registry the composition root builds, so an + entry that registry has no backend for would raise a ``KeyError`` the moment it is chosen.""" @pytest.mark.parametrize( "offered", diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 796650ace..6db46e522 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -6,6 +6,7 @@ import numpy as np import pytest +from sampletones_application.exports import build_export_backends from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.reconstruction import ( @@ -21,7 +22,6 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.registry import build_tracker_backends from sampletones_core.instructions import TriangleInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_shared.music import Tuning @@ -30,6 +30,7 @@ EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_MODULE, + EXT_FILE_NSF, ) from tests.suite.case import BaseRegularTestCase @@ -47,6 +48,7 @@ class FormatCase: FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER), FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE), FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET), + FormatCase(extension=EXT_FILE_NSF, export_format=ExportFormat.NSF), ] UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] @@ -96,7 +98,7 @@ def mock_export_backends() -> Dict[ExportFormat, MagicMock]: the registry's backend declares and leaves only the writing to the mock. """ backends: Dict[ExportFormat, MagicMock] = {} - for export_format, backend in build_tracker_backends().items(): + for export_format, backend in build_export_backends().items(): stub = MagicMock() stub.supported_scopes = backend.supported_scopes stub.extension.side_effect = backend.extension diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index d2aa33864..0e32bc8c1 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -198,10 +198,10 @@ def menu_bar( class TestInstrumentsExportMenu: - """Each tracker that writes a file per slice gets its own item, so choosing the tracker - is one click and the destination dialog then offers that tracker's type alone.""" + """Each offered format gets its own item, so choosing the format is one click and the + destination dialog then offers that format's type alone.""" - def test_every_offered_tracker_is_listed( + def test_every_offered_format_is_listed( self, menu_bar: MenuBar, framework: _DearPyGuiRecorder, @@ -214,6 +214,7 @@ def test_every_offered_tracker_is_listed( assert [entry["label"] for entry in entries] == [ "FamiTracker instruments...", "Bitphase presets...", + "NSF program...", ] def test_the_submenu_waits_for_a_loaded_reconstruction( From 9bca1fe40d203948d50d8d25ba42e4533a078628 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 10:08:57 +0200 Subject: [PATCH 029/142] Loaded: the driver when the NSF export backend is built --- docs/development/packages.md | 2 +- scripts/checks/import_boundary.py | 2 +- scripts/linux/build/build.sh | 1 + scripts/windows/build/build.bat | 1 + src/sampletones/self_check.py | 12 ++++++++ src/sampletones_player/export.py | 18 +++++++++++- src/sampletones_player/nsf/file.py | 20 +++++++++---- tests/integration/nsf/console/session.py | 2 +- tests/integration/nsf/test_nsf_pipeline.py | 17 +++++++---- .../unit/sampletones_player/nsf/test_file.py | 29 +++++++++++-------- 10 files changed, 77 insertions(+), 27 deletions(-) diff --git a/docs/development/packages.md b/docs/development/packages.md index d62f9c79d..294904baa 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -81,7 +81,7 @@ them. | `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | | `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` | -| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files | `builder.py`, `nsf/` | +| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries | `builder.py`, `nsf/`, `driver/` | ### The build toolchain is a developer tool diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 563432990..ec9db55ac 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -73,7 +73,7 @@ "builder.py": ("song.py", "registers", "clock"), "trace": ("song.py", "specification"), "nsf": ("song.py", "registers", "specification", "driver"), - "export.py": ("builder.py", "nsf"), + "export.py": ("builder.py", "nsf", "driver"), "driver": ("specification",), "driver/assembler": ("driver", "specification"), } diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh index 3fd6327e8..198b0acf7 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -44,6 +44,7 @@ echo "Building executable..." --add-data "src/sampletones_assets/icons:assets/icons" \ --add-data "src/sampletones_assets/fonts:assets/fonts" \ --add-data "src/sampletones_config:config" \ + --add-data "src/sampletones_player/driver/binary:sampletones_player/driver/binary" \ --copy-metadata sampletones \ --exclude-module PIL \ "${RELEASE_HOOK_ARGS[@]}" \ diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat index 915dc4713..f2567962c 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -51,6 +51,7 @@ echo Building executable... --add-data "src\sampletones_assets\icons;assets\icons" ^ --add-data "src\sampletones_assets\fonts;assets\fonts" ^ --add-data "src\sampletones_config;config" ^ + --add-data "src\sampletones_player\driver\binary;sampletones_player\driver\binary" ^ --copy-metadata sampletones ^ --exclude-module PIL ^ %RELEASE_HOOK% ^ diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py index ff5bb192d..212a8150b 100644 --- a/src/sampletones/self_check.py +++ b/src/sampletones/self_check.py @@ -126,6 +126,17 @@ def _check_resources() -> str: return f"{len(FontResource)} fonts, {len(IconResource)} icons" +def _check_export_backends() -> str: + """Composes every backend the application exports through. + + A backend reads the resources it writes with as it is built, so this is where a build + shipping without one — the player's assembled driver among them — names what is missing. + """ + from sampletones_application.exports import build_export_backends + + return ", ".join(sorted(build_export_backends())) + + def _check_file_dialog_backend() -> str: from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend @@ -141,6 +152,7 @@ def _check_file_dialog_backend() -> str: SelfCheck(name="themes", run=_check_themes), SelfCheck(name="language", run=_check_language), SelfCheck(name="resources", run=_check_resources), + SelfCheck(name="export backends", run=_check_export_backends), SelfCheck(name="file dialog backend", run=_check_file_dialog_backend), ) diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py index bf026d1cc..e4203a6cd 100644 --- a/src/sampletones_player/export.py +++ b/src/sampletones_player/export.py @@ -10,6 +10,7 @@ ) from sampletones_core.exports.scope import ExportScope from sampletones_player.builder import song_from_sample +from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.file import write_nsf from sampletones_player.nsf.information import NSFInformation from sampletones_shared.paths.extensions import EXT_FILE_NSF @@ -36,8 +37,22 @@ class NSFBackend: The console's program area bounds how long a song may run, and one outgrowing it is reported rather than written short. + + Every file carries the same assembled driver, which the backend reads once as it is built. + A build shipping without it therefore reports itself where the backends are composed, and + an export spends its reads on the song alone. """ + def __init__(self) -> None: + """Reads the driver every written file carries. + + Raises: + OSError: If the packaged driver is absent. + ValueError: If the packaged driver lays out something other than the addresses it + is built to answer at. + """ + self._image = DriverImage.load() + @property def export_format(self) -> ExportFormat: return ExportFormat.NSF @@ -46,7 +61,7 @@ def export_format(self) -> ExportFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: return SUPPORTED_SCOPES - def extension(self, scope: ExportScope) -> str: + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument return EXT_FILE_NSF def write_instrument( @@ -87,6 +102,7 @@ def write_sample( title=request.name, artist=NO_ARTIST, ), + self._image, ) return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) diff --git a/src/sampletones_player/nsf/file.py b/src/sampletones_player/nsf/file.py index b9b597be6..f63b2b71c 100644 --- a/src/sampletones_player/nsf/file.py +++ b/src/sampletones_player/nsf/file.py @@ -8,7 +8,11 @@ from sampletones_shared.utils.serialization import save_binary -def nsf_to_bytes(song: Song, information: NSFInformation) -> bytes: +def nsf_to_bytes( + song: Song, + information: NSFInformation, + image: DriverImage, +) -> bytes: """Builds the bytes of a playable NSF: the header, the driver and the song it plays. The three parts sit in the order the console loads them, the song following the driver at @@ -18,31 +22,35 @@ def nsf_to_bytes(song: Song, information: NSFInformation) -> bytes: Args: song: The streams, the clock and the loop point to play. information: The text fields the file is listed under. + image: The assembled driver the file carries, and the addresses its routines answer at. Returns: bytes: The whole file. Raises: SongTooLargeError: If the song takes more room than the driver leaves it. - ValueError: If the committed driver lays out something other than the addresses it is - built to answer at. """ - image = DriverImage.load() data = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) return header_to_bytes(information, image.addresses) + image.code + data -def write_nsf(filepath: Pathlike, song: Song, information: NSFInformation) -> None: +def write_nsf( + filepath: Pathlike, + song: Song, + information: NSFInformation, + image: DriverImage, +) -> None: """Exports a song to a playable ``.nsf`` file. Args: filepath: The file to write. song: The streams, the clock and the loop point to play. information: The text fields the file is listed under. + image: The assembled driver the file carries, and the addresses its routines answer at. Raises: SongTooLargeError: If the song takes more room than the driver leaves it. OSError: If the destination cannot be written. """ - save_binary(filepath, nsf_to_bytes(song, information)) + save_binary(filepath, nsf_to_bytes(song, information, image)) diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py index 2ed1c5c3f..34c4ddb8f 100644 --- a/tests/integration/nsf/console/session.py +++ b/tests/integration/nsf/console/session.py @@ -55,4 +55,4 @@ def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: Returns: RegisterTrace: The writes of the initialisation and of every play call in the run. """ - return captured_file_trace(nsf_to_bytes(song, information), song) + return captured_file_trace(nsf_to_bytes(song, information, DriverImage.load()), song) diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py index 1cc695fef..3ec153f30 100644 --- a/tests/integration/nsf/test_nsf_pipeline.py +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -44,9 +44,14 @@ def stream_offsets(block: bytes) -> Tuple[int, ...]: @pytest.fixture -def exported(song: Song, sample: Sample, nsf_paths: Dict[str, Path]) -> bytes: +def exported( + song: Song, + sample: Sample, + nsf_paths: Dict[str, Path], + driver_image: DriverImage, +) -> bytes: destination = nsf_paths[sample.name] - write_nsf(destination, song, exported_information(sample.name)) + write_nsf(destination, song, exported_information(sample.name), driver_image) return destination.read_bytes() @@ -57,10 +62,11 @@ def test_every_sample_reaches_a_playable_file( self, instrument_catalog: Dict[str, Sample], nsf_paths: Dict[str, Path], + driver_image: DriverImage, ) -> None: for name, sample in instrument_catalog.items(): song = song_from_reconstruction(sample.reconstruction, loop_tick=None) - write_nsf(nsf_paths[name], song, exported_information(name)) + write_nsf(nsf_paths[name], song, exported_information(name), driver_image) assert nsf_paths[name].read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC def test_the_file_carries_the_shipped_driver(self, exported: bytes, driver_image: DriverImage) -> None: @@ -135,6 +141,7 @@ def test_the_round_trip_leaves_the_exported_bytes_alone( sample: Sample, song: Song, tmp_path: Path, + driver_image: DriverImage, ) -> None: stored = tmp_path / f"{sample.name}{EXT_FILE_RECONSTRUCTION}" sample.reconstruction.save(stored) @@ -143,7 +150,7 @@ def test_the_round_trip_leaves_the_exported_bytes_alone( information = exported_information(sample.name) before = tmp_path / "before.nsf" after = tmp_path / "after.nsf" - write_nsf(before, song, information) - write_nsf(after, reloaded, information) + write_nsf(before, song, information, driver_image) + write_nsf(after, reloaded, information, driver_image) assert after.read_bytes() == before.read_bytes() diff --git a/tests/unit/sampletones_player/nsf/test_file.py b/tests/unit/sampletones_player/nsf/test_file.py index 98375dca8..80d1d4635 100644 --- a/tests/unit/sampletones_player/nsf/test_file.py +++ b/tests/unit/sampletones_player/nsf/test_file.py @@ -52,28 +52,28 @@ class TestNSFBytes: """The three parts a console loads, in the order it loads them.""" def test_the_file_leads_with_its_header(self, song: Song, image: DriverImage) -> None: - assert nsf_to_bytes(song, INFORMATION)[:HEADER_SIZE] == header_to_bytes(INFORMATION, image.addresses) + assert nsf_to_bytes(song, INFORMATION, image)[:HEADER_SIZE] == header_to_bytes(INFORMATION, image.addresses) def test_the_driver_follows_the_header(self, song: Song, image: DriverImage) -> None: - assert nsf_to_bytes(song, INFORMATION)[HEADER_SIZE : HEADER_SIZE + len(image.code)] == image.code + assert nsf_to_bytes(song, INFORMATION, image)[HEADER_SIZE : HEADER_SIZE + len(image.code)] == image.code def test_the_song_follows_the_driver(self, song: Song, image: DriverImage) -> None: - data = nsf_to_bytes(song, INFORMATION) + data = nsf_to_bytes(song, INFORMATION, image) assert data[HEADER_SIZE + len(image.code) :] == song_to_bytes(song, PROGRAM_SIZE - len(image.code)) def test_the_file_is_its_three_parts_and_nothing_more(self, song: Song, image: DriverImage) -> None: block = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) - assert len(nsf_to_bytes(song, INFORMATION)) == HEADER_SIZE + len(image.code) + len(block) + assert len(nsf_to_bytes(song, INFORMATION, image)) == HEADER_SIZE + len(image.code) + len(block) - def test_the_loaded_image_fits_the_program_area(self, song: Song) -> None: - assert len(nsf_to_bytes(song, INFORMATION)) - HEADER_SIZE <= PROGRAM_SIZE + def test_the_loaded_image_fits_the_program_area(self, song: Song, image: DriverImage) -> None: + assert len(nsf_to_bytes(song, INFORMATION, image)) - HEADER_SIZE <= PROGRAM_SIZE def test_the_header_loads_the_image_where_the_driver_expects_it( self, song: Song, image: DriverImage, ) -> None: - data = nsf_to_bytes(song, INFORMATION) + data = nsf_to_bytes(song, INFORMATION, image) assert struct.unpack_from(" None: - data = nsf_to_bytes(song, INFORMATION) + data = nsf_to_bytes(song, INFORMATION, image) block = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) song_start = len(data) - len(block) assert image.addresses.load + song_start - HEADER_SIZE == image.addresses.song @@ -92,13 +92,18 @@ class TestSongsBeyondTheProgramArea: def test_a_song_too_large_for_the_program_area_raises(self, image: DriverImage) -> None: with pytest.raises(SongTooLargeError): - nsf_to_bytes(oversized_song(image), INFORMATION) + nsf_to_bytes(oversized_song(image), INFORMATION, image) class TestWriteNSF: """The bytes reaching a file on disk.""" - def test_the_file_holds_the_bytes_the_song_serialises_to(self, song: Song, tmp_path: Path) -> None: + def test_the_file_holds_the_bytes_the_song_serialises_to( + self, + song: Song, + image: DriverImage, + tmp_path: Path, + ) -> None: destination = tmp_path / FILENAME - write_nsf(destination, song, INFORMATION) - assert destination.read_bytes() == nsf_to_bytes(song, INFORMATION) + write_nsf(destination, song, INFORMATION, image) + assert destination.read_bytes() == nsf_to_bytes(song, INFORMATION, image) From 1054e38577161f8ed89e26e98719283d6a279d97 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 10:23:28 +0200 Subject: [PATCH 030/142] Modelled: the import boundary rules --- scripts/checks/import_boundary.py | 66 +++++++++---------- .../meta/import_boundary/graph.py | 30 +++++++-- .../meta/import_boundary/rule.py | 25 +++++-- .../meta/import_boundary/token.py | 27 +++++++- .../meta/import_boundary/test_check.py | 20 +++++- .../meta/import_boundary/test_token.py | 16 +++++ 6 files changed, 134 insertions(+), 50 deletions(-) diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index ec9db55ac..13acf3d6b 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -92,18 +92,18 @@ APPLICATION_RULES: Final[Tuple[BoundaryRule, ...]] = ( BoundaryRule( - APPLICATION, - "config/**/*.py", - ( + root=APPLICATION, + pattern="config/**/*.py", + forbidden=( *VISUAL, "sampletones_application.coordinators", "sampletones_application.application", ), ), BoundaryRule( - APPLICATION, - "logic/**/*.py", - ( + root=APPLICATION, + pattern="logic/**/*.py", + forbidden=( *VISUAL, "sampletones_application.coordinators", "sampletones_application.services", @@ -111,9 +111,9 @@ contracts=SERVICE_CONTRACTS, ), BoundaryRule( - APPLICATION, - "view_model/**/*.py", - ( + root=APPLICATION, + pattern="view_model/**/*.py", + forbidden=( *VISUAL, "sampletones_application.coordinators", "sampletones_application.config", @@ -122,9 +122,9 @@ ), ), BoundaryRule( - APPLICATION, - "services/**/*.py", - ( + root=APPLICATION, + pattern="services/**/*.py", + forbidden=( *VISUAL, "sampletones_application.view_model", "sampletones_application.coordinators", @@ -133,25 +133,25 @@ ), ), BoundaryRule( - APPLICATION, - "shell.py", - ( + root=APPLICATION, + pattern="shell.py", + forbidden=( "sampletones_application.logic", "sampletones_application.services", ), ), BoundaryRule( - APPLICATION, - "coordinators/**/*.py", - ( + root=APPLICATION, + pattern="coordinators/**/*.py", + forbidden=( "sampletones_application.application", "sampletones_application.shell", ), ), BoundaryRule( - APPLICATION, - "ui/**/*.py", - ( + root=APPLICATION, + pattern="ui/**/*.py", + forbidden=( "sampletones_application.coordinators", "sampletones_application.logic", "sampletones_application.services", @@ -171,24 +171,24 @@ TOKEN_RULES: Final[Tuple[TokenRule, ...]] = ( TokenRule( - APPLICATION, - "ui/panels/**/*.py", - r"\bSUF_PANEL_", - "ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its " + root=APPLICATION, + pattern="ui/panels/**/*.py", + forbidden=r"\bSUF_PANEL_", + message="ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its " "parent through create_panel(parent), set by the coordinator that owns the layout", ), TokenRule( - APPLICATION, - "ui/panels/**/*.py", - r"parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b", - "ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); " + root=APPLICATION, + pattern="ui/panels/**/*.py", + forbidden=r"parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b", + message="ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); " "the coordinator injects the parent through create_panel(parent)", ), TokenRule( - APPLICATION, - "ui/panels/**/*.py", - r"\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b", - "ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/" + root=APPLICATION, + pattern="ui/panels/**/*.py", + forbidden=r"\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b", + message="ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/" "GROUND); only the layout primitives own depth (TabColumns binds the column, card() " "binds the card), and a panel binds only semantic themes", ), diff --git a/src/sampletones_shared/meta/import_boundary/graph.py b/src/sampletones_shared/meta/import_boundary/graph.py index bfa8a69e7..db59836cc 100644 --- a/src/sampletones_shared/meta/import_boundary/graph.py +++ b/src/sampletones_shared/meta/import_boundary/graph.py @@ -1,20 +1,31 @@ -from typing import Dict, List, NamedTuple, Tuple +from typing import Dict, Final, List, Tuple + +from pydantic import BaseModel, ConfigDict from sampletones_shared.meta.import_boundary.rule import BoundaryRule -from sampletones_shared.meta.import_boundary.units import nested_globs, unit_glob, unit_prefix +from sampletones_shared.meta.import_boundary.units import ( + nested_globs, + unit_glob, + unit_prefix, +) + +SOURCE_TREE: Final[str] = "" -class LayerGraph(NamedTuple): +class LayerGraph(BaseModel): """A tree of modules, the units it divides into, and what each unit may import. Attributes: - root: Directory under the source root the units are named within. + root: Directory under the source root the units are named within, empty where the units + sit at the source root itself. package: Import prefix the units sit under, empty where the units are packages themselves. layers: Each unit and the units it may import. """ - root: str - package: str + model_config = ConfigDict(extra="forbid", frozen=True) + + root: str = SOURCE_TREE + package: str = SOURCE_TREE layers: Dict[str, Tuple[str, ...]] def rules(self) -> List[BoundaryRule]: @@ -33,7 +44,12 @@ def rules(self) -> List[BoundaryRule]: root=self.root, pattern=unit_glob(unit), forbidden=tuple( - unit_prefix(self.package, other) for other in self.layers if other != unit and other not in allowed + unit_prefix( + self.package, + other, + ) + for other in self.layers + if other != unit and other not in allowed ), excluding=nested_globs(unit, self.layers), ) diff --git a/src/sampletones_shared/meta/import_boundary/rule.py b/src/sampletones_shared/meta/import_boundary/rule.py index e88556015..8ab77c3d5 100644 --- a/src/sampletones_shared/meta/import_boundary/rule.py +++ b/src/sampletones_shared/meta/import_boundary/rule.py @@ -1,5 +1,7 @@ from pathlib import Path -from typing import List, NamedTuple, Tuple +from typing import List, Tuple + +from pydantic import BaseModel, ConfigDict from sampletones_shared.meta.import_boundary.imports import ( imported_module, @@ -9,7 +11,7 @@ from sampletones_shared.meta.import_boundary.violation import Violation -class BoundaryRule(NamedTuple): +class BoundaryRule(BaseModel): """One tree of modules and the imports it stays clear of. Attributes: @@ -20,6 +22,8 @@ class BoundaryRule(NamedTuple): excluding: Globs naming the modules a rule of their own owns instead. """ + model_config = ConfigDict(extra="forbid", frozen=True) + root: str pattern: str forbidden: Tuple[str, ...] @@ -45,11 +49,24 @@ def violations(self, path: Path) -> List[Violation]: violations: List[Violation] = [] for line_number, line in numbered_lines(path): module = imported_module(line) - if module is None or any(matches_prefix(module, contract) for contract in self.contracts): + if module is None or any( + matches_prefix( + module, + contract, + ) + for contract in self.contracts + ): continue crossed = next( - (prefix for prefix in self.forbidden if matches_prefix(module, prefix)), + ( + prefix + for prefix in self.forbidden + if matches_prefix( + module, + prefix, + ) + ), None, ) if crossed is not None: diff --git a/src/sampletones_shared/meta/import_boundary/token.py b/src/sampletones_shared/meta/import_boundary/token.py index fe55f4ecd..51891e26d 100644 --- a/src/sampletones_shared/meta/import_boundary/token.py +++ b/src/sampletones_shared/meta/import_boundary/token.py @@ -1,12 +1,14 @@ import re from pathlib import Path -from typing import List, NamedTuple +from typing import List + +from pydantic import BaseModel, ConfigDict, field_validator from sampletones_shared.meta.import_boundary.lines import numbered_lines from sampletones_shared.meta.import_boundary.violation import Violation -class TokenRule(NamedTuple): +class TokenRule(BaseModel): """One tree of modules and a spelling that stays out of them. Attributes: @@ -16,11 +18,31 @@ class TokenRule(NamedTuple): message: What the rule holds, printed where a module writes the spelling. """ + model_config = ConfigDict(extra="forbid", frozen=True) + root: str pattern: str forbidden: str message: str + @field_validator("forbidden") + @classmethod + def _validate_the_spelling_is_a_usable_expression( + cls, + forbidden: str, + ) -> str: + """Holds the spelling to what `re` accepts, so a rule reports its own defect as it is read. + + Raises: + ValueError: If the spelling is no valid regular expression. + """ + try: + re.compile(forbidden) + except re.error as error: + raise ValueError(f"the spelling {forbidden!r} is no valid regular expression: {error}") from error + + return forbidden + def violations(self, path: Path) -> List[Violation]: """Every line of one module that writes the forbidden spelling. @@ -32,7 +54,6 @@ def violations(self, path: Path) -> List[Violation]: Raises: OSError: If the module cannot be read. - re.error: If the forbidden spelling is no valid regular expression. """ spelling = re.compile(self.forbidden) return [ diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_check.py b/tests/unit/sampletones_shared/meta/import_boundary/test_check.py index 38fd4a544..7cd39473c 100644 --- a/tests/unit/sampletones_shared/meta/import_boundary/test_check.py +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_check.py @@ -13,12 +13,26 @@ SPELLING: Final[str] = "dpg.add_group(parent=SUF_PANEL_LEFT)\n" RULES: Final[Tuple[BoundaryRule, ...]] = ( - BoundaryRule("package", "logic/**/*.py", ("other_package",)), - BoundaryRule("package", "nested/**/*.py", ("other_package",), excluding=("nested/inner/**/*.py",)), + BoundaryRule( + root="package", + pattern="logic/**/*.py", + forbidden=("other_package",), + ), + BoundaryRule( + root="package", + pattern="nested/**/*.py", + forbidden=("other_package",), + excluding=("nested/inner/**/*.py",), + ), ) TOKEN_RULES: Final[Tuple[TokenRule, ...]] = ( - TokenRule("package", "ui/**/*.py", r"\bSUF_PANEL_", "ui stays clear of a column suffix"), + TokenRule( + root="package", + pattern="ui/**/*.py", + forbidden=r"\bSUF_PANEL_", + message="ui stays clear of a column suffix", + ), ) diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_token.py b/tests/unit/sampletones_shared/meta/import_boundary/test_token.py index ad19063bc..d1b3018a9 100644 --- a/tests/unit/sampletones_shared/meta/import_boundary/test_token.py +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_token.py @@ -1,6 +1,9 @@ from pathlib import Path from typing import Final +import pytest +from pydantic import ValidationError + from sampletones_shared.meta.import_boundary.token import TokenRule from tests.suite.source import write_module @@ -38,3 +41,16 @@ def test_every_line_writing_the_spelling_is_reported(self, tmp_path: Path) -> No path = write_module(tmp_path, "left.py", body) assert len(RULE.violations(path)) == 2 + + +class TestUnusableSpellings: + """A rule states a spelling `re` accepts, so one it cannot read is refused as the rule is.""" + + def test_a_spelling_that_is_no_expression_is_refused(self) -> None: + with pytest.raises(ValidationError): + TokenRule( + root="package", + pattern="ui/**/*.py", + forbidden="(unclosed", + message=MESSAGE, + ) From 4b1f5025b4c7292ae7baf763a2dc6367fd97e4f1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 13:52:54 +0200 Subject: [PATCH 031/142] Minor changes --- src/sampletones_application/utils/gui/dpg.py | 2 +- src/sampletones_core/constants/algorithm.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 4f1c9a31b..e66dcdc0a 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -124,7 +124,7 @@ def dpg_get_item_parent( """ try: parent: Optional[Sender] = dpg.get_item_parent(tag, *args, **kwargs) - except Exception: # TODO: unsafe broad exception + except Exception: # unsafe broad exception return None return parent diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index 19f8d1e85..f9a3a3e46 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -1,7 +1,13 @@ from typing import Final -from .enums import HierarchyMode, PhaseAlignerName, SelectorName, SpectralDistance -from .general import MAX_VOLUME, MIN_VOLUME +from sampletones_core.constants.enums import ( + ChannelName, + HierarchyMode, + PhaseAlignerName, + SelectorName, + SpectralDistance, +) +from sampletones_core.constants.general import MAX_VOLUME, MIN_VOLUME # Matching floors @@ -59,7 +65,7 @@ # Stems assignment -DEFAULT_STEMS_CHANNEL_CAP: Final[int] = 1 +DEFAULT_STEMS_CHANNEL_CAP: Final[int] = len(ChannelName) DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN # Execution From 30895bb46f470cb47496e4f7ca6b30f2f61c42d5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 14:31:51 +0200 Subject: [PATCH 032/142] Moved: the import boundary declaration into shipped configuration --- scripts/checks/import_boundary.py | 175 ++---------------- src/sampletones/__main__.py | 4 + .../boundaries/general.yaml | 10 + src/sampletones_config/boundaries/graphs.yaml | 26 +++ src/sampletones_config/boundaries/rules.yaml | 55 ++++++ src/sampletones_config/boundaries/tokens.yaml | 21 +++ src/sampletones_shared/array.py | 50 +++-- .../meta/import_boundary/configs/__init__.py | 0 .../import_boundary/configs/declaration.py | 60 ++++++ .../meta/import_boundary/configs/general.py | 33 ++++ .../meta/import_boundary/configs/paths.py | 6 + .../meta/import_boundary/configs/rules.py | 74 ++++++++ .../meta/import_boundary/graph.py | 56 +++++- .../meta/import_boundary/configs/__init__.py | 0 .../configs/test_declaration.py | 61 ++++++ .../import_boundary/configs/test_general.py | 33 ++++ .../import_boundary/configs/test_rules.py | 164 ++++++++++++++++ .../meta/import_boundary/test_graph.py | 43 ++++- .../scripts/checks/test_import_boundary.py | 153 +-------------- 19 files changed, 689 insertions(+), 335 deletions(-) create mode 100644 src/sampletones_config/boundaries/general.yaml create mode 100644 src/sampletones_config/boundaries/graphs.yaml create mode 100644 src/sampletones_config/boundaries/rules.yaml create mode 100644 src/sampletones_config/boundaries/tokens.yaml create mode 100644 src/sampletones_shared/meta/import_boundary/configs/__init__.py create mode 100644 src/sampletones_shared/meta/import_boundary/configs/declaration.py create mode 100644 src/sampletones_shared/meta/import_boundary/configs/general.py create mode 100644 src/sampletones_shared/meta/import_boundary/configs/paths.py create mode 100644 src/sampletones_shared/meta/import_boundary/configs/rules.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/configs/__init__.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py create mode 100644 tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 13acf3d6b..c906623b0 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -19,8 +19,9 @@ express — e.g. that panels never compose a column suffix (`SUF_PANEL_*`) or parent into another panel's container. -This script declares what the boundaries are; `sampletones_shared/meta/import_boundary/` holds how -they are read and reported. +`sampletones_config/boundaries/` declares what the boundaries are and +`sampletones_shared/meta/import_boundary/` holds how they are read and reported; this script runs +them over a source tree and prints what they find. Usage: python scripts/checks/import_boundary.py [files...] # check specific files @@ -30,170 +31,12 @@ import argparse import sys from pathlib import Path -from typing import Dict, Final, List, Sequence, Tuple +from typing import List, Sequence from sampletones_shared.meta.import_boundary.check import check_boundaries -from sampletones_shared.meta.import_boundary.graph import LayerGraph -from sampletones_shared.meta.import_boundary.rule import BoundaryRule -from sampletones_shared.meta.import_boundary.token import TokenRule +from sampletones_shared.meta.import_boundary.configs.rules import ImportBoundaryRules from sampletones_shared.paths.source import SOURCE_ROOT -APPLICATION: Final[str] = "sampletones_application" -PLAYER: Final[str] = "sampletones_player" - -VISUAL: Final[Tuple[str, ...]] = ( - "dearpygui", - "sampletones_application.ui", - "sampletones_application.utils.gui", -) - -SERVICE_CONTRACTS: Final[Tuple[str, ...]] = ( - "sampletones_application.services.result", - "sampletones_application.services.render.result", - "sampletones_application.services.song_player.result", -) - -PACKAGE_LAYERS: Final[Dict[str, Tuple[str, ...]]] = { - "sampletones_shared": (), - "sampletones_config": (), - "sampletones_assets": ("sampletones_shared",), - "sampletones_synthesis": ("sampletones_shared",), - "sampletones_core": ("sampletones_shared", "sampletones_synthesis"), - "sampletones_player": ("sampletones_shared", "sampletones_core"), - "sampletones_application": ("sampletones_shared", "sampletones_core", "sampletones_player"), - "sampletones": ("sampletones_shared", "sampletones_core", "sampletones_application"), -} - -PLAYER_LAYERS: Final[Dict[str, Tuple[str, ...]]] = { - "__init__.py": (), - "specification": (), - "clock": ("specification",), - "registers": ("specification",), - "song.py": ("clock", "registers"), - "builder.py": ("song.py", "registers", "clock"), - "trace": ("song.py", "specification"), - "nsf": ("song.py", "registers", "specification", "driver"), - "export.py": ("builder.py", "nsf", "driver"), - "driver": ("specification",), - "driver/assembler": ("driver", "specification"), -} - -PACKAGES: Final[LayerGraph] = LayerGraph( - root="", - package="", - layers=PACKAGE_LAYERS, -) - -PLAYER_GRAPH: Final[LayerGraph] = LayerGraph( - root=PLAYER, - package=PLAYER, - layers=PLAYER_LAYERS, -) - -APPLICATION_RULES: Final[Tuple[BoundaryRule, ...]] = ( - BoundaryRule( - root=APPLICATION, - pattern="config/**/*.py", - forbidden=( - *VISUAL, - "sampletones_application.coordinators", - "sampletones_application.application", - ), - ), - BoundaryRule( - root=APPLICATION, - pattern="logic/**/*.py", - forbidden=( - *VISUAL, - "sampletones_application.coordinators", - "sampletones_application.services", - ), - contracts=SERVICE_CONTRACTS, - ), - BoundaryRule( - root=APPLICATION, - pattern="view_model/**/*.py", - forbidden=( - *VISUAL, - "sampletones_application.coordinators", - "sampletones_application.config", - "sampletones_application.logic", - "sampletones_application.services", - ), - ), - BoundaryRule( - root=APPLICATION, - pattern="services/**/*.py", - forbidden=( - *VISUAL, - "sampletones_application.view_model", - "sampletones_application.coordinators", - "sampletones_application.config", - "sampletones_application.logic", - ), - ), - BoundaryRule( - root=APPLICATION, - pattern="shell.py", - forbidden=( - "sampletones_application.logic", - "sampletones_application.services", - ), - ), - BoundaryRule( - root=APPLICATION, - pattern="coordinators/**/*.py", - forbidden=( - "sampletones_application.application", - "sampletones_application.shell", - ), - ), - BoundaryRule( - root=APPLICATION, - pattern="ui/**/*.py", - forbidden=( - "sampletones_application.coordinators", - "sampletones_application.logic", - "sampletones_application.services", - "sampletones_application.config", - "sampletones_application.application", - "sampletones_application.shell", - "sampletones_application.utils.gui.dialogs", - ), - ), -) - -RULES: Final[Tuple[BoundaryRule, ...]] = ( - *PACKAGES.rules(), - *PLAYER_GRAPH.rules(), - *APPLICATION_RULES, -) - -TOKEN_RULES: Final[Tuple[TokenRule, ...]] = ( - TokenRule( - root=APPLICATION, - pattern="ui/panels/**/*.py", - forbidden=r"\bSUF_PANEL_", - message="ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its " - "parent through create_panel(parent), set by the coordinator that owns the layout", - ), - TokenRule( - root=APPLICATION, - pattern="ui/panels/**/*.py", - forbidden=r"parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b", - message="ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); " - "the coordinator injects the parent through create_panel(parent)", - ), - TokenRule( - root=APPLICATION, - pattern="ui/panels/**/*.py", - forbidden=r"\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b", - message="ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/" - "GROUND); only the layout primitives own depth (TabColumns binds the column, card() " - "binds the card), and a panel binds only semantic themes", - ), -) - def main(argv: Sequence[str]) -> int: """Report every import and token the layer boundaries forbid.""" @@ -221,7 +64,13 @@ def main(argv: Sequence[str]) -> int: files: List[Path] = arguments.files selection = None if arguments.all else {path.resolve() for path in files} - violations = check_boundaries(arguments.source, RULES, TOKEN_RULES, selection) + boundaries = ImportBoundaryRules.load() + violations = check_boundaries( + arguments.source, + boundaries.boundary_rules(), + boundaries.tokens, + selection, + ) if not violations: return 0 diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py index b7625bb23..dc3dcb875 100644 --- a/src/sampletones/__main__.py +++ b/src/sampletones/__main__.py @@ -116,6 +116,10 @@ def main() -> None: raise SystemExit(run_self_check()) + from sampletones_shared.array import report_array_backend + + report_array_backend() + config_path = Path(args.config) if args.config else None output_path = Path(args.output) if args.output else None diff --git a/src/sampletones_config/boundaries/general.yaml b/src/sampletones_config/boundaries/general.yaml new file mode 100644 index 000000000..92755314b --- /dev/null +++ b/src/sampletones_config/boundaries/general.yaml @@ -0,0 +1,10 @@ +groups: + visual: + - dearpygui + - sampletones_application.ui + - sampletones_application.utils.gui + + service_contracts: + - sampletones_application.services.result + - sampletones_application.services.render.result + - sampletones_application.services.song_player.result diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml new file mode 100644 index 000000000..8ee892b70 --- /dev/null +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -0,0 +1,26 @@ +packages: + layers: + sampletones_shared: [] + sampletones_config: [] + sampletones_assets: [sampletones_shared] + sampletones_synthesis: [sampletones_shared] + sampletones_core: [sampletones_shared, sampletones_synthesis] + sampletones_player: [sampletones_shared, sampletones_core] + sampletones_application: [sampletones_shared, sampletones_core, sampletones_player] + sampletones: [sampletones_shared, sampletones_core, sampletones_application] + +player: + root: sampletones_player + package: sampletones_player + layers: + __init__.py: [] + specification: [] + clock: [specification] + registers: [specification] + song.py: [clock, registers] + builder.py: [song.py, registers, clock] + trace: [song.py, specification] + nsf: [song.py, registers, specification, driver] + export.py: [builder.py, nsf, driver] + driver: [specification] + driver/assembler: [driver, specification] diff --git a/src/sampletones_config/boundaries/rules.yaml b/src/sampletones_config/boundaries/rules.yaml new file mode 100644 index 000000000..80ea3ba95 --- /dev/null +++ b/src/sampletones_config/boundaries/rules.yaml @@ -0,0 +1,55 @@ +- root: sampletones_application + pattern: "config/**/*.py" + forbidden_groups: [visual] + forbidden: + - sampletones_application.coordinators + - sampletones_application.application + +- root: sampletones_application + pattern: "logic/**/*.py" + forbidden_groups: [visual] + forbidden: + - sampletones_application.coordinators + - sampletones_application.services + contract_groups: [service_contracts] + +- root: sampletones_application + pattern: "view_model/**/*.py" + forbidden_groups: [visual] + forbidden: + - sampletones_application.coordinators + - sampletones_application.config + - sampletones_application.logic + - sampletones_application.services + +- root: sampletones_application + pattern: "services/**/*.py" + forbidden_groups: [visual] + forbidden: + - sampletones_application.view_model + - sampletones_application.coordinators + - sampletones_application.config + - sampletones_application.logic + +- root: sampletones_application + pattern: "shell.py" + forbidden: + - sampletones_application.logic + - sampletones_application.services + +- root: sampletones_application + pattern: "coordinators/**/*.py" + forbidden: + - sampletones_application.application + - sampletones_application.shell + +- root: sampletones_application + pattern: "ui/**/*.py" + forbidden: + - sampletones_application.coordinators + - sampletones_application.logic + - sampletones_application.services + - sampletones_application.config + - sampletones_application.application + - sampletones_application.shell + - sampletones_application.utils.gui.dialogs diff --git a/src/sampletones_config/boundaries/tokens.yaml b/src/sampletones_config/boundaries/tokens.yaml new file mode 100644 index 000000000..f144f828a --- /dev/null +++ b/src/sampletones_config/boundaries/tokens.yaml @@ -0,0 +1,21 @@ +- root: sampletones_application + pattern: "ui/panels/**/*.py" + forbidden: '\bSUF_PANEL_' + message: >- + ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its + parent through create_panel(parent), set by the coordinator that owns the layout + +- root: sampletones_application + pattern: "ui/panels/**/*.py" + forbidden: 'parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b' + message: >- + ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); + the coordinator injects the parent through create_panel(parent) + +- root: sampletones_application + pattern: "ui/panels/**/*.py" + forbidden: '\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b' + message: >- + ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/GROUND); + only the layout primitives own depth (TabColumns binds the column, card() binds the card), + and a panel binds only semantic themes diff --git a/src/sampletones_shared/array.py b/src/sampletones_shared/array.py index 006271a4b..7b7dd1234 100644 --- a/src/sampletones_shared/array.py +++ b/src/sampletones_shared/array.py @@ -1,9 +1,13 @@ -from typing import Optional, Type, Union +import warnings +from typing import Final, Optional, Type, Union import numpy as np +from sampletones_shared.exceptions import CuPyNotInstalledWarning from sampletones_shared.logger import logger +CUPY_MISSING_MESSAGE: Final[str] = "CuPy is not available, falling back to NumPy." + def _preload_cuda_libraries() -> None: """Make CUDA libraries shipped as ``nvidia-*-cu12`` wheels discoverable by CuPy. @@ -32,35 +36,44 @@ def _preload_cuda_libraries() -> None: pass +def _format_warning_no_location( + message: Union[Warning, str], + category: Type[Warning], + filename: str, # pylint: disable=unused-argument + lineno: int, # pylint: disable=unused-argument + line: Optional[str] = None, # pylint: disable=unused-argument +) -> str: + return f"{category.__name__}: {message}\n" + + CUPY_AVAILABLE = False # pylint: disable=invalid-name try: _preload_cuda_libraries() import cupy as xp import cupy.typing as xp_typing - CUPY_AVAILABLE = True # pylint: disable=invalid-name, - logger.info(f"CuPy {xp.__version__} is active") + CUPY_AVAILABLE = True # pylint: disable=invalid-name except (AttributeError, ImportError, ModuleNotFoundError): - import warnings + import numpy.typing as xp_typing # pylint: disable=ungrouped-imports - from sampletones_shared.exceptions import CuPyNotInstalledWarning # pylint: disable=ungrouped-imports + xp = np - def _format_warning_no_location( - message: Union[Warning, str], - category: Type[Warning], - filename: str, # pylint: disable=unused-argument - lineno: int, # pylint: disable=unused-argument - line: Optional[str] = None, # pylint: disable=unused-argument - ) -> str: - return f"{category.__name__}: {message}\n" - warnings.formatwarning = _format_warning_no_location - warnings.warn("CuPy is not available, falling back to NumPy.", CuPyNotInstalledWarning) - logger.warning("CuPy is not available, falling back to NumPy.") +def report_array_backend() -> None: + """States which array backend the process computes on. - import numpy.typing as xp_typing # pylint: disable=ungrouped-imports + Choosing the backend is an import, and an import runs before an entry point has read the + verbosity it was configured with. Announcing it separately puts the line where a reader is — + a run of the application, or a reconstruction from the command line — and leaves a tool that + imports the array vocabulary for its types alone to its own output. + """ + if CUPY_AVAILABLE: + logger.info(f"CuPy {xp.__version__} is active") + return - xp = np + warnings.formatwarning = _format_warning_no_location + warnings.warn(CUPY_MISSING_MESSAGE, CuPyNotInstalledWarning) + logger.warning(CUPY_MISSING_MESSAGE) def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray: @@ -75,6 +88,7 @@ def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray: __all__ = [ "CUPY_AVAILABLE", + "report_array_backend", "to_numpy", "xp", "xp_typing", diff --git a/src/sampletones_shared/meta/import_boundary/configs/__init__.py b/src/sampletones_shared/meta/import_boundary/configs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_shared/meta/import_boundary/configs/declaration.py b/src/sampletones_shared/meta/import_boundary/configs/declaration.py new file mode 100644 index 000000000..5957647a3 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/configs/declaration.py @@ -0,0 +1,60 @@ +from typing import Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries +from sampletones_shared.meta.import_boundary.rule import BoundaryRule + + +class BoundaryDeclaration(BaseModel): + """One boundary as it is written, naming the groups it draws on. + + A rule reaching for a set several others share names the group instead of repeating it, so + the set stays one statement. What is written here becomes the rule the check runs once the + groups are spelled out. + + Attributes: + root: Directory under the source root the pattern is written against. + pattern: Glob naming the modules the declaration reaches. + forbidden_groups: Groups whose prefixes are out of reach in them. + forbidden: Import prefixes out of reach in them, beyond the groups'. + contract_groups: Groups whose prefixes are exempt from the forbidden ones. + contracts: Import prefixes exempt from the forbidden ones, beyond the groups'. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + root: str + pattern: str + forbidden_groups: Tuple[str, ...] = () + forbidden: Tuple[str, ...] = () + contract_groups: Tuple[str, ...] = () + contracts: Tuple[str, ...] = () + + def rule(self, general: GeneralBoundaries) -> BoundaryRule: + """The rule the declaration amounts to, with every group it names spelled out. + + A group's prefixes lead the ones written beside them, so the rule reads in the order it + was declared: the shared set first, the declaration's own prefixes after. + + Args: + general: The names the declaration is written in. + + Returns: + BoundaryRule: The boundary the check runs. + + Raises: + KeyError: If the declaration names a group the vocabulary leaves out. + """ + return BoundaryRule( + root=self.root, + pattern=self.pattern, + forbidden=( + *general.prefixes(self.forbidden_groups), + *self.forbidden, + ), + contracts=( + *general.prefixes(self.contract_groups), + *self.contracts, + ), + ) diff --git a/src/sampletones_shared/meta/import_boundary/configs/general.py b/src/sampletones_shared/meta/import_boundary/configs/general.py new file mode 100644 index 000000000..62f6f7724 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/configs/general.py @@ -0,0 +1,33 @@ +from typing import Dict, Sequence, Tuple + +from pydantic import BaseModel, ConfigDict + + +class GeneralBoundaries(BaseModel): + """The names the boundary declarations are written in. + + A group gathers the import prefixes several rules reach for as one thing — the interface a + layer stays clear of, the data contracts it may read — so the set is stated once and each + rule names it. + + Attributes: + groups: Each named set of import prefixes and the prefixes it gathers. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + groups: Dict[str, Tuple[str, ...]] + + def prefixes(self, names: Sequence[str]) -> Tuple[str, ...]: + """The import prefixes the named groups gather, in the order they are named. + + Args: + names: Groups to spell out. + + Returns: + Tuple[str, ...]: Every prefix those groups hold. + + Raises: + KeyError: If a name reaches no declared group. + """ + return tuple(prefix for name in names for prefix in self.groups[name]) diff --git a/src/sampletones_shared/meta/import_boundary/configs/paths.py b/src/sampletones_shared/meta/import_boundary/configs/paths.py new file mode 100644 index 000000000..92ac72066 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/configs/paths.py @@ -0,0 +1,6 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.resources import CONFIG_DIRECTORY + +BOUNDARIES_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "boundaries" diff --git a/src/sampletones_shared/meta/import_boundary/configs/rules.py b/src/sampletones_shared/meta/import_boundary/configs/rules.py new file mode 100644 index 000000000..b4fc43a46 --- /dev/null +++ b/src/sampletones_shared/meta/import_boundary/configs/rules.py @@ -0,0 +1,74 @@ +from typing import Dict, Self, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_shared.meta.import_boundary.configs.declaration import BoundaryDeclaration +from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries +from sampletones_shared.meta.import_boundary.configs.paths import BOUNDARIES_DIRECTORY +from sampletones_shared.meta.import_boundary.graph import LayerGraph +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.token import TokenRule +from sampletones_shared.utils.serialization import load_yaml_model_dir + + +class ImportBoundaryRules(BaseModel): + """Every boundary the source tree is held to, as the shipped configuration states it. + + The declaration comes in three forms, each a fragment of its own. A layer graph names a tree + of modules and what each unit may import, and amounts to one rule per unit. A boundary + declaration names one directory and the imports it stays clear of. A token rule names a + spelling a tree keeps out. The general vocabulary holds the prefix sets the declarations draw + on, so a set several of them reach for is written once. + + Attributes: + general: The names the declarations are written in. + graphs: Each layer graph the source tree divides into, under the name the documents give it. + rules: The boundaries written directly. + tokens: The spellings kept out of the trees they name. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + general: GeneralBoundaries + graphs: Dict[str, LayerGraph] + rules: Tuple[BoundaryDeclaration, ...] + tokens: Tuple[TokenRule, ...] + + @model_validator(mode="after") + def _validate_every_named_group_is_declared(self) -> Self: + """Holds each declaration to the vocabulary, so a name reaching no group is refused as it is read. + + Raises: + ValueError: If a declaration names a group the general configuration leaves out. + """ + named = { + name for declaration in self.rules for name in (*declaration.forbidden_groups, *declaration.contract_groups) + } + unknown = sorted(named - set(self.general.groups)) + if unknown: + raise ValueError(f"the declarations name groups the vocabulary leaves out: {', '.join(unknown)}") + + return self + + @classmethod + def load(cls) -> Self: + """The boundaries the build ships. + + Returns: + Self: The declaration validated from `sampletones_config/boundaries/`. + + Raises: + TypeError: If a fragment holds anything other than what its field states. + """ + return load_yaml_model_dir(BOUNDARIES_DIRECTORY, cls) + + def boundary_rules(self) -> Tuple[BoundaryRule, ...]: + """Every import boundary the check runs, the graphs' rules first and the declared ones after. + + Returns: + Tuple[BoundaryRule, ...]: The rules the declaration amounts to, in declaration order. + """ + return ( + *(rule for graph in self.graphs.values() for rule in graph.rules()), + *(declaration.rule(self.general) for declaration in self.rules), + ) diff --git a/src/sampletones_shared/meta/import_boundary/graph.py b/src/sampletones_shared/meta/import_boundary/graph.py index db59836cc..dede55c51 100644 --- a/src/sampletones_shared/meta/import_boundary/graph.py +++ b/src/sampletones_shared/meta/import_boundary/graph.py @@ -1,6 +1,6 @@ -from typing import Dict, Final, List, Tuple +from typing import Dict, Final, List, Mapping, Self, Set, Tuple -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from sampletones_shared.meta.import_boundary.rule import BoundaryRule from sampletones_shared.meta.import_boundary.units import ( @@ -12,6 +12,30 @@ SOURCE_TREE: Final[str] = "" +def reached_units(layers: Mapping[str, Tuple[str, ...]], unit: str) -> Set[str]: + """Every unit one unit imports, directly or through the units it imports. + + Args: + layers: Each unit and the units it may import. + unit: Unit to walk out from. + + Returns: + Set[str]: The units it reaches, itself among them where the graph closes a cycle. + + Raises: + KeyError: If a unit is reached that the layers leave undeclared. + """ + reached: Set[str] = set() + pending = [unit] + while pending: + for allowed in layers[pending.pop()]: + if allowed not in reached: + reached.add(allowed) + pending.append(allowed) + + return reached + + class LayerGraph(BaseModel): """A tree of modules, the units it divides into, and what each unit may import. @@ -28,6 +52,34 @@ class LayerGraph(BaseModel): package: str = SOURCE_TREE layers: Dict[str, Tuple[str, ...]] + @model_validator(mode="after") + def _validate_every_layer_a_unit_may_import_is_declared(self) -> Self: + """Holds each unit's layers to the units the graph divides into. + + Raises: + ValueError: If a unit may import something the graph leaves undeclared. + """ + undeclared = sorted( + {allowed for layers in self.layers.values() for allowed in layers} - set(self.layers), + ) + if undeclared: + raise ValueError(f"the graph leaves the units it reaches undeclared: {', '.join(undeclared)}") + + return self + + @model_validator(mode="after") + def _validate_the_graph_is_acyclic(self) -> Self: + """Holds the units to an order, which is what makes a unit's layers state a level. + + Raises: + ValueError: If a unit reaches itself through the units it may import. + """ + looping = sorted(unit for unit in self.layers if unit in reached_units(self.layers, unit)) + if looping: + raise ValueError(f"the units reach themselves through the graph: {', '.join(looping)}") + + return self + def rules(self) -> List[BoundaryRule]: """One rule per unit, forbidding every unit its layers leave out. diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/__init__.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py new file mode 100644 index 000000000..de73889d2 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py @@ -0,0 +1,61 @@ +from typing import Final + +from sampletones_shared.meta.import_boundary.configs.declaration import BoundaryDeclaration +from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries + +ROOT: Final[str] = "package" +PATTERN: Final[str] = "logic/**/*.py" + +GENERAL: Final[GeneralBoundaries] = GeneralBoundaries( + groups={ + "visual": ("dearpygui", "package.ui"), + "contracts": ("package.services.result",), + }, +) + + +class TestDeclaredRule: + """What a declaration is written as, and the rule it amounts to.""" + + def test_a_declaration_naming_no_group_states_its_own_prefixes(self) -> None: + declaration = BoundaryDeclaration( + root=ROOT, + pattern=PATTERN, + forbidden=("package.services",), + ) + + assert declaration.rule(GENERAL).forbidden == ("package.services",) + + def test_a_named_group_leads_the_prefixes_written_beside_it(self) -> None: + declaration = BoundaryDeclaration( + root=ROOT, + pattern=PATTERN, + forbidden_groups=("visual",), + forbidden=("package.services",), + ) + + assert declaration.rule(GENERAL).forbidden == ( + "dearpygui", + "package.ui", + "package.services", + ) + + def test_a_group_reaches_the_contracts_the_same_way(self) -> None: + declaration = BoundaryDeclaration( + root=ROOT, + pattern=PATTERN, + forbidden=("package.services",), + contract_groups=("contracts",), + ) + + assert declaration.rule(GENERAL).contracts == ("package.services.result",) + + def test_the_rule_is_written_against_the_tree_the_declaration_names(self) -> None: + declaration = BoundaryDeclaration( + root=ROOT, + pattern=PATTERN, + forbidden=("package.services",), + ) + rule = declaration.rule(GENERAL) + + assert (rule.root, rule.pattern) == (ROOT, PATTERN) diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py new file mode 100644 index 000000000..51b85a3c6 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py @@ -0,0 +1,33 @@ +from typing import Final + +import pytest + +from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries + +GENERAL: Final[GeneralBoundaries] = GeneralBoundaries( + groups={ + "visual": ("dearpygui", "package.ui"), + "contracts": ("package.services.result",), + }, +) + + +class TestGroupPrefixes: + """A group gathers the prefixes several rules reach for, so the set is written once.""" + + def test_a_group_is_spelled_out_as_the_prefixes_it_gathers(self) -> None: + assert GENERAL.prefixes(("visual",)) == ("dearpygui", "package.ui") + + def test_several_groups_follow_the_order_they_are_named_in(self) -> None: + assert GENERAL.prefixes(("contracts", "visual")) == ( + "package.services.result", + "dearpygui", + "package.ui", + ) + + def test_naming_no_group_reaches_no_prefix(self) -> None: + assert GENERAL.prefixes(()) == () + + def test_a_name_reaching_no_group_is_reported(self) -> None: + with pytest.raises(KeyError): + GENERAL.prefixes(("absent",)) diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py new file mode 100644 index 000000000..58e9acf81 --- /dev/null +++ b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py @@ -0,0 +1,164 @@ +from collections import Counter +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_shared.meta.import_boundary.check import check_boundaries +from sampletones_shared.meta.import_boundary.configs.declaration import BoundaryDeclaration +from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries +from sampletones_shared.meta.import_boundary.configs.rules import ImportBoundaryRules +from sampletones_shared.meta.import_boundary.graph import reached_units +from sampletones_shared.meta.import_boundary.rule import BoundaryRule +from sampletones_shared.meta.import_boundary.scope import rule_modules +from sampletones_shared.paths.source import SOURCE_ROOT +from tests.suite.source import swept_paths, write_module + +BOUNDARIES: Final[ImportBoundaryRules] = ImportBoundaryRules.load() + +APPLICATION: Final[str] = "sampletones_application" +CORE: Final[str] = "sampletones_core" +PLAYER: Final[str] = "sampletones_player" +ASSEMBLER: Final[str] = "sampletones_player.driver.assembler" + +VISUAL_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n" +CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n" +PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n" +PLAYER_IMPORT: Final[str] = "from sampletones_player.song import Song\n" +ASSEMBLER_IMPORT: Final[str] = "from sampletones_player.driver.assembler.builder import build_driver\n" +DRIVER_IMPORT: Final[str] = "from sampletones_player.driver.image import DriverImage\n" +PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n" + + +def reached_modules(rule: BoundaryRule) -> List[Path]: + """The modules a rule of the real source tree applies to.""" + root = SOURCE_ROOT / rule.root + return rule_modules(root, rule.pattern, rule.excluding, swept_paths(root), None) + + +def reported(tmp_path: Path) -> List[str]: + """What the shipped declaration reports over a tree a test builds.""" + violations = check_boundaries( + tmp_path, + BOUNDARIES.boundary_rules(), + BOUNDARIES.tokens, + None, + ) + return [violation.kind for violation in violations] + + +class TestPackageGraph: + """The packages under the source root, and the order they may reach each other in.""" + + LAYERS: Final[Dict[str, Tuple[str, ...]]] = BOUNDARIES.graphs["packages"].layers + + def test_every_package_of_the_source_tree_is_declared(self) -> None: + directories = {path.name for path in SOURCE_ROOT.iterdir() if (path / "__init__.py").is_file()} + + assert set(self.LAYERS) == directories + + def test_the_reconstruction_engine_stays_clear_of_the_console_player(self) -> None: + assert PLAYER not in reached_units(self.LAYERS, CORE) + + def test_the_console_player_reads_the_reconstruction_engine(self) -> None: + assert CORE in self.LAYERS[PLAYER] + + def test_the_synthesis_package_stands_below_the_reconstruction_engine(self) -> None: + """Equal temperament sits in `sampletones_shared`, so synthesis reaches no engine module.""" + assert CORE not in reached_units(self.LAYERS, "sampletones_synthesis") + + +class TestPlayerGraph: + """The player's own subpackages, and the order they may reach each other in.""" + + LAYERS: Final[Dict[str, Tuple[str, ...]]] = BOUNDARIES.graphs["player"].layers + + def test_the_specification_is_the_layer_everything_stands_on(self) -> None: + assert self.LAYERS["specification"] == () + + def test_the_build_toolchain_is_reached_from_no_shipped_module(self) -> None: + """`driver/assembler/` stays outside the wheel, so an import of it breaks an installed copy.""" + assert all("driver/assembler" not in layers for layers in self.LAYERS.values()) + + def test_the_driver_is_reached_through_the_file_that_writes_the_nsf(self) -> None: + assert "driver" in self.LAYERS["nsf"] + + def test_every_module_of_the_player_belongs_to_one_unit(self) -> None: + rules = BOUNDARIES.graphs["player"].rules() + owners = Counter(path for rule in rules for path in reached_modules(rule)) + + assert set(owners) == swept_paths(SOURCE_ROOT / PLAYER) + assert set(owners.values()) == {1} + + +class TestNamedGroups: + """A declaration names the groups it draws on, so a name reaching none is refused as it is read.""" + + def test_a_declaration_naming_no_declared_group_is_refused(self) -> None: + with pytest.raises(ValidationError): + ImportBoundaryRules( + general=GeneralBoundaries(groups={}), + graphs={}, + rules=( + BoundaryDeclaration( + root=APPLICATION, + pattern="logic/**/*.py", + forbidden_groups=("absent",), + ), + ), + tokens=(), + ) + + +class TestDeclaredRules: + """Each declared boundary read over a tree that crosses it.""" + + def test_a_layer_reaching_the_interface_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / APPLICATION / "logic", "direct.py", VISUAL_IMPORT) + + assert reported(tmp_path) == ["dearpygui"] + + def test_a_service_contract_stays_reachable(self, tmp_path: Path) -> None: + """A layer reads another layer's data contract while its implementation stays out of reach.""" + write_module(tmp_path / APPLICATION / "logic", "direct.py", CONTRACT_IMPORT) + + assert reported(tmp_path) == [] + + def test_the_reconstruction_engine_reaching_the_console_player_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / CORE / "formats", "player.py", PLAYER_IMPORT) + + assert reported(tmp_path) == [PLAYER] + + def test_the_console_player_reading_the_engine_is_left_alone(self, tmp_path: Path) -> None: + write_module(tmp_path / PLAYER / "nsf", "file.py", PLAIN_IMPORT) + + assert reported(tmp_path) == [] + + def test_a_shipped_module_reaching_the_build_toolchain_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / PLAYER / "nsf", "file.py", ASSEMBLER_IMPORT) + + assert reported(tmp_path) == [ASSEMBLER] + + def test_the_build_toolchain_reads_the_driver_it_assembles(self, tmp_path: Path) -> None: + write_module(tmp_path / PLAYER / "driver" / "assembler", "builder.py", DRIVER_IMPORT) + + assert reported(tmp_path) == [] + + def test_a_panel_composing_a_column_suffix_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / APPLICATION / "ui" / "panels", "left.py", PANEL_SUFFIX) + + assert len(reported(tmp_path)) == 1 + + +class TestRuleCoverage: + """A rule naming no module of the tree reads as a clean tree, so each one reaches something.""" + + def test_the_source_root_holds_modules(self) -> None: + assert swept_paths(SOURCE_ROOT) + + def test_every_boundary_rule_reaches_a_module(self) -> None: + assert all(reached_modules(rule) for rule in BOUNDARIES.boundary_rules()) + + def test_every_token_rule_reaches_a_module(self) -> None: + assert all(list((SOURCE_ROOT / rule.root).glob(rule.pattern)) for rule in BOUNDARIES.tokens) diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py b/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py index 395e53da5..d6857ef97 100644 --- a/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py @@ -1,6 +1,9 @@ from typing import Final -from sampletones_shared.meta.import_boundary.graph import LayerGraph +import pytest +from pydantic import ValidationError + +from sampletones_shared.meta.import_boundary.graph import LayerGraph, reached_units GRAPH: Final[LayerGraph] = LayerGraph( root="package", @@ -35,3 +38,41 @@ def test_every_rule_is_written_against_the_graphs_root(self) -> None: def test_a_rule_names_its_unit_by_the_glob_the_unit_holds(self) -> None: assert [rule.pattern for rule in GRAPH.rules()] == ["low/**/*.py", "high/**/*.py", "high/nested/**/*.py"] + + +class TestWellFormedGraphs: + """A graph states an order over its units, so one it cannot state is refused as it is read.""" + + def test_a_unit_reaching_an_undeclared_unit_is_refused(self) -> None: + with pytest.raises(ValidationError): + LayerGraph( + root="package", + package="package", + layers={"high": ("absent",)}, + ) + + def test_a_graph_closing_a_cycle_is_refused(self) -> None: + with pytest.raises(ValidationError): + LayerGraph( + root="package", + package="package", + layers={"low": ("high",), "high": ("low",)}, + ) + + def test_a_unit_importing_itself_is_refused(self) -> None: + with pytest.raises(ValidationError): + LayerGraph( + root="package", + package="package", + layers={"low": ("low",)}, + ) + + +class TestReachedUnits: + """What a unit imports through the units it imports, which is what an order is read from.""" + + def test_a_unit_reaches_what_its_layers_reach(self) -> None: + assert reached_units(GRAPH.layers, "high") == {"low"} + + def test_a_unit_standing_on_nothing_reaches_nothing(self) -> None: + assert reached_units(GRAPH.layers, "low") == set() diff --git a/tests/unit/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py index 10ff6256d..2e0798f3b 100644 --- a/tests/unit/scripts/checks/test_import_boundary.py +++ b/tests/unit/scripts/checks/test_import_boundary.py @@ -1,166 +1,17 @@ -from collections import Counter from pathlib import Path -from typing import Dict, Final, List, Set, Tuple +from typing import Final import pytest -from sampletones_shared.meta.import_boundary.check import check_boundaries -from sampletones_shared.meta.import_boundary.rule import BoundaryRule -from sampletones_shared.meta.import_boundary.scope import rule_modules -from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.scripts import load_script -from tests.suite.source import swept_paths, write_module +from tests.suite.source import write_module check_import_boundary = load_script("checks/import_boundary.py") APPLICATION: Final[str] = "sampletones_application" -CORE: Final[str] = "sampletones_core" -PLAYER: Final[str] = "sampletones_player" -ASSEMBLER: Final[str] = "sampletones_player.driver.assembler" VISUAL_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n" -CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n" PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n" -PLAYER_IMPORT: Final[str] = "from sampletones_player.song import Song\n" -ASSEMBLER_IMPORT: Final[str] = "from sampletones_player.driver.assembler.builder import build_driver\n" -DRIVER_IMPORT: Final[str] = "from sampletones_player.driver.image import DriverImage\n" -PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n" - - -def reached_modules(rule: BoundaryRule) -> List[Path]: - """The modules a rule of the real source tree applies to.""" - root = SOURCE_ROOT / rule.root - return rule_modules(root, rule.pattern, rule.excluding, swept_paths(root), None) - - -def reaches(layers: Dict[str, Tuple[str, ...]], unit: str, seen: Set[str]) -> Set[str]: - """Every unit a unit imports, directly or through the units it imports.""" - for allowed in layers[unit]: - if allowed not in seen: - seen.add(allowed) - reaches(layers, allowed, seen) - - return seen - - -def reported(tmp_path: Path) -> List[str]: - """What the declared rules report over a tree a test builds.""" - violations = check_boundaries( - tmp_path, - check_import_boundary.RULES, - check_import_boundary.TOKEN_RULES, - None, - ) - return [violation.kind for violation in violations] - - -class TestPackageGraph: - """The packages under the source root, and the order they may reach each other in.""" - - LAYERS: Final[Dict[str, Tuple[str, ...]]] = check_import_boundary.PACKAGE_LAYERS - - def test_every_package_of_the_source_tree_is_declared(self) -> None: - directories = {path.name for path in SOURCE_ROOT.iterdir() if (path / "__init__.py").is_file()} - - assert set(self.LAYERS) == directories - - def test_every_layer_a_package_may_import_is_a_declared_package(self) -> None: - assert all(allowed in self.LAYERS for layers in self.LAYERS.values() for allowed in layers) - - def test_the_package_graph_is_acyclic(self) -> None: - assert all(package not in reaches(self.LAYERS, package, set()) for package in self.LAYERS) - - def test_the_reconstruction_engine_stays_clear_of_the_console_player(self) -> None: - assert PLAYER not in reaches(self.LAYERS, CORE, set()) - - def test_the_console_player_reads_the_reconstruction_engine(self) -> None: - assert CORE in self.LAYERS[PLAYER] - - def test_the_synthesis_package_stands_below_the_reconstruction_engine(self) -> None: - """Equal temperament sits in `sampletones_shared`, so synthesis reaches no engine module.""" - assert CORE not in reaches(self.LAYERS, "sampletones_synthesis", set()) - - -class TestPlayerGraph: - """The player's own subpackages, and the order they may reach each other in.""" - - LAYERS: Final[Dict[str, Tuple[str, ...]]] = check_import_boundary.PLAYER_LAYERS - - def test_every_layer_a_unit_may_import_is_a_declared_unit(self) -> None: - assert all(allowed in self.LAYERS for layers in self.LAYERS.values() for allowed in layers) - - def test_the_player_graph_is_acyclic(self) -> None: - assert all(unit not in reaches(self.LAYERS, unit, set()) for unit in self.LAYERS) - - def test_the_specification_is_the_layer_everything_stands_on(self) -> None: - assert self.LAYERS["specification"] == () - - def test_the_build_toolchain_is_reached_from_no_shipped_module(self) -> None: - """`driver/assembler/` stays outside the wheel, so an import of it breaks an installed copy.""" - assert all("driver/assembler" not in layers for layers in self.LAYERS.values()) - - def test_the_driver_is_reached_through_the_file_that_writes_the_nsf(self) -> None: - assert "driver" in self.LAYERS["nsf"] - - def test_every_module_of_the_player_belongs_to_one_unit(self) -> None: - owners = Counter(path for rule in check_import_boundary.PLAYER_GRAPH.rules() for path in reached_modules(rule)) - - assert set(owners) == swept_paths(SOURCE_ROOT / PLAYER) - assert set(owners.values()) == {1} - - -class TestDeclaredRules: - """Each declared boundary read over a tree that crosses it.""" - - def test_a_layer_reaching_the_interface_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / APPLICATION / "logic", "direct.py", VISUAL_IMPORT) - - assert reported(tmp_path) == ["dearpygui"] - - def test_a_service_contract_stays_reachable(self, tmp_path: Path) -> None: - """A layer reads another layer's data contract while its implementation stays out of reach.""" - write_module(tmp_path / APPLICATION / "logic", "direct.py", CONTRACT_IMPORT) - - assert reported(tmp_path) == [] - - def test_the_reconstruction_engine_reaching_the_console_player_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / CORE / "formats", "player.py", PLAYER_IMPORT) - - assert reported(tmp_path) == [PLAYER] - - def test_the_console_player_reading_the_engine_is_left_alone(self, tmp_path: Path) -> None: - write_module(tmp_path / PLAYER / "nsf", "file.py", PLAIN_IMPORT) - - assert reported(tmp_path) == [] - - def test_a_shipped_module_reaching_the_build_toolchain_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / PLAYER / "nsf", "file.py", ASSEMBLER_IMPORT) - - assert reported(tmp_path) == [ASSEMBLER] - - def test_the_build_toolchain_reads_the_driver_it_assembles(self, tmp_path: Path) -> None: - write_module(tmp_path / PLAYER / "driver" / "assembler", "builder.py", DRIVER_IMPORT) - - assert reported(tmp_path) == [] - - def test_a_panel_composing_a_column_suffix_is_reported(self, tmp_path: Path) -> None: - write_module(tmp_path / APPLICATION / "ui" / "panels", "left.py", PANEL_SUFFIX) - - assert len(reported(tmp_path)) == 1 - - -class TestRuleCoverage: - """A rule naming no module of the tree reads as a clean tree, so each one reaches something.""" - - def test_the_source_root_holds_modules(self) -> None: - assert swept_paths(SOURCE_ROOT) - - def test_every_boundary_rule_reaches_a_module(self) -> None: - assert all(reached_modules(rule) for rule in check_import_boundary.RULES) - - def test_every_token_rule_reaches_a_module(self) -> None: - rules = check_import_boundary.TOKEN_RULES - assert all(list((SOURCE_ROOT / rule.root).glob(rule.pattern)) for rule in rules) class TestMain: From e9f9b30a7db0f4ba16454c19cca73ed553c30344 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 15:01:21 +0200 Subject: [PATCH 033/142] Documented: the boundaries configuration domain --- docs/development/architecture.md | 2 +- docs/development/config-organization.md | 29 +++++++++++----- docs/development/packages.md | 34 +++++++++++-------- src/sampletones_config/README.md | 3 +- .../exports/implementation/bitphase.py | 4 +-- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 6a11ca688..126fcc46b 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -178,7 +178,7 @@ What DearPyGui has already taken a copy of is registered rather than remembered Two mechanisms keep the codebase aligned with this document. -**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. The same script holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. The script declares what the boundaries are; `sampletones_shared/meta/import_boundary/` holds how they are read and reported. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. +**Import-expressible contracts are enforced by a check.** `sampletones_config/boundaries/rules.yaml` states one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the configuration is itself a defect. The same domain holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. `sampletones_config/boundaries/` declares what the boundaries are, `sampletones_shared/meta/import_boundary/` holds how they are read and reported, and `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) runs them over the source tree. A rule names the prefixes it reaches through the groups `boundaries/general.yaml` declares, so the interface several layers stay clear of is written once and each rule names it. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule names the contracts group that stays in reach. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. **The identifier vocabularies are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target: diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md index fb4df14cb..1ecb9877c 100644 --- a/docs/development/config-organization.md +++ b/docs/development/config-organization.md @@ -10,7 +10,8 @@ is read; use it as the reference when adding or moving a value. It sits alongsid first: - **Shipped configuration** — the `sampletones_config` YAML package: layout, theme, - palettes, keybindings, language, behavior, deployment, and calibration. *(This document.)* + palettes, keybindings, language, behavior, deployment, calibration, and the import + boundaries. *(This document.)* - **Runtime user preferences** — mutable state persisted to the user profile (`sampletones_application/config`, e.g. `PlaybackConfig`, `ShortcutsConfig`, `ApplicationState`), governed by that package. @@ -32,8 +33,8 @@ empty `__init__.py`. Each schema lives with its reader: - `sampletones_application` owns the layout, theme, palettes, keybindings, language, behavior, and deployment schemas. - `sampletones_core` owns the calibration schemas. -- `sampletones_shared` owns the loader primitives (`load_yaml_model`, - `load_yaml_model_dir`). +- `sampletones_shared` owns the import-boundary schemas and the loader primitives + (`load_yaml_model`, `load_yaml_model_dir`). So the data carries the values and the consumer carries the meaning, and the two evolve on their own terms. @@ -41,9 +42,10 @@ on their own terms. ### 2. The top level is organized by domain `sampletones_config` has one top-level directory per schema family and its loader: -`application`, `behavior`, `calibration`, `keybindings`, `lang`, `layout`, `palettes`, -`theme`. Each domain owns its schema and its load path (see [Domains](#domains)). A new domain -is a new top-level directory with its own schema owner and loader. +`application`, `behavior`, `boundaries`, `calibration`, `keybindings`, `lang`, `layout`, +`palettes`, `theme`. Each domain owns its schema and its load path (see +[Domains](#domains)). A new domain is a new top-level directory with its own schema owner +and loader. Palettes are a domain of their own because two other domains resolve against them: a colour field in `layout/` and a colour entry in `theme/` both name a palette token, and the palette @@ -130,6 +132,7 @@ each value sits in the tree stays in the factory. |--------|-----------|--------------|------------------| | Application | `application/` | `DeploymentConfig` (`sampletones_application/config/deployment/`) | `DeploymentConfig.load()`, with `SAMPLETONES_*` env overrides | | Behavior | `behavior/` | `BehaviorConfig` (`sampletones_application/layout/behavior.py`) | folded into `LayoutConfig.behavior` by `load_layout_config` | +| Boundaries | `boundaries/` | `ImportBoundaryRules` (`sampletones_shared/meta/import_boundary/configs/`) | `ImportBoundaryRules.load()` | | Calibration | `calibration/` | `CorpusConfig`, `RefereeConfig` (`sampletones_core/calibration/config/`) | each model's own `.load()` | | Keybindings | `keybindings/` | `ShortcutScheme` (`sampletones_application/utils/gui/shortcuts/`) | `ShortcutCatalog.load()`, indexed by scheme name | | Language | `lang/` | `LanguageManager` (`sampletones_application/categories/`) | flat string map keyed `page.panel.text_type.element`, each key validated at load | @@ -165,6 +168,14 @@ consumers reach it as `layout.behavior.*`. A single access path serves the ~15 r sites across `application.py` and the tab coordinators that read it, and the ~10 modules that import `SchedulingBehavior` as a type. +Boundaries is the domain a developer tool reads. It states the layer graphs the packages +divide into, the imports each part of the application stays clear of, and the spellings a +tree keeps out, and `scripts/checks/import_boundary.py` runs it over the source tree on every +commit. A declaration draws on the named prefix groups `general.yaml` holds, so a set several +rules reach for is written once and each rule names it, and a name reaching no group is +refused as the domain is read. The bundle carries the domain because `--add-data` copies +`sampletones_config` whole — the terms `calibration/` already ships on. + --- ## Loading @@ -189,6 +200,6 @@ Three load mechanisms serve the three grouping schemes: file on disk. `ShortcutCatalog.load()` reads `keybindings/` the same way, keyed by `ShortcutScheme.name`. -Deployment and calibration each load through a bespoke `.load()` classmethod over -the same low-level primitives in `sampletones_shared/utils/serialization.py` — the one -module that calls `yaml.safe_load`. +Deployment, calibration and the boundaries each load through a bespoke `.load()` +classmethod over the same low-level primitives in +`sampletones_shared/utils/serialization.py` — the one module that calls `yaml.safe_load`. diff --git a/docs/development/packages.md b/docs/development/packages.md index 294904baa..3fbfa745b 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -2,8 +2,9 @@ _SampleToNES_ is one repository holding several packages under `src/`, ordered so that dependencies run one way. This document states that order, what each package is for, and how the console player -is layered inside it. It is prescriptive: `scripts/checks/import_boundary.py` holds the source tree -to these tables on every commit, and a divergence between them and the script is itself a defect. +is layered inside it. It is prescriptive: `sampletones_config/boundaries/graphs.yaml` restates these +tables in the form the import-boundary check runs on every commit, and a divergence between this +document and that configuration is itself a defect. The layering of `sampletones_application` has its own document, [`architecture.md`](architecture.md), which the same check enforces. @@ -39,8 +40,8 @@ graph TD | Package | Purpose | May import | |---------|---------|------------| -| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the source layer the checks read the tree through | — | -| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, calibration — reached as package data rather than by import | — | +| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, the source layer the checks read the tree through, and the schema these boundaries are declared in | — | +| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, calibration, and these boundaries themselves — reached as package data rather than by import | — | | `sampletones_assets` | The application mark and the bundled fonts, with the code that draws the mark | `sampletones_shared` | | `sampletones_synthesis` | Analytic waveform synthesis: oscillators, envelopes, layers and voices | `sampletones_shared` | | `sampletones_core` | The reconstruction engine, the project model, and the tracker export formats | `sampletones_shared`, `sampletones_synthesis` | @@ -95,13 +96,18 @@ copy, and no unit above declares it. The developer toolchain it needs is describ ## Enforcement -`scripts/checks/import_boundary.py` declares both graphs as layer tables — each unit and the units -it may import — and derives the rule it runs from them: every unit a table leaves out is out of -reach, so an edge is declared before it is taken. The hook audits the whole source tree on every -commit (`make check-import-boundary`), which means adding an edge to a table is how a new dependency -is opened, and removing one enumerates the work of closing it. - -The script is the declaration alone. Reading a module line by line, resolving a unit to the modules -it owns, deriving a rule from a graph and reporting what crosses it live in -`sampletones_shared/meta/import_boundary/`, beside the source layer the other checks read the tree -through. +`sampletones_config/boundaries/graphs.yaml` declares both graphs as layer tables — each unit and the +units it may import — and the rule the check runs derives from them: every unit a table leaves out is +out of reach, so an edge is declared before it is taken. The hook audits the whole source tree on +every commit (`make check-import-boundary`), which means adding an edge to a table is how a new +dependency is opened, and removing one enumerates the work of closing it. + +A graph answers for its own well-formedness as it is read: a unit reaching a unit the graph leaves +undeclared is refused, and so is a graph whose units reach themselves, since a unit's layers state a +level only where the units stand in an order. + +Three parts share the work. `sampletones_config/boundaries/` states what the boundaries are. +`sampletones_shared/meta/import_boundary/` validates that statement and holds the mechanism — +reading a module line by line, resolving a unit to the modules it owns, deriving a rule from a graph +and reporting what crosses it — beside the source layer the other checks read the tree through. +`scripts/checks/import_boundary.py` runs them over a source tree and prints what they find. diff --git a/src/sampletones_config/README.md b/src/sampletones_config/README.md index e54fd9655..37addfdcd 100644 --- a/src/sampletones_config/README.md +++ b/src/sampletones_config/README.md @@ -9,7 +9,7 @@ The schema that validates each file lives in the **consuming** package: - `sampletones_application` — layout, theme, palettes, language, behavior, deployment. - `sampletones_core` — calibration. -- `sampletones_shared` — the loader primitives only. +- `sampletones_shared` — the import boundaries and the loader primitives. The data package must not import a schema, and a schema package must not inline data. @@ -19,6 +19,7 @@ The data package must not import a schema, and a schema package must not inline |-----------|---------|--------------| | `application/` | Deployment-time environment knobs | `DeploymentConfig` | | `behavior/` | Non-visual runtime behavior | `BehaviorConfig` | +| `boundaries/` | The imports the source tree is held to | `ImportBoundaryRules` | | `calibration/` | DSP calibration tuning | `CorpusConfig`, `RefereeConfig` | | `keybindings/` | The key combinations each named action answers | `ShortcutScheme` | | `lang/` | Interface strings (i18n) | `LanguageManager` | diff --git a/src/sampletones_core/exports/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py index d154062f8..a267234df 100644 --- a/src/sampletones_core/exports/implementation/bitphase.py +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -42,7 +42,7 @@ def export_format(self) -> ExportFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: return DOCUMENT_SCOPES - def extension(self, scope: ExportScope) -> str: + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument return EXT_FILE_BITPHASE def write_instrument( @@ -87,7 +87,7 @@ def export_format(self) -> ExportFormat: def supported_scopes(self) -> FrozenSet[ExportScope]: return PRESET_SCOPES - def extension(self, scope: ExportScope) -> str: + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument return EXT_FILE_JSON def write_instrument( From a6d3b3764d27d6b6a1096b8b9000bd288079a835 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 18:05:42 +0200 Subject: [PATCH 034/142] Stems: unify conversion around the stems pipeline --- .../coordinators/reconstruction.py | 2 +- .../logic/reconstruction/audio_location.py | 4 +- .../logic/reconstruction/data.py | 55 ++---- .../logic/reconstruction/manager.py | 2 +- .../logic/reconstruction/reconstruction.py | 59 ++----- src/sampletones_core/compatibility/fields.py | 12 ++ .../compatibility/reconstruction/v2_2.py | 166 ++++++++++++------ src/sampletones_core/constants/algorithm.py | 3 +- src/sampletones_core/data/model.py | 27 +-- .../reconstructions/naming/derive.py | 20 ++- .../naming/rules/common_prefix.py | 21 +++ .../naming/rules/first_source.py | 12 ++ .../reconstruction/reconstruction.py | 69 ++++---- .../reconstruction/stems/data.py | 20 +++ .../reconstruction/stems/filter.py | 5 +- .../reconstructor/reconstructor.py | 23 +-- .../reconstructor/stems/configs/config.py | 21 +++ .../test_stems_reconstruction.py | 55 +++++- .../services/conftest.py | 7 +- tests/suite/player.py | 7 +- tests/suite/sequencer.py | 16 +- tests/suite/stems.py | 23 +++ .../logic/project/test_controller.py | 8 +- .../logic/reconstruction/test_data.py | 20 +-- .../logic/reconstruction/test_manager.py | 31 +++- .../reconstruction/test_reconstruction.py | 2 +- .../logic/sequencer/playback/conftest.py | 19 +- .../logic/sequencer/playback/test_voice.py | 10 +- .../sampletones_application/test_startup.py | 2 +- .../compatibility/reconstruction/test_v2_2.py | 44 ++++- .../formats/bitphase/test_project_builder.py | 4 +- .../formats/famitracker/conftest.py | 4 +- .../reconstructions/naming/test_naming.py | 31 +++- .../reconstruction/test_reconstruction.py | 115 ++++++++++-- 34 files changed, 647 insertions(+), 272 deletions(-) create mode 100644 src/sampletones_core/reconstructions/naming/rules/common_prefix.py create mode 100644 src/sampletones_core/reconstructions/naming/rules/first_source.py create mode 100644 tests/suite/stems.py diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 5e0f8a893..b66c0f5a5 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -291,7 +291,7 @@ def on_reconstruction_loaded(self) -> None: raise RuntimeError("No reconstruction is loaded after loading process") self._audio_device_manager.stop() - missing_path = first_missing(reconstruction_data.reconstruction.source_paths) + missing_path = first_missing(reconstruction_data.reconstruction.audio_filepath) if missing_path is not None: self._dialogs.show_file_not_found( missing_path, diff --git a/src/sampletones_application/logic/reconstruction/audio_location.py b/src/sampletones_application/logic/reconstruction/audio_location.py index c587c7f75..20c5b9fa3 100644 --- a/src/sampletones_application/logic/reconstruction/audio_location.py +++ b/src/sampletones_application/logic/reconstruction/audio_location.py @@ -1,10 +1,10 @@ from pathlib import Path -from typing import Optional, Tuple, Union +from typing import Tuple from sampletones_core.reconstructions import Reconstruction -def resolve_original_audio(filepath: Path) -> Optional[Union[Path, Tuple[Path, ...]]]: +def resolve_original_audio(filepath: Path) -> Tuple[Path, ...]: """Reads a browsed reconstruction to recover the original audio location it records.""" reconstruction = Reconstruction.load(filepath) return reconstruction.audio_filepath diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index bd9ab7f8e..9b928eb94 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -113,9 +113,9 @@ def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str: source paths (stems) name the document through the source-naming rules, and a detached reconstruction (no source audio) falls back to the ``.stn`` filename. """ - source_paths = reconstruction.source_paths + source_paths = reconstruction.audio_filepath if source_paths: - return derive_name(source_paths, fallback_stem=filepath.stem) + return derive_name(source_paths) return filepath.stem @@ -131,7 +131,7 @@ def _load_stem_audios( come back as one empty tuple in either case; the approximation then stands on its own in playback and the display. """ - source_paths = reconstruction.source_paths + source_paths = reconstruction.audio_filepath if not source_paths: return () @@ -162,18 +162,14 @@ def original_audio(self) -> Optional[np.ndarray]: def _stem_recording_indexes(self) -> Dict[int, int]: """Maps each stem id to the index of its recording in ``stem_audios``. - A stems reconstruction maps the entries' ids to their recordings in entry order, a - single source presents one implicit stem (id 0) holding its recording, and source - audio absent or unreadable maps nothing. + The entries' ids map to their recordings in entry order, and source audio absent + or unreadable maps nothing. """ if not self.stem_audios: return {} stems_data = self.reconstruction.stems_data - if stems_data is not None: - return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} - - return {0: 0} + return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} def original_mix_for(self, selected_stem_ids: AbstractSet[int]) -> np.ndarray: """The original audio of the selected stems, silence once none are selected.""" @@ -196,11 +192,10 @@ def waveform_data( if selected_stem_ids is None: return self._unfiltered_waveform() - stems_data = self.reconstruction.stems_data - if stems_data is None: - return self._single_source_waveform(selected_stem_ids) - - return self._filtered_waveform(selected_stem_ids, stems_data) + return self._filtered_waveform( + selected_stem_ids, + self.reconstruction.stems_data, + ) def _unfiltered_waveform(self) -> WaveformData: """The whole document: every channel's stored approximation and the full original.""" @@ -210,17 +205,6 @@ def _unfiltered_waveform(self) -> WaveformData: self.reconstruction.approximation, ) - def _single_source_waveform(self, selected_stem_ids: AbstractSet[int]) -> WaveformData: - """The projection of a reconstruction that records no stems assignment. - - A recorded source with its one implicit stem unselected projects silence; every other - selection projects the whole document. - """ - if self.reconstruction.source_paths and not selected_stem_ids: - return self._silenced_waveform() - - return self._unfiltered_waveform() - def _filtered_waveform( self, selected_stem_ids: AbstractSet[int], @@ -253,19 +237,6 @@ def _waveform_data( frame_length=self.reconstruction.config.frame_length, ) - def _silenced_waveform(self) -> WaveformData: - """A projection of silence in the shape of the reconstruction.""" - approximation = self.reconstruction.approximation - return WaveformData( - original_audio=np.zeros_like(approximation), - approximation=np.zeros_like(approximation), - approximations={ - channel: np.zeros_like(audio) for channel, audio in self.reconstruction.approximations.items() - }, - coefficient=self.reconstruction.coefficient, - frame_length=self.reconstruction.config.frame_length, - ) - def get_partials(self, channel_names: List[ChannelName]) -> np.ndarray: return self.waveform_data().partials(channel_names) @@ -274,9 +245,5 @@ def partials_for( channel_names: List[ChannelName], selected_stem_ids: AbstractSet[int], ) -> np.ndarray: - """Sums the selected channels with the unselected stems' frames silenced. - - A single source with its one stem unselected is silence, and a reconstruction - recording no source answers its full approximation. - """ + """Sums the selected channels with the unselected stems' frames silenced.""" return self.waveform_data(selected_stem_ids).partials(channel_names) diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index df05dacbb..88b8687d9 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -220,4 +220,4 @@ def source_paths(self) -> Tuple[Path, ...]: if self._current_reconstruction is None: return () - return self._current_reconstruction.reconstruction.source_paths + return self._current_reconstruction.reconstruction.audio_filepath diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index b80efc6bf..cc2c29b4b 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -265,14 +265,7 @@ def _adopt_selected_stems(self, stem_ids: FrozenSet[int]) -> None: def _all_stem_ids( reconstruction_data: ReconstructionData, ) -> FrozenSet[int]: - stems_data = reconstruction_data.reconstruction.stems_data - if stems_data is not None: - return frozenset(entry.id for entry in stems_data.config.entries) - - if reconstruction_data.reconstruction.source_paths: - return frozenset({0}) - - return frozenset() + return frozenset(entry.id for entry in reconstruction_data.reconstruction.stems_data.config.entries) def _build_stems_view_model( self, @@ -280,45 +273,29 @@ def _build_stems_view_model( ) -> ReconstructionStemsViewModel: reconstruction = reconstruction_data.reconstruction stems_data = reconstruction.stems_data - if stems_data is not None: - assigned_stem_ids = { - stem_id for stem_ids in stems_data.assignments_by_channel.values() for stem_id in stem_ids - } - rows = tuple( - StemViewModel( - stem_id=entry.id, - label=reconstruction.source_paths[index].name, - channels=tuple(entry.channels), - enabled=entry.id in assigned_stem_ids, - selected=entry.id in self._selected_stems, - ) - for index, entry in enumerate(stems_data.config.entries) - ) + source_paths = reconstruction.audio_filepath + if not source_paths: return ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=rows, - hierarchy_mode=stems_data.config.hierarchy.mode, - channel_cap=stems_data.config.channel_cap, + stems=(), ) - source_paths = reconstruction.source_paths - if source_paths: - return ReconstructionStemsViewModel( - reconstruction_loaded=True, - stems=( - StemViewModel( - stem_id=0, - label=source_paths[0].name, - channels=tuple(reconstruction.playing_channels), - enabled=True, - selected=0 in self._selected_stems, - ), - ), + assigned_stem_ids = {stem_id for stem_ids in stems_data.assignments_by_channel.values() for stem_id in stem_ids} + rows = tuple( + StemViewModel( + stem_id=entry.id, + label=source_paths[index].name, + channels=tuple(entry.channels), + enabled=entry.id in assigned_stem_ids, + selected=entry.id in self._selected_stems, ) - + for index, entry in enumerate(stems_data.config.entries) + ) return ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=(), + stems=rows, + hierarchy_mode=stems_data.config.hierarchy.mode, + channel_cap=stems_data.config.channel_cap, ) def request_export_instrument_dialog( @@ -594,7 +571,7 @@ def _build_path_view_models( """ reconstruction_file = self._build_file_path_view_model(reconstruction_data.filepath) original_audio = self._build_audio_path_view_model( - reconstruction_data.reconstruction.source_paths, + reconstruction_data.reconstruction.audio_filepath, reconstruction_data.original_audio, ) return reconstruction_file, original_audio diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index ce13d8947..2fec290a1 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -5,10 +5,22 @@ GENERATION: Final = "generation" GENERATORS: Final = "generators" +AUDIO_FILEPATH: Final = "audio_filepath" INSTRUCTIONS_DATA: Final = "instructions_data" APPROXIMATIONS_DATA: Final = "approximations_data" RECONSTRUCTION_DATA_VERSION: Final = "reconstruction_data_version" +STEMS_DATA: Final = "stems_data" +ENTRIES: Final = "entries" +ID: Final = "id" +HIERARCHY: Final = "hierarchy" +LEVELS: Final = "levels" +MODE: Final = "mode" +CHANNEL_CAP: Final = "channel_cap" +ASSIGNMENTS: Final = "assignments" +STEM_IDS: Final = "stem_ids" +INSTRUCTIONS: Final = "instructions" + CHANNELS: Final = "channels" GENERATOR: Final = "generator" NAME: Final = "name" diff --git a/src/sampletones_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py index c89c2b1bb..38eeb0cc8 100644 --- a/src/sampletones_core/compatibility/reconstruction/v2_2.py +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -1,20 +1,35 @@ -from typing import Final +from typing import Any, Final from sampletones_core.compatibility.fields import ( APPROXIMATIONS_DATA, + ASSIGNMENTS, + AUDIO_FILEPATH, + CHANNEL_CAP, CHANNEL_NAME, CHANNELS, CONFIG, + ENTRIES, GENERATION, GENERATOR_NAME, GENERATORS, + HIERARCHY, + ID, + INSTRUCTIONS, INSTRUCTIONS_DATA, + LEVELS, METADATA, + MODE, RECONSTRUCTION_DATA_VERSION, + STEM_IDS, + STEMS_DATA, ) from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.compatibility.update import VersionUpdate from sampletones_core.compatibility.utils import renamed +from sampletones_core.constants.algorithm import ( + DEFAULT_STEMS_CHANNEL_CAP, + DEFAULT_STEMS_HIERARCHY_MODE, +) from sampletones_shared.deployment.version import Version from sampletones_shared.types.data import SerializedData @@ -22,63 +37,114 @@ TARGET_DATA_VERSION: Final[str] = "2.2" +def _normalized_audio_filepath(data: SerializedData) -> Any: + raw = data.get(AUDIO_FILEPATH) + if raw is None: + return [] + + if isinstance(raw, list): + return raw + + return [raw] + + +def _default_stems_data(data: SerializedData) -> SerializedData: + """The single-entry stems record a conversion predating stems carries. + + One stem covers every enabled channel and owns every frame of each channel that plays, + which is the classic run's shape, so the synthesized record states what the + reconstruction is. + """ + config = data.get(CONFIG) + channels = config.get(GENERATION, {}).get(CHANNELS, []) if isinstance(config, dict) else [] + instructions_data = data.get(INSTRUCTIONS_DATA) + stream_items = instructions_data if isinstance(instructions_data, list) else [] + assignments = [ + { + CHANNEL_NAME: item.get(CHANNEL_NAME), + STEM_IDS: [0] * len(item.get(INSTRUCTIONS, [])), + } + for item in stream_items + if isinstance(item, dict) and item.get(INSTRUCTIONS) + ] + return { + CONFIG: { + ENTRIES: [{ID: 0, CHANNELS: channels}], + HIERARCHY: {LEVELS: [[0]], MODE: str(DEFAULT_STEMS_HIERARCHY_MODE)}, + CHANNEL_CAP: DEFAULT_STEMS_CHANNEL_CAP, + }, + ASSIGNMENTS: assignments, + } + + +def _renamed_stream_keys(data: SerializedData) -> SerializedData: + """The stream and approximation sections keyed by channel name.""" + updated = dict(data) + for section in (APPROXIMATIONS_DATA, INSTRUCTIONS_DATA): + entries = data.get(section) + if isinstance(entries, list): + updated[section] = [renamed(item, GENERATOR_NAME, CHANNEL_NAME) for item in entries] + + return updated + + +def _stamped_embedded_config(data: SerializedData) -> SerializedData: + """The embedded config named by channel and stamped with the target version.""" + updated = dict(data) + config = data.get(CONFIG) + if not isinstance(config, dict): + return updated + + updated_config = dict(config) + metadata = config.get(METADATA) + if isinstance(metadata, dict) and isinstance( + metadata.get(RECONSTRUCTION_DATA_VERSION), + str, + ): + updated_config[METADATA] = { + **metadata, + RECONSTRUCTION_DATA_VERSION: TARGET_DATA_VERSION, + } + + generation = config.get(GENERATION) + if isinstance(generation, dict): + updated_config[GENERATION] = renamed(generation, GENERATORS, CHANNELS) + + updated[CONFIG] = updated_config + return updated + + +def _normalized_source_paths(data: SerializedData) -> SerializedData: + """The recorded source audio as one path per stem.""" + updated = dict(data) + updated[AUDIO_FILEPATH] = _normalized_audio_filepath(data) + return updated + + +def _with_default_stems_record(data: SerializedData) -> SerializedData: + """The single-entry stems record, present on every reconstruction.""" + updated = dict(data) + if STEMS_DATA not in updated: + updated[STEMS_DATA] = _default_stems_data(updated) + + return updated + + def update(data: SerializedData) -> SerializedData: """Names each stored stream and approximation by its channel. Data version 2.1 stored a channel's stream and approximation under the key ``generator_name`` and the channel selection under ``config.generation.generators``. Data version 2.2 names them ``channel_name`` - and ``config.generation.channels``, and stamps the embedded config's metadata - with the new data version, since the load contract holds every metadata block - to it. + and ``config.generation.channels``, stamps the embedded config's metadata with the + new data version, records the source audio as one path per stem, and carries the + single-entry stems record every reconstruction states. """ updated = dict(data) - approximations = data.get(APPROXIMATIONS_DATA) - if isinstance(approximations, list): - updated[APPROXIMATIONS_DATA] = [ - renamed( - item, - GENERATOR_NAME, - CHANNEL_NAME, - ) - for item in approximations - ] - - instructions = data.get(INSTRUCTIONS_DATA) - if isinstance(instructions, list): - updated[INSTRUCTIONS_DATA] = [ - renamed( - item, - GENERATOR_NAME, - CHANNEL_NAME, - ) - for item in instructions - ] - - config = data.get(CONFIG) - if isinstance(config, dict): - updated_config = dict(config) - metadata = config.get(METADATA) - if isinstance(metadata, dict) and isinstance( - metadata.get(RECONSTRUCTION_DATA_VERSION), - str, - ): - updated_config[METADATA] = { - **metadata, - RECONSTRUCTION_DATA_VERSION: TARGET_DATA_VERSION, - } - - generation = config.get(GENERATION) - if isinstance(generation, dict): - updated_config[GENERATION] = renamed( - generation, - GENERATORS, - CHANNELS, - ) - - updated[CONFIG] = updated_config - - return updated + updated = _renamed_stream_keys(updated) + updated = _stamped_embedded_config(updated) + updated = _normalized_source_paths(updated) + return _with_default_stems_record(updated) V2_2: Final[VersionUpdate] = VersionUpdate( diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index f9a3a3e46..8ad90fbfa 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -65,7 +65,8 @@ # Stems assignment -DEFAULT_STEMS_CHANNEL_CAP: Final[int] = len(ChannelName) +ALL_STEMS_CHANNEL_CAP: Final[int] = len(ChannelName) +DEFAULT_STEMS_CHANNEL_CAP: Final[int] = ALL_STEMS_CHANNEL_CAP DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN # Execution diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index 916775577..051c1a177 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -139,12 +139,6 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: return self._pack_value(value, optional_inner, field_name) - if isinstance(value, Path): - return str(value) - - if isinstance(value, tuple): - return [str(path) for path in value] - return self._pack_union(value) if isinstance(annotation, TypeVar): @@ -153,6 +147,17 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: if get_origin(annotation) is list: return self._pack_list(value, field_name) + if get_origin(annotation) is tuple: + item_class = get_args(annotation)[0] + return [ + self._pack_value( + item, + item_class, + field_name, + ) + for item in value + ] + if issubclass(annotation, DataModel): return value.serialize_inner() @@ -196,12 +201,6 @@ def _unpack_value( fast, ) - if isinstance(raw, list): - return tuple(Path(item) for item in raw) - - if isinstance(raw, str): - return Path(raw) - return cls._unpack_union(raw) if isinstance(annotation, TypeVar): @@ -217,6 +216,10 @@ def _unpack_value( fast, ) + if get_origin(annotation) is tuple: + item_class = get_args(annotation)[0] + return tuple(cls._unpack_value(item, item_class, field_name, validation, fast) for item in raw) + if issubclass(annotation, DataModel): return annotation.deserialize_inner(raw, validation, fast=fast) diff --git a/src/sampletones_core/reconstructions/naming/derive.py b/src/sampletones_core/reconstructions/naming/derive.py index 3164359c3..d9bee9d78 100644 --- a/src/sampletones_core/reconstructions/naming/derive.py +++ b/src/sampletones_core/reconstructions/naming/derive.py @@ -3,28 +3,34 @@ from .protocol import NameRule from .rules.common_directory import CommonDirectoryRule +from .rules.common_prefix import CommonPrefixRule +from .rules.first_source import FirstSourceRule from .rules.single_source import SingleSourceRule SOURCE_RULES: Final[Tuple[Type[NameRule], ...]] = ( SingleSourceRule, CommonDirectoryRule, + CommonPrefixRule, + FirstSourceRule, ) def derive_name( source_paths: Tuple[Path, ...], - *, - fallback_stem: str, ) -> str: - """Names a reconstruction from its sources, falling back through the rule hierarchy. + """Names a reconstruction from its sources through the rule hierarchy. - The first rule that applies derives the name: one source names after itself, - several sources sharing one directory name after that directory, and the - caller-supplied fallback stem names every other set of sources. + The first rule that applies derives the name: one source names after itself, sources + sharing one directory name after that directory, sources sharing a deeper directory + name after the deepest one they share, and every other set names after its first + source. + + Raises: + ValueError: If the source set is empty. """ for rule_class in SOURCE_RULES: rule = rule_class() if rule.applies(source_paths): return rule.derive(source_paths) - return fallback_stem + raise ValueError("Source paths must hold at least one path") diff --git a/src/sampletones_core/reconstructions/naming/rules/common_prefix.py b/src/sampletones_core/reconstructions/naming/rules/common_prefix.py new file mode 100644 index 000000000..1e50cd91d --- /dev/null +++ b/src/sampletones_core/reconstructions/naming/rules/common_prefix.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path +from typing import Tuple + + +class CommonPrefixRule: + """Names a reconstruction after the filename prefix every source shares.""" + + def applies(self, source_paths: Tuple[Path, ...]) -> bool: + if len(source_paths) < 2: + return False + + return bool(self._common_prefix(source_paths)) + + def derive(self, source_paths: Tuple[Path, ...]) -> str: + return self._common_prefix(source_paths) + + @staticmethod + def _common_prefix(source_paths: Tuple[Path, ...]) -> str: + stems = [path.stem for path in source_paths] + return os.path.commonprefix(stems).rstrip("_- ") diff --git a/src/sampletones_core/reconstructions/naming/rules/first_source.py b/src/sampletones_core/reconstructions/naming/rules/first_source.py new file mode 100644 index 000000000..6152387a9 --- /dev/null +++ b/src/sampletones_core/reconstructions/naming/rules/first_source.py @@ -0,0 +1,12 @@ +from pathlib import Path +from typing import Tuple + + +class FirstSourceRule: + """Names a reconstruction after its first source recording.""" + + def applies(self, source_paths: Tuple[Path, ...]) -> bool: + return len(source_paths) >= 1 + + def derive(self, source_paths: Tuple[Path, ...]) -> str: + return source_paths[0].stem diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 780784d7d..462663723 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -14,12 +14,11 @@ Self, Sequence, Tuple, - Union, ) from uuid import uuid4 import numpy as np -from pydantic import ConfigDict, Field, ValidationError, field_serializer +from pydantic import ConfigDict, Field, ValidationError, field_serializer, model_validator from sampletones_core.audio.mixing import align, common_length, mix from sampletones_core.compatibility.kind import ObjectKind @@ -52,7 +51,6 @@ from sampletones_shared.types.data import SerializedData from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_binary, serialize_array -from sampletones_shared.utils.system.paths import to_paths RECONSTRUCTION_DATA_CONTRACT: Final[MetadataContract] = MetadataContract( label="Reconstruction data", @@ -72,11 +70,11 @@ class Reconstruction(DataModel): ..., description="Unique identifier for the reconstruction", ) - audio_filepath: Optional[Union[Path, Tuple[Path, ...]]] = Field( + audio_filepath: Tuple[Path, ...] = Field( ..., description=( - "Location of the source audio: one path for a single source, the stem paths " - "for a stems reconstruction, and None once detached from the local origin" + "Location of the source audio: one path per stems entry, in entry order, and " + "empty once detached from the local origin" ), ) config: Config = Field( @@ -96,19 +94,21 @@ class Reconstruction(DataModel): ..., description="Instructions per channel", ) - stems_data: Optional[StemsData] = Field( - None, - description="Stems assignment recorded when built from several stems", + stems_data: StemsData = Field( + ..., + description="The stems setup and per-frame assignment recorded by the conversion", ) coefficient: float = Field( ..., description="Normalization coefficient used during reconstruction", ) - @cached_property - def source_paths(self) -> Tuple[Path, ...]: - """The recorded source audio paths, empty while the reconstruction is detached.""" - return to_paths(self.audio_filepath) + @model_validator(mode="after") + def _validate_source_stem_parallel(self) -> Self: + if self.audio_filepath and len(self.audio_filepath) != len(self.stems_data.config.entries): + raise ValueError("The recorded source paths number one per stems entry") + + return self @cached_property def approximations(self) -> Dict[ChannelName, np.ndarray]: @@ -198,8 +198,8 @@ def create( instructions: Mapping[ChannelName, Sequence[InstructionUnion]], config: Config, coefficient: float, - audio_filepath: Union[Path, Tuple[Path, ...]], - stems_data: Optional[StemsData] = None, + audio_filepath: Tuple[Path, ...], + stems_data: StemsData, ) -> Self: approximation = np.nan_to_num(approximation, nan=0.0) approximations_data: List[ApproximationsItem] = [ @@ -244,14 +244,18 @@ def from_state( state: ReconstructionState, config: Config, coefficient: float, - path: Union[Path, Tuple[Path, ...]], - stems_data: Optional[StemsData] = None, + path: Tuple[Path, ...], + stems_data: StemsData, ) -> Optional[Self]: - if any(len(approximation) == 0 for approximation in state.approximations.values()): + if all(len(approximation) == 0 for approximation in state.approximations.values()): logger.warning(f"Reconstruction for file: {path} is empty") return None - approximations = {name: np.concatenate(state.approximations[name]) for name in state.approximations} + approximations = { + name: np.concatenate(state.approximations[name]) + for name in state.approximations + if state.approximations[name] + } approximation = mix(list(approximations.values())) return cls.create( @@ -281,7 +285,9 @@ def update_channel_data( The channel keeps its place among the streams however the edit leaves it, so one cleared of every frame stands by and stays editable. Its rendered audio lasts as - long as it carries samples, which keeps silence out of the stored waveforms. + long as it carries samples, which keeps silence out of the stored waveforms. The + edited channel leaves the stems record: the edit re-derives the stream, so the + conversion's per-frame ownership no longer applies to it. """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") rendered = {name: audio for name, audio in self.approximations.items() if name != channel_name} @@ -303,6 +309,10 @@ def update_channel_data( held_features=held_features, ) self.instructions_data = [streams[name] for name in ChannelName.items()] + self.stems_data = StemsData( + config=self.stems_data.config, + assignments=[item for item in self.stems_data.assignments if item.channel_name != channel_name], + ) self._invalidate_derived_caches(self) self.approximation = mix([item.approximation for item in self.approximations_data]) @@ -316,12 +326,11 @@ def detach_source(self) -> None: """Drops the local source-audio location so the reconstruction becomes self-contained. Embedding a reconstruction in a project makes it part of a shareable artifact, where an - absolute path to the author's machine carries no meaning. Clearing ``audio_filepath`` keeps - the reconstruction — its approximation and instructions — intact while removing the local - origin, so a saved project stays portable. + absolute path to the author's machine carries no meaning. Emptying ``audio_filepath`` + keeps the reconstruction — its approximation and instructions — intact while removing the + local origin, so a saved project stays portable. """ - self.audio_filepath = None - self.__dict__.pop("source_paths", None) + self.audio_filepath = () def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: """Returns a copy retuned to ``nes_frequency`` by re-rendering its audio. @@ -490,13 +499,7 @@ def _serialize_approximation( @field_serializer("audio_filepath") def _serialize_audio_filepath( self, - audio_filepath: Optional[Union[Path, Tuple[Path, ...]]], + audio_filepath: Tuple[Path, ...], _info: Any, - ) -> Optional[Union[str, List[str]]]: - if audio_filepath is None: - return None - - if isinstance(audio_filepath, Path): - return str(audio_filepath) - + ) -> List[str]: return [str(path) for path in audio_filepath] diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py index 208e0d9f2..11ed4c2e8 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/data.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from functools import cached_property from typing import Dict, List from pydantic import ConfigDict, Field +from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -21,6 +24,23 @@ class StemsData(DataModel): description="Per channel, the stem holding each frame", ) + @classmethod + def single_entry( + cls, + channels: List[ChannelName], + assignments: List[ChannelAssignment], + *, + channel_cap: int = ALL_STEMS_CHANNEL_CAP, + ) -> StemsData: + """The record of one stem covering ``channels`` under ``channel_cap``.""" + return cls( + config=StemsConfig.single_entry( + channels, + channel_cap=channel_cap, + ), + assignments=assignments, + ) + @cached_property def assignments_by_channel(self) -> Dict[ChannelName, List[int]]: """The per-frame stem ids each channel carries, keyed by channel.""" diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py index 6f27770d4..80ff5f273 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py @@ -19,13 +19,16 @@ def filter_approximations( every other frame keeps its samples. The arrays keep their lengths, which is what aligns a filtered mix with the unfiltered one sample for sample. Every selected stem answers the original arrays, and channels the stems data names come back filtered. + The mask covers the frames the stored array holds; samples past the last recorded + frame keep their values. """ filtered: Dict[ChannelName, np.ndarray] = {} for channel, stem_ids in stems_data.assignments_by_channel.items(): approximation = approximations[channel] keep = np.isin(np.array(stem_ids, dtype=int), list(selected_stem_ids)) + keep_samples = np.repeat(keep, frame_length) masked = np.array(approximation, copy=True) - masked[~np.repeat(keep, frame_length)] = 0 + masked[~keep_samples[: len(masked)]] = 0 filtered[channel] = masked return filtered diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 930275567..195d54835 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -5,7 +5,9 @@ from sampletones_core.audio import active_frame_level, load_audio, mix from sampletones_core.configs import Config -from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL +from sampletones_core.constants.algorithm import ( + MINIMUM_AUDIO_LEVEL, +) from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import FragmentedAudio, Window from sampletones_core.generators import ( @@ -103,8 +105,10 @@ def __init__( def __call__(self, path: Pathlike) -> Optional[Reconstruction]: """Reconstructs an audio file into a :class:`Reconstruction`. - Loads and normalizes the audio, frames it, matches every frame against the - library, and assembles the chosen instructions into a reconstruction. + The classic run is the stems pipeline's single-stem case: one stem covering + every enabled channel on one precedence level, with the cap at the channel + count. The cap equals the channel count, so the greedy baseline plays + unchanged. Args: path: Path to the audio file to reconstruct. @@ -115,17 +119,8 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: Raises: TypeError: If ``path`` is not a string or ``Path``. """ - if not isinstance(path, (str, Path)): - raise TypeError("Input must be a path to an audio file") - - path = to_path(path) - audio = self.load_audio(path) - self.reset_generators() - self.state = ReconstructionState.create(list(self.channels.keys())) - coefficient = self.get_coefficient(audio) - fragmented_audio = self.get_fragments(audio / coefficient) - self.reconstruct(fragmented_audio) - return Reconstruction.from_state(self.state, self.config, coefficient, path) + stems_config = StemsConfig.single_entry(list(self.config.generation.channels)) + return self.reconstruct_stems([path], stems_config) def reconstruct_stems( self, diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py index 1fef36ffe..ce8f46fce 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -3,8 +3,10 @@ from pydantic import ConfigDict, Field, model_validator from sampletones_core.constants.algorithm import ( + ALL_STEMS_CHANNEL_CAP, DEFAULT_STEMS_CHANNEL_CAP, ) +from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy @@ -27,6 +29,25 @@ class StemsConfig(DataModel): description="The most channels one stem holds per frame", ) + @classmethod + def single_entry( + cls, + channels: List[ChannelName], + *, + channel_cap: int = ALL_STEMS_CHANNEL_CAP, + ) -> Self: + """The setup for one stem covering ``channels``, the classic run's shape. + + One entry holding every channel on a single precedence level reproduces the classic + greedy pick when the cap equals the channel count, so this setup describes both a + single-file conversion and the stems pipeline's simplest case. + """ + return cls( + entries=[StemEntry(id=0, channels=channels)], + hierarchy=StemsHierarchy(levels=[[0]]), + channel_cap=channel_cap, + ) + @model_validator(mode="after") def _validate_unique_entry_ids(self) -> Self: ids = [entry.id for entry in self.entries] diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 991ef0676..61119ad2c 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -7,6 +7,7 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_core.audio import load_audio, mix, write_wave from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -139,7 +140,7 @@ def test_round_trips_through_the_file(self, tmp_path: Path) -> None: loaded = Reconstruction.load(save_path) - assert loaded.source_paths == paths + assert loaded.audio_filepath == paths assert loaded.stems_data is not None assert loaded.stems_data.config == stems_config assert loaded.stems_data.assignments_by_channel == reconstruction.stems_data.assignments_by_channel @@ -256,7 +257,7 @@ def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> Non data = ReconstructionData.load(save_path) - assert data.reconstruction.source_paths == (tone_path, noise_path) + assert data.reconstruction.audio_filepath == (tone_path, noise_path) assert data.name == tmp_path.name load_options = { "target_sample_rate": config.library.sample_rate, @@ -271,3 +272,53 @@ def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> Non ) assert data.original_audio is not None np.testing.assert_allclose(data.original_audio, expected) + + +class TestClassicRunCarriesTheSingleEntryRecord: + """The classic single-file run is the stems pipeline's one-stem case.""" + + def _tone_path(self, tmp_path: Path, config: Config) -> Path: + sample_rate = config.library.sample_rate + count = int(sample_rate * _DURATION_SECONDS) + time = np.arange(count) / sample_rate + tone = 0.5 * np.sin(2 * np.pi * _TONE_FREQUENCY * time) + tone_path = tmp_path / "tone.wav" + write_wave(tone_path, sample_rate, tone) + return tone_path + + def test_classic_conversion_records_one_stem_over_every_enabled_channel(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + tone_path = self._tone_path(tmp_path, config) + + reconstruction = reconstructor(tone_path) + + assert reconstruction is not None + assert reconstruction.audio_filepath == (tone_path,) + stems_data = reconstruction.stems_data + assert stems_data.config.entries[0].id == 0 + assert stems_data.config.entries[0].channels == list(config.generation.channels) + assert stems_data.config.channel_cap == DEFAULT_STEMS_CHANNEL_CAP + for channel, stem_ids in stems_data.assignments_by_channel.items(): + assert set(stem_ids) <= {0} + assert len(stem_ids) == len(reconstruction.instructions[channel]) + + def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + tone_path = self._tone_path(tmp_path, config) + + reconstruction = reconstructor.reconstruct_stems( + [tone_path], + StemsConfig.single_entry(list(config.generation.channels), channel_cap=1), + ) + + assert reconstruction is not None + stems_data = reconstruction.stems_data + frame_count = int(config.library.sample_rate * _DURATION_SECONDS) // config.library.frame_length + assert sum(len(stem_ids) for stem_ids in stems_data.assignments_by_channel.values()) == frame_count + for stem_ids in stems_data.assignments_by_channel.values(): + assert set(stem_ids) <= {0} + assert len(stem_ids) <= frame_count diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index f72361893..ec6ee4b19 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -12,6 +12,7 @@ from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from tests.suite.application import synchronous_executor, synchronous_queue +from tests.suite.stems import single_entry_stems_data __all__ = ["synchronous_executor", "synchronous_queue"] @@ -49,7 +50,11 @@ def minimal_reconstruction(default_config, pulse_instructions) -> Reconstruction instructions={ChannelName.PULSE1: pulse_instructions}, config=default_config, coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(default_config.generation.channels), + {ChannelName.PULSE1: pulse_instructions}, + ), ) diff --git a/tests/suite/player.py b/tests/suite/player.py index 0b915800b..b227e44bc 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -27,6 +27,7 @@ TRIANGLE_SOUNDING_RELOAD, ) from sampletones_shared.utils.frequencies import pitch_to_frequency +from tests.suite.stems import single_entry_stems_data PLAYER_REFERENCE_TIMER: Final[int] = 0x154 PLAYER_OCTAVE_UP_TIMER: Final[int] = PLAYER_REFERENCE_TIMER // 2 @@ -142,11 +143,13 @@ def player_reconstruction( The audio itself is silent, since what a player test reads off a reconstruction is the instructions its channels carry and the rate they advance at. """ + config = Config().with_library(nes_frequency=nes_frequency) return Reconstruction.create( approximation=np.zeros(PLAYER_APPROXIMATION_SAMPLES, dtype=np.float32), approximations={}, instructions=instructions, - config=Config().with_library(nes_frequency=nes_frequency), + config=config, coefficient=1.0, - audio_filepath=Path(os.devnull), + audio_filepath=(Path(os.devnull),), + stems_data=single_entry_stems_data(list(config.generation.channels), instructions), ) diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index c35c9c131..7b7a2cc12 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -37,8 +37,8 @@ ) from sampletones_shared.constants.general import HEXADECIMAL_BASE from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS +from tests.suite.stems import single_entry_stems_data -SAMPLE_LENGTH: Final[int] = 64 SAMPLE_PITCH: Final[int] = 60 SAMPLE_VOLUME: Final[int] = 8 SAMPLE_PERIOD: Final[int] = 4 @@ -57,17 +57,21 @@ def sample_reconstruction(channels: Sequence[ChannelName]) -> Reconstruction: Each channel carries the instruction its own channel sounds, since the instruction type is what names the exporter a channel is read through — so a reading taken off this reconstruction - is the reading the channel gives. + is the reading the channel gives. The audio spans one frame per instruction, which keeps the + stems record the reconstruction carries parallel to the stored waveforms. """ + config = Config() + length = config.library.frame_length instructions = {channel: [_instruction(channel)] for channel in channels} - approximations = {channel: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for channel in channels} + approximations = {channel: np.zeros(length, dtype=np.float32) for channel in channels} return Reconstruction.create( - approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32), + approximation=np.zeros(length, dtype=np.float32), approximations=approximations, instructions=instructions, - config=Config(), + config=config, coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data(list(config.generation.channels), instructions), ) diff --git a/tests/suite/stems.py b/tests/suite/stems.py new file mode 100644 index 000000000..3768392e2 --- /dev/null +++ b/tests/suite/stems.py @@ -0,0 +1,23 @@ +from typing import List, Mapping, Sequence + +from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData + + +def single_entry_stems_data( + channels: List[ChannelName], + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], +) -> StemsData: + """The single-entry record for ``channels``, stem 0 owning each frame that plays.""" + assignments = [ + ChannelAssignment(channel_name=channel_name, stem_ids=[0] * len(stream)) + for channel_name, stream in instructions.items() + if stream + ] + return StemsData.single_entry( + channels, + assignments, + ) diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 62c4bd02f..d636cef4f 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -73,12 +73,12 @@ def test_add_sample_detaches_source_but_keeps_object_identity( ) -> None: controller = _controller() reconstruction = reconstruction_factory() - assert reconstruction.audio_filepath is not None + assert reconstruction.audio_filepath sample = controller.add_sample(reconstruction, name="lead") assert sample.reconstruction is reconstruction - assert sample.reconstruction.audio_filepath is None + assert sample.reconstruction.audio_filepath == () def test_remove_sample_purges_row_references( self, @@ -232,11 +232,11 @@ def test_replace_sample_reconstruction_detaches_source( controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") replacement = reconstruction_factory() - assert replacement.audio_filepath is not None + assert replacement.audio_filepath controller.replace_sample_reconstruction(sample.id, replacement) - assert replacement.audio_filepath is None + assert replacement.audio_filepath == () def test_replace_sample_reconstruction_preserves_row_references( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 60e998818..b08c9277a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -63,7 +63,7 @@ def test_detached_reconstruction_has_no_original_audio( name="Sample", ) - assert reconstruction.audio_filepath is None + assert reconstruction.audio_filepath == () assert data.original_audio is None def test_loads_original_audio_when_source_file_is_available( @@ -77,7 +77,7 @@ def test_loads_original_audio_when_source_file_is_available( Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5, ) - reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio}) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)}) data = ReconstructionData.from_reconstruction( reconstruction, @@ -217,8 +217,8 @@ def test_names_after_the_source_audio_when_present( copy = data.detached_copy(tmp_path / "lead.stn") - assert reconstruction.audio_filepath is not None - assert copy.name == reconstruction.audio_filepath.stem + assert reconstruction.audio_filepath + assert copy.name == reconstruction.audio_filepath[0].stem def test_names_after_the_shared_directory_of_stems( self, @@ -235,20 +235,18 @@ def test_names_after_the_shared_directory_of_stems( assert copy.name == "drums" - def test_names_after_the_file_when_stems_share_no_directory( + def test_names_after_the_first_source_when_stems_share_no_directory( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - (tmp_path / "one").mkdir() - (tmp_path / "two").mkdir() - stems = (tmp_path / "one" / "kick.wav", tmp_path / "two" / "snare.wav") + stems = (Path("/one/kick.wav"), Path("/two/snare.wav")) reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": stems}) data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") copy = data.detached_copy(tmp_path / "lead.stn") - assert copy.name == "lead" + assert copy.name == "kick" def test_reuses_the_already_loaded_original_audio( self, @@ -261,7 +259,7 @@ def test_reuses_the_already_loaded_original_audio( Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5, ) - reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio}) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)}) data = ReconstructionData.from_reconstruction( reconstruction, name="Sample", @@ -374,7 +372,7 @@ def test_a_single_source_with_no_selection_is_silence( ) -> None: source_audio = tmp_path / "source.wav" write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) - reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio}) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)}) data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index e33d67501..a9b168cbb 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -8,11 +8,30 @@ from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_shared.exceptions import LoadReconstructionError from tests.suite.errors import DIRECTORY_READ_ERRORS +from tests.suite.stems import single_entry_stems_data + + +def _two_entry_stems_data() -> StemsData: + return StemsData( + config=StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.PULSE1]), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + channel_cap=1, + ), + assignments=[], + ) class TestLoadReconstructionPropagatesErrors: @@ -319,7 +338,7 @@ def test_source_paths_return_reconstruction_source_paths( ) -> None: reconstruction = reconstruction_factory() reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") - assert reconstruction_manager.source_paths == reconstruction.source_paths + assert reconstruction_manager.source_paths == reconstruction.audio_filepath def test_current_features_is_populated_after_load( self, @@ -383,7 +402,11 @@ def test_locate_audio_raises_file_not_found_when_audio_missing( instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, config=Config(), coefficient=1.0, - audio_filepath=missing_path, + audio_filepath=(missing_path,), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + {ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, + ), ) reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") with pytest.raises(FileNotFoundError): @@ -411,6 +434,7 @@ def test_locate_audio_opens_the_recorded_paths( config=Config(), coefficient=1.0, audio_filepath=(first, second), + stems_data=_two_entry_stems_data(), ) reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") @@ -434,6 +458,7 @@ def test_locate_audio_reports_the_first_missing_path( config=Config(), coefficient=1.0, audio_filepath=(present, missing), + stems_data=_two_entry_stems_data(), ) reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index c3a0fd449..1472bfc3e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -128,7 +128,7 @@ def data_with_original_audio( Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5, ) - reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio}) + reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)}) return ReconstructionData.from_reconstruction(reconstruction, name="Sample") diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index b7367ba5b..e96c46dd6 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -20,6 +20,7 @@ from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction +from tests.suite.stems import single_entry_stems_data def make_controller() -> ProjectController: @@ -66,7 +67,11 @@ def make_pulse_reconstruction( instructions={ChannelName.PULSE1: instructions}, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + {ChannelName.PULSE1: instructions}, + ), ) if held_features: reconstruction.update_channel_data( @@ -92,7 +97,11 @@ def make_triangle_reconstruction( instructions={ChannelName.TRIANGLE: instructions}, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + {ChannelName.TRIANGLE: instructions}, + ), ) @@ -109,7 +118,11 @@ def make_noise_reconstruction( instructions={ChannelName.NOISE: instructions}, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + {ChannelName.NOISE: instructions}, + ), ) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py index b8fc725f5..6075eab78 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py @@ -19,6 +19,7 @@ from sampletones_core.reconstructions import Reconstruction from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.stems import single_entry_stems_data AUDIO_LENGTH: Final[int] = 64 REFERENCE_PITCH: Final[int] = 60 @@ -37,13 +38,18 @@ def _reconstruction( held_features: Iterable[FeatureKey], ) -> Reconstruction: """A one-channel reconstruction whose instrument leaves ``held_features`` to the channel.""" + instructions_list = list(instructions) reconstruction = Reconstruction.create( approximation=np.zeros(AUDIO_LENGTH, dtype=np.float32), approximations={channel_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)}, - instructions={channel_name: list(instructions)}, + instructions={channel_name: instructions_list}, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + {channel_name: instructions_list}, + ), ) reconstruction.update_channel_data( channel_name, diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 264d5db20..e236cf1bf 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -327,7 +327,7 @@ def test_embedded_sample_is_a_detached_copy( sample = app.project_manager.current.samples[0] assert sample.reconstruction is not app.reconstruction_manager.reconstruction - assert sample.reconstruction.audio_filepath is None + assert sample.reconstruction.audio_filepath == () assert not app._editing_project_sample() diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py index c1705c70e..192b18410 100644 --- a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -1,7 +1,15 @@ from typing import Any, Dict -from sampletones_core.compatibility.fields import CHANNEL_NAME, GENERATOR_NAME +from sampletones_core.compatibility.fields import ( + AUDIO_FILEPATH, + CHANNEL_NAME, + CHANNELS, + GENERATOR_NAME, + INSTRUCTIONS, + STEMS_DATA, +) from sampletones_core.compatibility.reconstruction.v2_2 import update +from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP def _stream(extra: Dict[str, Any]) -> Dict[str, Any]: @@ -51,7 +59,37 @@ def test_leaves_the_input_untouched(self) -> None: assert data["approximations_data"][0][GENERATOR_NAME] == "pulse1" assert data["instructions_data"][0][GENERATOR_NAME] == "pulse1" - def test_payload_without_known_sections_stays_the_same_shape(self) -> None: + def test_payload_without_known_sections_gains_the_stems_record(self) -> None: data = {"id": "abc"} - assert update(data) == data + upgraded = update(data) + + assert upgraded["id"] == "abc" + assert upgraded[AUDIO_FILEPATH] == [] + assert upgraded[STEMS_DATA]["config"]["entries"][0]["channels"] == [] + assert upgraded[STEMS_DATA]["assignments"] == [] + + def test_a_single_path_records_as_a_one_tuple(self) -> None: + data = {AUDIO_FILEPATH: "/audio/kick.wav"} + + upgraded = update(data) + + assert upgraded[AUDIO_FILEPATH] == ["/audio/kick.wav"] + + def test_a_file_without_stems_data_gains_the_single_entry_record(self) -> None: + data = { + "config": {"generation": {"channels": ["pulse1", "noise"]}}, + "instructions_data": [ + {CHANNEL_NAME: "pulse1", INSTRUCTIONS: ["frame", "frame"]}, + {CHANNEL_NAME: "noise", INSTRUCTIONS: []}, + ], + } + + upgraded = update(data) + + stems_data = upgraded[STEMS_DATA] + assert stems_data["config"]["entries"] == [{"id": 0, CHANNELS: ["pulse1", "noise"]}] + assert stems_data["config"]["channel_cap"] == DEFAULT_STEMS_CHANNEL_CAP + assert stems_data["assignments"] == [ + {CHANNEL_NAME: "pulse1", "stem_ids": [0, 0]}, + ] diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index cbe3166b7..3ca11b7c3 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -44,6 +44,7 @@ from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection +from tests.suite.stems import single_entry_stems_data RECONSTRUCTION_LENGTH: Final[int] = 4 ROWS_PER_PATTERN: Final[int] = 8 @@ -70,7 +71,8 @@ def build_reconstruction( instructions=instructions, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data(list(Config().generation.channels), instructions), ) diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index 93883be2a..d835ad8e5 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -22,6 +22,7 @@ from sampletones_core.project.song import Song from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection +from tests.suite.stems import single_entry_stems_data RECONSTRUCTION_LENGTH = 8 @@ -36,7 +37,8 @@ def build_reconstruction( instructions=instructions, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data(list(Config().generation.channels), instructions), ) diff --git a/tests/unit/sampletones_core/reconstructions/naming/test_naming.py b/tests/unit/sampletones_core/reconstructions/naming/test_naming.py index ee022feb0..75781fbb8 100644 --- a/tests/unit/sampletones_core/reconstructions/naming/test_naming.py +++ b/tests/unit/sampletones_core/reconstructions/naming/test_naming.py @@ -1,11 +1,13 @@ from pathlib import Path +import pytest + from sampletones_core.reconstructions.naming.derive import derive_name class TestDeriveName: def test_single_source_names_after_its_stem(self) -> None: - assert derive_name((Path("/stems/kick.wav"),), fallback_stem="document") == "kick" + assert derive_name((Path("/stems/kick.wav"),)) == "kick" def test_sources_sharing_one_directory_name_after_it(self) -> None: paths = ( @@ -13,15 +15,32 @@ def test_sources_sharing_one_directory_name_after_it(self) -> None: Path("/stems/drums/snare.wav"), ) - assert derive_name(paths, fallback_stem="document") == "drums" + assert derive_name(paths) == "drums" + + def test_sources_sharing_a_filename_prefix_name_after_it(self) -> None: + paths = ( + Path("/a/song_vocals.wav"), + Path("/b/song_drums.wav"), + ) + + assert derive_name(paths) == "song" + + def test_sources_with_matching_filenames_name_after_them(self) -> None: + paths = ( + Path("/a/kick.wav"), + Path("/b/kick.wav"), + ) + + assert derive_name(paths) == "kick" - def test_sources_from_different_directories_fall_back(self) -> None: + def test_sources_sharing_no_directory_name_after_the_first_stem(self) -> None: paths = ( Path("/a/kick.wav"), Path("/b/snare.wav"), ) - assert derive_name(paths, fallback_stem="document") == "document" + assert derive_name(paths) == "kick" - def test_no_sources_fall_back(self) -> None: - assert derive_name((), fallback_stem="document") == "document" + def test_no_sources_raise(self) -> None: + with pytest.raises(ValueError, match="at least one"): + derive_name(()) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index c989b0a07..295b5cc70 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -6,6 +6,7 @@ import msgpack import numpy as np import pytest +from pydantic import ValidationError from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, FeatureKey, HierarchyMode @@ -38,6 +39,7 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import DIRECTORY_READ_ERRORS +from tests.suite.stems import single_entry_stems_data _RETUNED_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY // 2 _FASTER_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY * 2 @@ -60,7 +62,11 @@ def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: instructions={ChannelName.PULSE1: instructions}, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + {ChannelName.PULSE1: instructions}, + ), ) @@ -100,7 +106,7 @@ def test_stems_data_survives_save_and_load(self, tmp_path: Path) -> None: instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH), _pulse(_BASE_PITCH)]}, config=Config(), coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), stems_data=stems_data, ) path = tmp_path / "stems.stn" @@ -110,19 +116,22 @@ def test_stems_data_survives_save_and_load(self, tmp_path: Path) -> None: assert loaded.stems_data == stems_data - def test_load_without_stems_data_keeps_none(self, tmp_path: Path) -> None: - path = tmp_path / "plain.stn" - _reconstruction([_pulse(_BASE_PITCH)]).save(path) - - loaded = Reconstruction.load(path) - - assert loaded.stems_data is None - def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> None: stem_paths = ( Path("/dev/null/stem_a.wav"), Path("/dev/null/stem_b.wav"), ) + stems_data = StemsData( + config=StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.PULSE1]), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + channel_cap=1, + ), + assignments=[], + ) reconstruction = Reconstruction.create( approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, @@ -130,6 +139,7 @@ def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> No config=Config(), coefficient=1.0, audio_filepath=stem_paths, + stems_data=stems_data, ) path = tmp_path / "stems_paths.stn" reconstruction.save(path) @@ -138,18 +148,47 @@ def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> No assert loaded.audio_filepath == stem_paths + def test_paths_numbering_the_entries_is_enforced(self) -> None: + stems_data = StemsData.single_entry( + [ChannelName.PULSE1], + [ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0])], + channel_cap=1, + ) + + with pytest.raises(ValidationError, match="one per stems entry"): + Reconstruction.create( + approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), + approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, + instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH)]}, + config=Config(), + coefficient=1.0, + audio_filepath=(Path("/dev/null/a.wav"), Path("/dev/null/b.wav")), + stems_data=stems_data, + ) + class TestSourcePaths: def test_single_source_yields_one_path(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.source_paths == (Path("/dev/null"),) + assert reconstruction.audio_filepath == (Path("/dev/null"),) def test_stem_sources_yield_the_recorded_tuple(self) -> None: stem_paths = ( Path("/dev/null/stem_a.wav"), Path("/dev/null/stem_b.wav"), ) + stems_data = StemsData( + config=StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.PULSE1]), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + channel_cap=1, + ), + assignments=[], + ) reconstruction = Reconstruction.create( approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32), approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)}, @@ -157,17 +196,18 @@ def test_stem_sources_yield_the_recorded_tuple(self) -> None: config=Config(), coefficient=1.0, audio_filepath=stem_paths, + stems_data=stems_data, ) - assert reconstruction.source_paths == stem_paths + assert reconstruction.audio_filepath == stem_paths def test_detaching_empties_the_source_paths(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.source_paths == (Path("/dev/null"),) + assert reconstruction.audio_filepath == (Path("/dev/null"),) reconstruction.detach_source() - assert reconstruction.source_paths == () + assert reconstruction.audio_filepath == () class TestRoundTrip: @@ -187,7 +227,7 @@ def test_save_load_round_trip( assert loaded.audio_filepath == reconstruction.audio_filepath assert_array_equal(loaded.approximation, reconstruction.approximation) - def test_detached_source_round_trips_as_none( + def test_detached_source_round_trips_as_empty( self, tmp_path: Path, reconstruction_factory: ReconstructionFactory, @@ -199,7 +239,7 @@ def test_detached_source_round_trips_as_none( reconstruction.save(path) loaded = Reconstruction.load(path) - assert loaded.audio_filepath is None + assert loaded.audio_filepath == () class TestDetachSource: @@ -208,11 +248,11 @@ def test_detach_clears_the_source_location( reconstruction_factory: ReconstructionFactory, ) -> None: reconstruction = reconstruction_factory() - assert reconstruction.audio_filepath is not None + assert reconstruction.audio_filepath reconstruction.detach_source() - assert reconstruction.audio_filepath is None + assert reconstruction.audio_filepath == () class TestLoadRejectsForeignFiles: @@ -332,6 +372,45 @@ def test_a_2_1_file_loads_through_the_upgrade( assert loaded.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION assert loaded.config.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION assert set(loaded.approximations) == set(reconstruction.approximations) + assert loaded.audio_filepath == reconstruction.audio_filepath + assert loaded.stems_data == reconstruction.stems_data + + def test_a_2_1_file_without_stems_record_gains_the_single_entry_record( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + path = tmp_path / "old_plain.stn" + reconstruction.save(path) + + binary = path.read_bytes() + data = msgpack.unpackb(binary, raw=False) + data["metadata"]["reconstruction_data_version"] = "2.1" + data.pop("stems_data") + data["audio_filepath"] = str(reconstruction.audio_filepath[0]) + for item in data["approximations_data"]: + item["generator_name"] = item.pop("channel_name") + + for item in data["instructions_data"]: + item["generator_name"] = item.pop("channel_name") + + generation = data["config"]["generation"] + generation["generators"] = generation.pop("channels") + config_metadata = data["config"].get("metadata") + if isinstance(config_metadata, dict): + config_metadata["reconstruction_data_version"] = "2.1" + + path.write_bytes(msgpack.packb(data, use_bin_type=True)) + + loaded = Reconstruction.load(path) + + stems_data = loaded.stems_data + assert stems_data.config.entries[0].id == 0 + assert stems_data.config.entries[0].channels == list(loaded.config.generation.channels) + assert loaded.audio_filepath == reconstruction.audio_filepath + for channel, stem_ids in stems_data.assignments_by_channel.items(): + assert len(stem_ids) == len(loaded.instructions[channel]) class TestDeserializeDataWrapping(BaseTestSuite): From ca94538ace21754efe33f42b8f9b3d2df5d1d99f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 18:23:17 +0200 Subject: [PATCH 035/142] Fixed: non-refreshing stems names --- .../ui/panels/reconstruction/stems.py | 1 + .../ui/panels/reconstruction/test_stems_panel.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index 111c1196f..9ceab0787 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -145,6 +145,7 @@ def _create_stem_row(self, row: StemViewModel) -> str: def _render_row(self, row: StemViewModel) -> None: checkbox_tag = self._stem_checkbox_tag(row.stem_id) + dpg.configure_item(checkbox_tag, label=row.label) dpg.configure_item(checkbox_tag, enabled=row.enabled) dpg.set_value(checkbox_tag, row.selected) dpg.set_value( diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py index ee9d5d1ee..3d65db54e 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -137,6 +137,22 @@ def test_rows_follow_a_changed_stem_set(self, panel: GUIReconstructionStemsPanel assert not dpg.does_item_exist(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) assert dpg.does_item_exist(GUIReconstructionStemsPanel._stem_checkbox_tag(1)) + def test_a_reused_row_takes_the_new_stem_label(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + panel.update_view( + _view_model( + _stem_row(0, label="kick.wav", selected=True, enabled=True), + ) + ) + + panel.update_view( + _view_model( + _stem_row(0, label="snare.wav", selected=True, enabled=True), + ) + ) + + assert dpg.get_item_label(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) == "snare.wav" + class TestStemsPanelSelection: def test_unchecking_a_stem_reports_the_remaining_selection(self, panel: GUIReconstructionStemsPanel) -> None: From e67861a06af2a45af269b510003a103d689f1b94 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 19:00:19 +0200 Subject: [PATCH 036/142] Derived: the play rate from the NTSC frame rate --- docs/development/bugs-and-todos.md | 2 -- scripts/nsf_render.py | 11 ++++---- src/sampletones_player/clock/schedule.py | 16 +++++------ src/sampletones_player/nsf/header.py | 4 +-- src/sampletones_player/specification/clock.py | 6 ++++- src/sampletones_player/specification/nsf.py | 5 ++++ .../sampletones_player/clock/test_schedule.py | 27 +++++++++---------- .../sampletones_player/nsf/test_header.py | 19 ++++++++++--- .../unit/sampletones_player/nsf/test_song.py | 2 +- 9 files changed, 52 insertions(+), 40 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 9bc8e94cd..b5b2a61c7 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -38,8 +38,6 @@ * Per-tab undo routing * In-application console * Improve performance of browser favorite scan of the entire tree per click -* NSF play rate: the driver's step follows the 16666 µs the header asks for, while players drive the play routine from the NTSC frame rate -* Pylint gate: `fail-on` lists `fixme` while the source carries TODO comments, so the hook fails until either the comments are resolved or `fixme` leaves the list ## Bugs diff --git a/scripts/nsf_render.py b/scripts/nsf_render.py index f0428ede6..922c58407 100755 --- a/scripts/nsf_render.py +++ b/scripts/nsf_render.py @@ -10,8 +10,7 @@ from sampletones_player.driver.image import DriverImage from sampletones_player.specification.clock import ( FIXED_POINT_SCALE, - MICROSECONDS_PER_SECOND, - PLAY_PERIOD_MICROSECONDS, + NTSC_FRAME_RATE, ) from sampletones_player.specification.nsf import HEADER_SIZE from sampletones_player.specification.song import ( @@ -64,9 +63,8 @@ def decodes_exports() -> bool: def song_seconds(data: bytes, code_length: int) -> float: """How long the song in an exported file lasts, read out of the block behind the driver. - The block states the ticks the song covers and the step the driver advances them by, and the - header asks the console for one play call every `PLAY_PERIOD_MICROSECONDS`, so the three - together give the seconds the song sounds for. + The block states the ticks the song covers and the step the driver advances them by, which + gives the play calls the song takes, and the console makes one of those every video frame. Args: data: The whole `.nsf` file, header included. @@ -79,7 +77,8 @@ def song_seconds(data: bytes, code_length: int) -> float: ticks: int = struct.unpack_from(WORD, block, TOTAL_TICKS_OFFSET)[0] fraction: int = struct.unpack_from(WORD, block, STEP_FRACTION_OFFSET)[0] step: float = block[STEP_WHOLE_OFFSET] + fraction / FIXED_POINT_SCALE - return ticks / step * PLAY_PERIOD_MICROSECONDS / MICROSECONDS_PER_SECOND + play_calls = ticks / step + return play_calls / float(NTSC_FRAME_RATE) def render(source: Path, destination: Path, seconds: float) -> None: diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py index 3c539d639..2f5b1f7e5 100644 --- a/src/sampletones_player/clock/schedule.py +++ b/src/sampletones_player/clock/schedule.py @@ -11,8 +11,7 @@ FIXED_POINT_BITS, FIXED_POINT_SCALE, MAX_STEP_WHOLE, - MICROSECONDS_PER_SECOND, - PLAY_PERIOD_MICROSECONDS, + NTSC_FRAME_RATE, ) @@ -49,8 +48,10 @@ class PlaySchedule(BaseModel): def from_parameters(cls, nes_frequency: int) -> PlaySchedule: """Derives the schedule a stream built at ``nes_frequency`` plays on. - The play rate follows from the period the NSF header asks for, so the step the driver - carries and the rate the file requests state the same clock. + The console calls the play routine once a video frame, so the rate a stream is read at + is its own rate measured against `NTSC_FRAME_RATE`. The header states that same rate as + the period it asks for, so a player honouring the field and one driving from the frame + itself run the stream at the speed it was built at. Args: nes_frequency: The engine tick rate the reconstruction was built at, in Hz. @@ -65,12 +66,7 @@ def from_parameters(cls, nes_frequency: int) -> PlaySchedule: if nes_frequency < 1: raise ValueError(f"nes_frequency must be at least 1, got {nes_frequency}") - return cls( - ticks_per_play_call=Fraction( - nes_frequency * PLAY_PERIOD_MICROSECONDS, - MICROSECONDS_PER_SECOND, - ), - ) + return cls(ticks_per_play_call=Fraction(nes_frequency) / NTSC_FRAME_RATE) def ticks_at(self, play_calls: int) -> int: """The tick the stream stands on once ``play_calls`` calls have been made. diff --git a/src/sampletones_player/nsf/header.py b/src/sampletones_player/nsf/header.py index 79b82b5ae..60645d751 100644 --- a/src/sampletones_player/nsf/header.py +++ b/src/sampletones_player/nsf/header.py @@ -1,7 +1,6 @@ from sampletones_core.formats.binary import BinaryWriter from sampletones_player.driver.addresses import DriverAddresses from sampletones_player.nsf.information import NSFInformation -from sampletones_player.specification.clock import PLAY_PERIOD_MICROSECONDS from sampletones_player.specification.nsf import ( FIRST_SONG, NO_BANKSWITCHING, @@ -10,6 +9,7 @@ NSF2_LENGTH_UNSTATED, NSF_MAGIC, NSF_VERSION, + NTSC_PLAY_PERIOD_MICROSECONDS, NTSC_REGION, PAL_PLAY_PERIOD_MICROSECONDS, SONG_COUNT, @@ -37,7 +37,7 @@ def _write_strings(writer: BinaryWriter, information: NSFInformation) -> None: def _write_playback(writer: BinaryWriter) -> None: - writer.write_uint16(PLAY_PERIOD_MICROSECONDS) + writer.write_uint16(NTSC_PLAY_PERIOD_MICROSECONDS) writer.write_bytes(NO_BANKSWITCHING) writer.write_uint16(PAL_PLAY_PERIOD_MICROSECONDS) writer.write_uint8(NTSC_REGION) diff --git a/src/sampletones_player/specification/clock.py b/src/sampletones_player/specification/clock.py index 0e153515e..4d69fcd99 100644 --- a/src/sampletones_player/specification/clock.py +++ b/src/sampletones_player/specification/clock.py @@ -1,6 +1,10 @@ +from fractions import Fraction from typing import Final -PLAY_PERIOD_MICROSECONDS: Final[int] = 16666 +NTSC_MASTER_CYCLES_PER_FRAME: Final[int] = 357_366 +NTSC_MASTER_CLOCK_HERTZ: Final[Fraction] = Fraction(236_250_000, 11) +NTSC_FRAME_RATE: Final[Fraction] = NTSC_MASTER_CLOCK_HERTZ / NTSC_MASTER_CYCLES_PER_FRAME + MICROSECONDS_PER_SECOND: Final[int] = 1_000_000 FIXED_POINT_BITS: Final[int] = 16 diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py index 44573b851..bc87e6198 100644 --- a/src/sampletones_player/specification/nsf.py +++ b/src/sampletones_player/specification/nsf.py @@ -1,6 +1,10 @@ from typing import Final from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.clock import ( + MICROSECONDS_PER_SECOND, + NTSC_FRAME_RATE, +) PROGRAM_START: Final[int] = 0x8000 PROGRAM_SIZE: Final[int] = 0x8000 @@ -33,6 +37,7 @@ NSF2_LENGTH_OFFSET: Final[int] = NSF2_FEATURES_OFFSET + 1 HEADER_SIZE: Final[int] = NSF2_LENGTH_OFFSET + NSF2_LENGTH_SIZE +NTSC_PLAY_PERIOD_MICROSECONDS: Final[int] = round(MICROSECONDS_PER_SECOND / NTSC_FRAME_RATE) PAL_PLAY_PERIOD_MICROSECONDS: Final[int] = 20000 NTSC_REGION: Final[int] = 0x00 NO_EXPANSION_CHIPS: Final[int] = 0x00 diff --git a/tests/unit/sampletones_player/clock/test_schedule.py b/tests/unit/sampletones_player/clock/test_schedule.py index da134f9df..cd12f99a2 100644 --- a/tests/unit/sampletones_player/clock/test_schedule.py +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -12,8 +12,7 @@ FIXED_POINT_BITS, FIXED_POINT_SCALE, MAX_STEP_WHOLE, - MICROSECONDS_PER_SECOND, - PLAY_PERIOD_MICROSECONDS, + NTSC_FRAME_RATE, ) from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY from tests.suite.base import BaseTestSuite @@ -24,7 +23,7 @@ def exact_rate(nes_frequency: int) -> Fraction: - return Fraction(nes_frequency * PLAY_PERIOD_MICROSECONDS, MICROSECONDS_PER_SECOND) + return Fraction(nes_frequency) / NTSC_FRAME_RATE class TestPlaySchedule(BaseTestSuite): @@ -99,12 +98,12 @@ class TestTheScheduleHoldsTheRate(BaseTestSuite): """The rate a stream is read at, and the advances a rate dividing the play period produces.""" @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) - def test_the_rate_is_the_stream_measured_against_the_play_period(self, nes_frequency: int) -> None: + def test_the_rate_is_the_stream_measured_against_the_frame_rate(self, nes_frequency: int) -> None: schedule = PlaySchedule.from_parameters(nes_frequency) assert schedule.ticks_per_play_call == exact_rate(nes_frequency) def test_a_stream_at_the_play_rate_advances_a_tick_a_call(self) -> None: - """A stream built at the period the header asks for lands a whole tick on every call.""" + """A stream built at the rate the console calls at lands a whole tick on every call.""" schedule = PlaySchedule(ticks_per_play_call=Fraction(1)) assert all(schedule.advance_at(play_call) == 1 for play_call in range(LONG_RUN_PLAY_CALLS)) @@ -127,15 +126,15 @@ def label(self) -> str: return f"{self.nes_frequency}hz" test_cases = ( - TestCase(nes_frequency=15, expected=(0, 16383)), - TestCase(nes_frequency=24, expected=(0, 26213)), - TestCase(nes_frequency=30, expected=(0, 32767)), - TestCase(nes_frequency=50, expected=(0, 54611)), - TestCase(nes_frequency=60, expected=(0, 65533)), - TestCase(nes_frequency=100, expected=(1, 43686)), - TestCase(nes_frequency=120, expected=(1, 65531)), - TestCase(nes_frequency=200, expected=(3, 21837)), - TestCase(nes_frequency=300, expected=(4, 65523)), + TestCase(nes_frequency=15, expected=(0, 16357)), + TestCase(nes_frequency=24, expected=(0, 26171)), + TestCase(nes_frequency=30, expected=(0, 32714)), + TestCase(nes_frequency=50, expected=(0, 54524)), + TestCase(nes_frequency=60, expected=(0, 65428)), + TestCase(nes_frequency=100, expected=(1, 43511)), + TestCase(nes_frequency=120, expected=(1, 65320)), + TestCase(nes_frequency=200, expected=(3, 21486)), + TestCase(nes_frequency=300, expected=(4, 64997)), ) @pytest.mark.parametrize( diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py index 98b37025f..4d22e3204 100644 --- a/tests/unit/sampletones_player/nsf/test_header.py +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -1,5 +1,6 @@ import struct from dataclasses import dataclass +from fractions import Fraction from typing import Final, Tuple import pytest @@ -7,7 +8,10 @@ from sampletones_player.driver.addresses import DriverAddresses from sampletones_player.nsf.header import header_to_bytes from sampletones_player.nsf.information import NSFInformation -from sampletones_player.specification.clock import PLAY_PERIOD_MICROSECONDS +from sampletones_player.specification.clock import ( + MICROSECONDS_PER_SECOND, + NTSC_FRAME_RATE, +) from sampletones_player.specification.nsf import ( ARTIST_OFFSET, BANKSWITCH_OFFSET, @@ -28,6 +32,7 @@ NSF_MAGIC, NSF_VERSION, NTSC_PERIOD_OFFSET, + NTSC_PLAY_PERIOD_MICROSECONDS, NTSC_REGION, PAL_PERIOD_OFFSET, PAL_PLAY_PERIOD_MICROSECONDS, @@ -48,6 +53,7 @@ ARTIST: Final[str] = "Jakim" ADDRESSES: Final[DriverAddresses] = DriverAddresses(song=SONG_ADDRESS) INFORMATION: Final[NSFInformation] = NSFInformation(title=TITLE, artist=ARTIST) +RATE_TOLERANCE: Final[Fraction] = Fraction(1, 10_000) def header() -> bytes: @@ -77,7 +83,7 @@ class TestHeaderBytes: + TITLE.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + ARTIST.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") + SAMPLETONES_COPYRIGHT.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00") - + b"\x1a\x41" + + b"\xff\x40" + bytes(BANKSWITCH_SIZE) + b"\x20\x4e" + b"\x00\x00\x00" @@ -158,8 +164,13 @@ def test_the_fields_stand_back_to_back(self) -> None: class TestHeaderPlayback: """The rate the console drives the file at, and the hardware it asks for.""" - def test_the_ntsc_period_is_the_one_the_schedule_counts_in(self) -> None: - assert read_word(header(), NTSC_PERIOD_OFFSET) == PLAY_PERIOD_MICROSECONDS + def test_the_ntsc_period_is_the_one_the_specification_names(self) -> None: + assert read_word(header(), NTSC_PERIOD_OFFSET) == NTSC_PLAY_PERIOD_MICROSECONDS + + def test_the_ntsc_period_asks_for_the_rate_the_schedule_counts_in(self) -> None: + """A player reading the field and one driving from the frame run a stream at one speed.""" + requested = Fraction(MICROSECONDS_PER_SECOND, read_word(header(), NTSC_PERIOD_OFFSET)) + assert abs(requested - NTSC_FRAME_RATE) / NTSC_FRAME_RATE < RATE_TOLERANCE def test_the_pal_period_states_the_fiftieth_of_a_second(self) -> None: assert read_word(header(), PAL_PERIOD_OFFSET) == PAL_PLAY_PERIOD_MICROSECONDS diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 787b732c9..0bf3acdf1 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -61,7 +61,7 @@ class TestSongBytes: """ EXPECTED: Final[bytes] = ( - b"\x00\xff\x7f" + b"\x00\xca\x7f" b"\x02\x00" b"\xff\xff" b"\x0f\x00\x15\x00\x1b\x00\x21\x00" From 2935f78514f472b4f98575bd20a8993b2611e4b8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 19:38:20 +0200 Subject: [PATCH 037/142] Fixed: the stem-filtered original audio and the waveform partial sum --- .../logic/reconstruction/reconstruction.py | 7 +++---- .../view_model/shared/waveform_data.py | 9 ++------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index ca697a43e..f65a76e44 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -566,12 +566,11 @@ def _compute_audio_data(self) -> Optional[AudioData]: sample_rate = reconstruction_data.reconstruction.config.sample_rate if self._current_audio_source == AudioSourceType.ORIGINAL: - original_audio = reconstruction_data.original_audio - if original_audio is None: + if reconstruction_data.original_audio is None: return None - original_audio = reconstruction_data.original_mix_for(self._selected_stems) - return AudioData.from_array(original_audio, sample_rate) + selected_original_audio = reconstruction_data.original_mix_for(self._selected_stems) + return AudioData.from_array(selected_original_audio, sample_rate) partial_approximation = reconstruction_data.partials_for( self._selected_channels, diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py index 09f6ac6af..844a25de4 100644 --- a/src/sampletones_application/view_model/shared/waveform_data.py +++ b/src/sampletones_application/view_model/shared/waveform_data.py @@ -3,6 +3,7 @@ import numpy as np +from sampletones_core.audio.mixing import align, mix from sampletones_core.constants.enums import ChannelName @@ -31,10 +32,4 @@ def partials(self, channel_names: List[ChannelName]) -> np.ndarray: if not selected_approximations: return np.zeros_like(self.approximation) - length = len(self.approximation) - dtype: np.dtype = np.result_type(*[audio.dtype for audio in selected_approximations]) - summed = np.zeros(length, dtype=dtype) - for audio in selected_approximations: - summed[: len(audio)] += audio # does audio.mixing applies here? if no, remove the comment; otherwises apply - - return summed + return mix(align(selected_approximations, len(self.approximation))) From c985c77d73783bf5fa600acb999905763686cf00 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 21:01:58 +0200 Subject: [PATCH 038/142] Fixed: every channel carries one entry per frame in a stems assignment --- src/sampletones_core/constants/algorithm.py | 1 + .../reconstructor/reconstructor.py | 168 ++++++++++----- .../reconstructions/reconstructor/state.py | 17 +- .../stems/assignment/__init__.py | 0 .../reconstructor/stems/assignment/frame.py | 57 +++++ .../reconstructor/stems/assignment/session.py | 141 +++++++++++++ .../stems/assignment/validation.py | 25 +++ .../reconstructor/stems/configs/config.py | 32 ++- .../reconstructor/stems/configs/entry.py | 8 +- .../reconstructor/stems/configs/stem.py | 15 -- .../reconstructor/stems/frame.py | 199 ------------------ .../stems/models/frame_assignment.py | 7 + .../reconstructor/stems/models/hierarchy.py | 15 -- .../test_stems_reconstruction.py | 66 +++++- .../logic/reconstruction/test_data.py | 3 + .../reconstruction/test_reconstruction.py | 2 + .../reconstruction/test_stems_filter.py | 6 +- .../reconstructor/stems/test_config.py | 67 ++++++ .../reconstructor/stems/test_equivalence.py | 88 ++++---- .../reconstructor/stems/test_frame.py | 176 ++++++++-------- .../reconstructor/stems/test_models.py | 75 +++++-- .../reconstructor/test_reconstructor.py | 35 ++- .../reconstructor/test_state.py | 50 +++-- 23 files changed, 765 insertions(+), 488 deletions(-) create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/assignment/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py delete mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py delete mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/frame.py delete mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index 8ad90fbfa..c7d6cd776 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -68,6 +68,7 @@ ALL_STEMS_CHANNEL_CAP: Final[int] = len(ChannelName) DEFAULT_STEMS_CHANNEL_CAP: Final[int] = ALL_STEMS_CHANNEL_CAP DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN +RESTING_STEM_ID: Final[int] = -1 # Execution diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 195d54835..acc0d2190 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -3,10 +3,11 @@ import numpy as np -from sampletones_core.audio import active_frame_level, load_audio, mix +from sampletones_core.audio import active_frame_level, load_audio, mix, silence from sampletones_core.configs import Config from sampletones_core.constants.algorithm import ( MINIMUM_AUDIO_LEVEL, + RESTING_STEM_ID, ) from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import FragmentedAudio, Window @@ -15,6 +16,7 @@ GeneratorUnion, get_generators_by_channels, ) +from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibrary, InstructionLibraryData from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -22,10 +24,9 @@ from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher from sampletones_core.reconstructions.reconstructor.state import ReconstructionState +from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig -from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem -from sampletones_core.reconstructions.reconstructor.stems.frame import assign_frame -from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker from sampletones_shared.exceptions import NoLibraryDataError from sampletones_shared.types.path import Pathlike @@ -150,17 +151,15 @@ def reconstruct_stems( checked_paths = self._check_stem_paths(paths, stems_config) mixed = self._mix_stem_audios(checked_paths) fragmented_audio, coefficient = self._prepare_stem_frames(mixed, stems_config) - stems, hierarchy = self._build_stem_models(stems_config) worker, matcher = self._build_stem_matcher(mixed) assignments = self._assign_stem_frames( fragmented_audio, - stems, - hierarchy, + stems_config, worker, matcher, - stems_config.channel_cap, ) - stems_data = self._build_stems_data(stems_config, assignments) + playing = self._drop_resting_channels(assignments) + stems_data = self._build_stems_data(stems_config, playing) return Reconstruction.from_state( self.state, self.config, @@ -207,24 +206,12 @@ def _prepare_stem_frames( Returns the framed target together with the coefficient it was scaled by, so the assembled reconstruction records the level it was matched at. """ - coefficient = self.get_coefficient(mixed) + coefficient = self.get_coefficient(mixed, stems_config) self.reset_generators() - covered = {channel for entry in stems_config.entries for channel in entry.channels} + covered = stems_config.covered_channels self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered]) return self.get_fragments(mixed / coefficient), coefficient - @staticmethod - def _build_stem_models( - stems_config: StemsConfig, - ) -> Tuple[Dict[int, Stem], StemHierarchy]: - """Converts the stems setup into the runtime stem and hierarchy models.""" - stems = {entry.id: Stem(id=entry.id, channels=frozenset(entry.channels)) for entry in stems_config.entries} - hierarchy = StemHierarchy( - levels=tuple(tuple(level) for level in stems_config.hierarchy.levels), - mode=stems_config.hierarchy.mode, - ) - return stems, hierarchy - def _build_stem_matcher( self, signal: np.ndarray, @@ -248,41 +235,78 @@ def _build_stem_matcher( def _assign_stem_frames( self, fragmented_audio: FragmentedAudio, - stems: Dict[int, Stem], - hierarchy: StemHierarchy, + stems_config: StemsConfig, worker: ReconstructorWorker, matcher: FrameMatcher, - channel_cap: int, ) -> Dict[ChannelName, List[int]]: """Assigns every frame's channels to the stems and records both sides of the outcome. - Each choice updates the reconstruction state and the per-channel stem record, - the two lists staying parallel so stem id ``i`` names frame ``i`` of its channel. + Each frame contributes one entry to every channel in play — a pick or a rest — so the + reconstruction state and the per-channel stem record stay parallel to the frames, and + stem id ``i`` names frame ``i`` of its channel. """ - assignments: Dict[ChannelName, List[int]] = {} + assignments: Dict[ChannelName, List[int]] = {name: [] for name in self.state.channel_names} for fragment_id in fragmented_audio.fragments_ids: - fragment = fragmented_audio[fragment_id] frame_assignment = assign_frame( - fragment, - stems, - hierarchy, + fragmented_audio[fragment_id], + stems_config, self.channels, matcher, worker.feature_extractor, - channel_cap, ) - for choice in frame_assignment.choices: - self.update_state( - ApproximationData( - channel_name=choice.channel_name, - approximation=choice.approximation, - instruction=choice.instruction, - ) - ) - assignments.setdefault(choice.channel_name, []).append(choice.stem_id) + self._record_frame(frame_assignment, assignments) return assignments + def _record_frame( + self, + frame_assignment: StemFrameAssignment, + assignments: Dict[ChannelName, List[int]], + ) -> None: + """Writes one frame's outcome into the state and the per-channel stem record.""" + for choice in frame_assignment.choices: + self._record( + choice.channel_name, + choice.instruction, + choice.approximation.audio, + ) + assignments[choice.channel_name].append(choice.stem_id) + + for channel_name in frame_assignment.resting: + self._record_rest(channel_name) + assignments[channel_name].append(RESTING_STEM_ID) + + def _record_rest(self, channel_name: ChannelName) -> None: + """Records the frame of a channel no stem took: its null instruction, sounding nothing. + + The channel keeps its place in the frame, which is what holds every channel's stream + against the timeline the frames lay out, and the silent frame is what a cap or a + hierarchy leaving the channel free actually sounds like. + """ + generator = self.channels[channel_name] + instruction = generator.get_instruction_type().null_instruction() + self._record(channel_name, instruction, silence(generator.frame_length)) + + def _drop_resting_channels( + self, + assignments: Dict[ChannelName, List[int]], + ) -> Dict[ChannelName, List[int]]: + """Leaves the channels that sound, dropping those that rested through every frame. + + A channel no stem ever took describes nothing, so it stands by: the state releases its + stream and the record names it no more, which is what keeps a silent channel out of + every export. + """ + playing: Dict[ChannelName, List[int]] = {} + for channel_name, stem_ids in assignments.items(): + if all(stem_id == RESTING_STEM_ID for stem_id in stem_ids): + self.state.drop(channel_name) + continue + + playing[channel_name] = stem_ids + + return playing + @staticmethod def _build_stems_data( stems_config: StemsConfig, @@ -316,22 +340,29 @@ def load_audio(self, path: Path) -> np.ndarray: quantization_levels=self.config.general.quantization_levels, ) - def get_coefficient(self, audio: np.ndarray) -> float: + def get_coefficient( + self, + audio: np.ndarray, + stems_config: StemsConfig, + ) -> float: """ - Working-level coefficient that scales the input into the range the enabled - channels span. + Working-level coefficient that scales the input into the range one frame spans. The reference anchors to the robust active-frame level using the configured percentile and audibility floor, and is floored at `MINIMUM_AUDIO_LEVEL` so - a fully silent input yields a finite coefficient. + a fully silent input yields a finite coefficient. The range it is measured against + is what the setup's frame budget reaches: the loudest mixer weights among the covered + channels, as many of them as one frame can hold, so a capped run targets a level its + channels render. Args: audio: The prepared input audio. + stems_config: The stems setup the reconstruction runs under. Returns: float: The positive scale factor the input is divided by before matching. """ - total = sum(MIXER_LEVELS[generator.class_name()] for generator in self.channels.values()) + total = self._frame_mixer_total(stems_config) level = max( active_frame_level( audio, @@ -343,6 +374,15 @@ def get_coefficient(self, audio: np.ndarray) -> float: ) return float(level / total) + def _frame_mixer_total(self, stems_config: StemsConfig) -> float: + """The mixer weight one frame reaches: the loudest covered channels, up to the budget.""" + covered = stems_config.covered_channels + levels = sorted( + (MIXER_LEVELS[generator.class_name()] for name, generator in self.channels.items() if name in covered), + reverse=True, + ) + return sum(levels[: stems_config.frame_budget]) + def get_fragments(self, audio: np.ndarray) -> FragmentedAudio: """Frames the audio into the fragments matched against the library. @@ -409,30 +449,42 @@ def load_library(self, library: Optional[InstructionLibrary] = None) -> Instruct def update_state(self, fragment_approximation: ApproximationData) -> None: """Appends one fragment's chosen approximation to the reconstruction state. - Regenerates the approximation from its instruction when final regeneration is - enabled, otherwise reuses the stored approximation, scaling either by the - configured drive. - Args: fragment_approximation: The chosen approximation for one fragment and channel. """ - generator: GeneratorUnion = self.channels[fragment_approximation.channel_name] + self._record( + fragment_approximation.channel_name, + fragment_approximation.instruction, + fragment_approximation.approximation.audio, + ) + + def _record( + self, + channel_name: ChannelName, + instruction: InstructionUnion, + matched_audio: np.ndarray, + ) -> None: + """Appends one frame of one channel to the reconstruction state. + + Regenerates the frame from its instruction when final regeneration is enabled, which + carries the oscillator's phase into the next frame, otherwise keeps the audio the match + was made on. Either one is scaled by the configured drive. + """ + generator: GeneratorUnion = self.channels[channel_name] if self.config.generation.final_regeneration: - instruction = fragment_approximation.instruction - initials = generator.initials approximation = ( generator( instruction, # type: ignore[arg-type] - initials=initials, + initials=generator.initials, save=True, ) * self.config.generation.drive ) else: - approximation = fragment_approximation.approximation.audio * self.config.generation.drive + approximation = matched_audio * self.config.generation.drive - self.state.append(fragment_approximation, approximation) + self.state.append(channel_name, instruction, approximation) def reset_generators(self) -> None: """Resets every channel's generator so the next reconstruction starts fresh.""" diff --git a/src/sampletones_core/reconstructions/reconstructor/state.py b/src/sampletones_core/reconstructions/reconstructor/state.py index 69cfaa811..9d480e920 100644 --- a/src/sampletones_core/reconstructions/reconstructor/state.py +++ b/src/sampletones_core/reconstructions/reconstructor/state.py @@ -7,8 +7,6 @@ from sampletones_core.fft import Fragment from sampletones_core.instructions import InstructionUnion -from .approximation import ApproximationData - class FragmentReconstructionState(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) @@ -34,9 +32,16 @@ def create(cls, channel_names: List[ChannelName]) -> Self: def append( self, - fragment_approximation: ApproximationData, + channel_name: ChannelName, + instruction: InstructionUnion, approximation: np.ndarray, ) -> None: - name = fragment_approximation.channel_name - self.instructions[name].append(fragment_approximation.instruction) - self.approximations[name].append(approximation) + """Records one frame of one channel: what it plays and how it sounds.""" + self.instructions[channel_name].append(instruction) + self.approximations[channel_name].append(approximation) + + def drop(self, channel_name: ChannelName) -> None: + """Releases a channel's stream, leaving it out of the reconstruction being assembled.""" + self.channel_names.remove(channel_name) + del self.instructions[channel_name] + del self.approximations[channel_name] diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py new file mode 100644 index 000000000..089094fef --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py @@ -0,0 +1,57 @@ +from typing import Dict + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import GeneratorUnion +from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.stems.assignment.session import AssignmentSession +from sampletones_core.reconstructions.reconstructor.stems.assignment.validation import validate_stems_config +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment + + +def assign_frame( + fragment: Fragment, + stems_config: StemsConfig, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, +) -> StemFrameAssignment: + """ + Assigns one target frame's channels to stems, one pick at a time. + + Every pick scores each eligible stem's candidates against the current residual, + takes the cheapest choice across the active level, subtracts its approximation + from the residual, and consumes its channel. Levels pick in the hierarchy's + mode: round-based gives every level's stems one channel per round in level + order, strict exhausts each level before the next. Each stem holds at most + the setup's channel cap per frame. + + The frame is answered whole: every covered channel is either picked or reported as + resting, so a caller records one entry per channel per frame and the streams it + assembles stay parallel to the frames they describe. + + Args: + fragment: The frame to assign, matching the matcher and extractor feature + space. + stems_config: The stems setup the assignment runs under. + channels: The enabled channels with their generators. + matcher: The candidate scoring machinery. + extractor: The feature extractor whose subtraction forms the residual. + + Returns: + The picks in the order they were made, together with the channels left resting. + + Raises: + ValueError: If a stem allows a channel the enabled channels lack. + """ + validate_stems_config(stems_config, channels) + session = AssignmentSession( + fragment, + stems_config, + channels, + matcher, + extractor, + ) + return session.run() diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py new file mode 100644 index 000000000..2ff308128 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py @@ -0,0 +1,141 @@ +from typing import Dict, List, Optional, Sequence, Set + +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import ( + GeneratorUnion, + get_generator_by_instruction, + get_remaining_generator_classes, +) +from sampletones_core.reconstructions.reconstructor.selector.matching import ( + FrameMatcher, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import ( + StemsConfig, +) +from sampletones_core.reconstructions.reconstructor.stems.models.choice import ( + StemChoice, +) +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import ( + StemFrameAssignment, +) + + +class AssignmentSession: + """ + Carries one frame assignment's mutable progress: the residual, the free + channels, and the per-stem channel counts. + """ + + def __init__( + self, + fragment: Fragment, + stems_config: StemsConfig, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + self.stems_config = stems_config + self.channels = channels + self.matcher = matcher + self.extractor = extractor + self.channel_cap = stems_config.channel_cap + self.residual = fragment + covered = stems_config.covered_channels + self.free_channels = [name for name in ChannelName.items() if name in covered] + self.used_channels: Dict[int, int] = {entry.id: 0 for entry in stems_config.entries} + self.choices: List[StemChoice] = [] + + def run(self) -> StemFrameAssignment: + """Runs the frame's picks and reports them together with the channels left resting.""" + match self.stems_config.hierarchy.mode: + case HierarchyMode.ROUND_ROBIN: + self._round_robin() + case HierarchyMode.STRICT: + self._strict() + + return StemFrameAssignment( + choices=tuple(self.choices), + resting=tuple(self.free_channels), + ) + + def _round_robin(self) -> None: + for _ in range(self.channel_cap): + for level in self.stems_config.hierarchy.levels: + if not self.free_channels: + return + + self._pick_from_level(level, repeat=False) + + def _strict(self) -> None: + for level in self.stems_config.hierarchy.levels: + if not self.free_channels: + return + + self._pick_from_level(level, repeat=True) + + def _pick_from_level( + self, + level: Sequence[int], + *, + repeat: bool, + ) -> None: + picked_this_visit: Set[int] = set() + while True: + eligible = [ + stem_id + for stem_id in level + if self.used_channels[stem_id] < self.channel_cap and (repeat or stem_id not in picked_this_visit) + ] + if not eligible or not self.free_channels: + return + + choice = self._best_choice(eligible) + if choice is None: + return + + self.choices.append(choice) + self.used_channels[choice.stem_id] += 1 + self.free_channels.remove(choice.channel_name) + self.residual = self.extractor.subtract( + self.residual, + choice.approximation, + ) + picked_this_visit.add(choice.stem_id) + + def _best_choice(self, stem_ids: Sequence[int]) -> Optional[StemChoice]: + best: Optional[StemChoice] = None + for stem_id in stem_ids: + remaining_channels = self._remaining_channels(stem_id) + if not remaining_channels: + continue + + remaining_generator_classes = get_remaining_generator_classes(remaining_channels) + scored = self.matcher.score_candidates( + self.residual, + remaining_generator_classes, + ) + candidate = scored[0] + generator = get_generator_by_instruction( + candidate.instruction, + remaining_generator_classes, + ) + choice = StemChoice( + stem_id=stem_id, + channel_name=ChannelName(generator.name), + instruction=candidate.instruction, + approximation=candidate.approximation, + cost=candidate.cost, + ) + if best is None or choice.cost < best.cost: + best = choice + + return best + + def _remaining_channels( + self, + stem_id: int, + ) -> Dict[ChannelName, GeneratorUnion]: + allowed = self.stems_config.entries_by_id[stem_id].channel_set + return {name: self.channels[name] for name in self.free_channels if name in allowed} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py new file mode 100644 index 000000000..597f307d4 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py @@ -0,0 +1,25 @@ +from typing import Dict + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators import GeneratorUnion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +def validate_stems_config( + stems_config: StemsConfig, + channels: Dict[ChannelName, GeneratorUnion], +) -> None: + """Holds a stems setup against the channels the reconstruction enables. + + The setup states its own consistency — unique ids, a hierarchy naming every entry, a cap of + at least one — so what is left to check is the pairing with this run: every channel a stem + may occupy has a generator to render it. + + Raises: + ValueError: If a stem allows a channel the configuration lacks. + """ + enabled = set(channels) + for entry in stems_config.entries: + foreign = entry.channel_set - enabled + if foreign: + raise ValueError(f"Stem {entry.id} allows channels the configuration lacks: {sorted(foreign)}") diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py index ce8f46fce..fcaa1d30d 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -1,4 +1,5 @@ -from typing import List, Self +from functools import cached_property +from typing import Dict, FrozenSet, List, Self from pydantic import ConfigDict, Field, model_validator @@ -48,6 +49,27 @@ def single_entry( channel_cap=channel_cap, ) + @cached_property + def entries_by_id(self) -> Dict[int, StemEntry]: + """The entries keyed by the id the hierarchy names them with.""" + return {entry.id: entry for entry in self.entries} + + @cached_property + def covered_channels(self) -> FrozenSet[ChannelName]: + """Every channel some stem may occupy, which is the set an assignment puts in play.""" + return frozenset(channel for entry in self.entries for channel in entry.channels) + + @property + def frame_budget(self) -> int: + """The most channels that can sound in one frame under this setup. + + Each stem holds at most ``channel_cap`` channels per frame and every held channel is one + of the covered ones, so the smaller of the two bounds is what a frame can reach. The + working level is measured against this budget, which keeps a capped run's target within + what its channels render. + """ + return min(len(self.covered_channels), len(self.entries) * self.channel_cap) + @model_validator(mode="after") def _validate_unique_entry_ids(self) -> Self: ids = [entry.id for entry in self.entries] @@ -55,3 +77,11 @@ def _validate_unique_entry_ids(self) -> Self: raise ValueError("Stem entries must have unique ids") return self + + @model_validator(mode="after") + def _validate_hierarchy_names_every_entry(self) -> Self: + referenced = sorted(stem_id for level in self.hierarchy.levels for stem_id in level) + if referenced != sorted(entry.id for entry in self.entries): + raise ValueError("Hierarchy levels must name every stem exactly once") + + return self diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py index f9760fe51..822ab5b65 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py @@ -1,4 +1,5 @@ -from typing import List +from functools import cached_property +from typing import FrozenSet, List from pydantic import ConfigDict, Field @@ -17,3 +18,8 @@ class StemEntry(DataModel): ..., description="The channels the stem may occupy", ) + + @cached_property + def channel_set(self) -> FrozenSet[ChannelName]: + """The channels this stem may occupy, in the form an assignment tests membership against.""" + return frozenset(self.channels) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py deleted file mode 100644 index afe96e67a..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/stem.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass -from typing import FrozenSet - -from sampletones_core.constants.enums import ChannelName - - -@dataclass(frozen=True) -class Stem: - """ - One audio source competing for channels in a reconstruction, identified by its - id and restricted to the channels it may occupy. - """ - - id: int - channels: FrozenSet[ChannelName] diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/frame.py deleted file mode 100644 index 8689956e3..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/stems/frame.py +++ /dev/null @@ -1,199 +0,0 @@ -from typing import Dict, List, Optional, Sequence, Set, Tuple - -from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_core.fft import Fragment -from sampletones_core.fft.features import FeatureExtractor -from sampletones_core.generators import ( - GeneratorUnion, - get_generator_by_instruction, - get_remaining_generator_classes, -) -from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher -from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem -from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice -from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment -from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy - - -def assign_frame( - fragment: Fragment, - stems: Dict[int, Stem], - hierarchy: StemHierarchy, - channels: Dict[ChannelName, GeneratorUnion], - matcher: FrameMatcher, - extractor: FeatureExtractor, - channel_cap: int, -) -> StemFrameAssignment: - """ - Assigns one target frame's channels to stems, one pick at a time. - - Every pick scores each eligible stem's candidates against the current residual, - takes the cheapest choice across the active level, subtracts its approximation - from the residual, and consumes its channel. Levels pick in the hierarchy's - mode: round-based gives every level's stems one channel per round in level - order, strict exhausts each level before the next. Each stem holds at most - ``channel_cap`` channels per frame. - - Args: - fragment: The frame to assign, matching the matcher and extractor feature - space. - stems: The competing stems keyed by their id. - hierarchy: The precedence levels and their picking mode. - channels: The enabled channels with their generators. - matcher: The candidate scoring machinery. - extractor: The feature extractor whose subtraction forms the residual. - channel_cap: The maximum number of channels one stem holds per frame. - - Returns: - The picks in the order they were made, with the final channel mapping. - - Raises: - ValueError: If ``channel_cap`` is below 1, a stem allows a channel the - enabled channels lack, a stem id disagrees with its key, or the - hierarchy names every stem exactly once. - """ - _validate(stems, hierarchy, channels, channel_cap) - session = _AssignmentSession( - fragment, - stems, - hierarchy, - channels, - matcher, - extractor, - channel_cap, - ) - return session.run() - - -def _validate( - stems: Dict[int, Stem], - hierarchy: StemHierarchy, - channels: Dict[ChannelName, GeneratorUnion], - channel_cap: int, -) -> None: - if channel_cap < 1: - raise ValueError("channel_cap must be at least 1") - - enabled = set(channels) - for stem_id, stem in stems.items(): - if stem.id != stem_id: - raise ValueError(f"Stem {stem.id} is keyed as {stem_id}") - - foreign = set(stem.channels) - enabled - if foreign: - raise ValueError(f"Stem {stem.id} allows channels the configuration lacks: {sorted(foreign)}") - - referenced = [stem_id for level in hierarchy.levels for stem_id in level] - if set(referenced) != set(stems) or len(set(referenced)) != len(referenced): - raise ValueError("Hierarchy levels must name every stem exactly once") - - -class _AssignmentSession: - """ - Carries one frame assignment's mutable progress: the residual, the free - channels, and the per-stem channel counts. - """ - - def __init__( - self, - fragment: Fragment, - stems: Dict[int, Stem], - hierarchy: StemHierarchy, - channels: Dict[ChannelName, GeneratorUnion], - matcher: FrameMatcher, - extractor: FeatureExtractor, - channel_cap: int, - ) -> None: - self.stems = stems - self.hierarchy = hierarchy - self.channels = channels - self.matcher = matcher - self.extractor = extractor - self.channel_cap = channel_cap - self.residual = fragment - reachable = {channel for stem in stems.values() for channel in stem.channels} - self.free_channels = [name for name in ChannelName.items() if name in reachable] - self.used_channels: Dict[int, int] = {stem_id: 0 for stem_id in stems} - self.choices: List[StemChoice] = [] - - def run(self) -> StemFrameAssignment: - match self.hierarchy.mode: - case HierarchyMode.ROUND_ROBIN: - self._round_robin() - case HierarchyMode.STRICT: - self._strict() - - return StemFrameAssignment(tuple(self.choices)) - - def _round_robin(self) -> None: - for _ in range(self.channel_cap): - for level in self.hierarchy.levels: - if not self.free_channels: - return - - self._pick_from_level(level, repeat=False) - - def _strict(self) -> None: - for level in self.hierarchy.levels: - if not self.free_channels: - return - - self._pick_from_level(level, repeat=True) - - def _pick_from_level( - self, - level: Tuple[int, ...], - *, - repeat: bool, - ) -> None: - picked_this_visit: Set[int] = set() - while True: - eligible = [ - stem_id - for stem_id in level - if self.used_channels[stem_id] < self.channel_cap and (repeat or stem_id not in picked_this_visit) - ] - if not eligible or not self.free_channels: - return - - choice = self._best_choice(eligible) - if choice is None: - return - - self.choices.append(choice) - self.used_channels[choice.stem_id] += 1 - self.free_channels.remove(choice.channel_name) - self.residual = self.extractor.subtract(self.residual, choice.approximation) - picked_this_visit.add(choice.stem_id) - - def _best_choice(self, stem_ids: Sequence[int]) -> Optional[StemChoice]: - best: Optional[StemChoice] = None - for stem_id in stem_ids: - remaining_channels = self._remaining_channels(stem_id) - if not remaining_channels: - continue - - remaining_generator_classes = get_remaining_generator_classes(remaining_channels) - scored = self.matcher.score_candidates(self.residual, remaining_generator_classes) - candidate = scored[0] - generator = get_generator_by_instruction( - candidate.instruction, - remaining_generator_classes, - ) - choice = StemChoice( - stem_id=stem_id, - channel_name=ChannelName(generator.name), - instruction=candidate.instruction, - approximation=candidate.approximation, - cost=candidate.cost, - ) - if best is None or choice.cost < best.cost: - best = choice - - return best - - def _remaining_channels( - self, - stem_id: int, - ) -> Dict[ChannelName, GeneratorUnion]: - return {name: self.channels[name] for name in self.free_channels if name in self.stems[stem_id].channels} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py index aaceee4b7..78fc6dfb3 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py @@ -5,7 +5,14 @@ class StemFrameAssignment(NamedTuple): + """One frame's outcome: the picks that were made and the channels left resting. + + Together the two cover every channel the setup puts in play, which is what lets a + reconstruction record one entry per channel for each frame. + """ + choices: Tuple[StemChoice, ...] + resting: Tuple[ChannelName, ...] @property def by_channel(self) -> Dict[ChannelName, StemChoice]: diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py deleted file mode 100644 index b93ea04db..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/stems/models/hierarchy.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass -from typing import Tuple - -from sampletones_core.constants.enums import HierarchyMode - - -@dataclass(frozen=True) -class StemHierarchy: - """ - The precedence structure of a stems assignment: stems grouped into levels that - pick in order, with a mode choosing how picks alternate between levels. - """ - - levels: Tuple[Tuple[int, ...], ...] - mode: HierarchyMode diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 61119ad2c..89948f592 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -7,7 +7,7 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_core.audio import load_audio, mix, write_wave from sampletones_core.configs import Config -from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP +from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -17,6 +17,7 @@ STEM_A_ID, STEM_B_ID, STEM_C_ID, + STEM_RECORDING_DURATION_SECONDS, build_mini_library, three_stem_config, three_stem_reconstruction_config, @@ -28,6 +29,10 @@ _MIX_TOLERANCE: Final[float] = 1e-6 # float32 sums drift with accumulation order +def _frame_count(config: Config, duration_seconds: float) -> int: + return int(config.library.sample_rate * duration_seconds) // config.library.frame_length + + def _stems_config() -> StemsConfig: return StemsConfig( entries=[ @@ -119,13 +124,47 @@ def test_builds_a_reconstruction_over_the_three_stems(self, tmp_path: Path) -> N assignments = stems_data.assignments_by_channel assert assignments - assert set(assignments.get(ChannelName.PULSE2, [])) == {STEM_B_ID} - assert set(assignments.get(ChannelName.TRIANGLE, [])) <= {STEM_A_ID, STEM_B_ID} - assert set(assignments.get(ChannelName.PULSE1, [])) <= {STEM_A_ID, STEM_C_ID} - assert set(assignments.get(ChannelName.NOISE, [])) <= {STEM_A_ID, STEM_C_ID} + holders = { + ChannelName.PULSE2: {STEM_B_ID}, + ChannelName.TRIANGLE: {STEM_A_ID, STEM_B_ID}, + ChannelName.PULSE1: {STEM_A_ID, STEM_C_ID}, + ChannelName.NOISE: {STEM_A_ID, STEM_C_ID}, + } + for channel, allowed in holders.items(): + assert set(assignments.get(channel, [])) - {RESTING_STEM_ID} <= allowed + for channel, stem_ids in assignments.items(): assert len(reconstruction.instructions[channel]) == len(stem_ids) + def test_every_channel_in_play_carries_one_entry_per_frame(self, tmp_path: Path) -> None: + """A cap leaves channels unclaimed, and each of them rests rather than dropping its frame. + + Streams that skipped a frame would carry their later frames early, so what a channel plays + would drift out of step with the recording it was matched against. + """ + config = three_stem_reconstruction_config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + stems_config = three_stem_config() + paths = write_three_stem_recordings(config, tmp_path) + + reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + + assert reconstruction is not None + frame_count = _frame_count(config, STEM_RECORDING_DURATION_SECONDS) + assignments = reconstruction.stems_data.assignments_by_channel + assert set(assignments) == set(reconstruction.playing_channels) + + for channel, stem_ids in assignments.items(): + assert len(stem_ids) == frame_count + assert len(reconstruction.instructions[channel]) == frame_count + assert len(reconstruction.approximations[channel]) == frame_count * config.library.frame_length + + picks_per_frame = [ + sum(stem_ids[frame] != RESTING_STEM_ID for stem_ids in assignments.values()) for frame in range(frame_count) + ] + assert picks_per_frame == [len(stems_config.entries)] * frame_count + def test_round_trips_through_the_file(self, tmp_path: Path) -> None: config = three_stem_reconstruction_config() library = build_mini_library(config) @@ -305,6 +344,7 @@ def test_classic_conversion_records_one_stem_over_every_enabled_channel(self, tm assert len(stem_ids) == len(reconstruction.instructions[channel]) def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> None: + """One channel sounds per frame while the others rest, each keeping its place in the frame.""" config = Config() library = build_mini_library(config) reconstructor = Reconstructor(config, library=library) @@ -316,9 +356,13 @@ def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> ) assert reconstruction is not None - stems_data = reconstruction.stems_data - frame_count = int(config.library.sample_rate * _DURATION_SECONDS) // config.library.frame_length - assert sum(len(stem_ids) for stem_ids in stems_data.assignments_by_channel.values()) == frame_count - for stem_ids in stems_data.assignments_by_channel.values(): - assert set(stem_ids) <= {0} - assert len(stem_ids) <= frame_count + assignments = reconstruction.stems_data.assignments_by_channel + frame_count = _frame_count(config, _DURATION_SECONDS) + + for channel, stem_ids in assignments.items(): + assert set(stem_ids) <= {0, RESTING_STEM_ID} + assert len(stem_ids) == frame_count + assert len(reconstruction.instructions[channel]) == frame_count + + sounding = [sum(stem_ids[frame] == 0 for stem_ids in assignments.values()) for frame in range(frame_count)] + assert sounding == [1] * frame_count diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index b08c9277a..8e0489792 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -13,6 +13,7 @@ from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy class TestFromReconstruction: @@ -287,6 +288,7 @@ def _stems_data( StemEntry(id=0, channels=[ChannelName.PULSE1]), StemEntry(id=1, channels=[ChannelName.PULSE1]), ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), ) reconstruction = Reconstruction.create( approximation=approximation, @@ -339,6 +341,7 @@ def test_original_mix_mixes_the_selected_recordings( StemEntry(id=0, channels=[ChannelName.PULSE1]), StemEntry(id=1, channels=[ChannelName.PULSE1]), ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), ) reconstruction = reconstruction_factory().model_copy( update={ diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 10be582de..fc7a91c48 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -28,6 +28,7 @@ from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, @@ -945,6 +946,7 @@ def stems_data_fixture( StemEntry(id=0, channels=[ChannelName.PULSE1]), StemEntry(id=1, channels=[ChannelName.PULSE1]), ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), ) stems_reconstruction = reconstruction.model_copy( update={ diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py index 6488dc66a..8f660fe19 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -10,6 +10,7 @@ ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy FRAME_LENGTH: Final[int] = 2 @@ -17,7 +18,10 @@ def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: entries = [StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)] return StemsData( - config=StemsConfig(entries=entries), + config=StemsConfig( + entries=entries, + hierarchy=StemsHierarchy(levels=[[entry.id] for entry in entries]), + ), assignments=[ChannelAssignment(channel_name=channel, stem_ids=stem_ids) for channel, stem_ids in stem_lists], ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py index 290ea6ebd..48f837bfe 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py @@ -34,6 +34,7 @@ def test_duplicate_entry_ids_raise(self) -> None: StemEntry(id=0, channels=[ChannelName.PULSE1]), StemEntry(id=0, channels=[ChannelName.NOISE]), ], + hierarchy=StemsHierarchy(levels=[[0]]), ) def test_channel_cap_below_one_raises(self) -> None: @@ -46,3 +47,69 @@ def test_fields(self) -> None: assert stems.hierarchy.levels == [[0], [1]] assert stems.hierarchy.mode == HierarchyMode.STRICT assert stems.channel_cap == DEFAULT_STEMS_CHANNEL_CAP + + +class TestStemsConfigHierarchy: + """The hierarchy is the order the entries pick in, so it names each of them once.""" + + def test_a_duplicated_stem_raises(self) -> None: + with pytest.raises(ValidationError, match="exactly once"): + StemsConfig( + entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])], + hierarchy=StemsHierarchy(levels=[[0], [0]]), + ) + + def test_a_stem_left_out_raises(self) -> None: + with pytest.raises(ValidationError, match="exactly once"): + StemsConfig( + entries=[ + StemEntry(id=0, channels=[ChannelName.PULSE1]), + StemEntry(id=1, channels=[ChannelName.NOISE]), + ], + hierarchy=StemsHierarchy(levels=[[0]]), + ) + + def test_an_unknown_stem_raises(self) -> None: + with pytest.raises(ValidationError, match="exactly once"): + StemsConfig( + entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])], + hierarchy=StemsHierarchy(levels=[[0], [5]]), + ) + + +class TestStemsConfigViews: + def test_entries_are_keyed_by_their_id(self) -> None: + stems = _stems_config() + assert set(stems.entries_by_id) == {0, 1} + assert stems.entries_by_id[1].channels == [ChannelName.NOISE] + + def test_covered_channels_gather_every_entry(self) -> None: + stems = _stems_config() + assert stems.covered_channels == frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + def test_frame_budget_stops_at_the_covered_channels(self) -> None: + stems = _stems_config() + assert stems.frame_budget == len(stems.covered_channels) + + def test_frame_budget_stops_at_the_cap(self) -> None: + stems = StemsConfig( + entries=[StemEntry(id=0, channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE])], + hierarchy=StemsHierarchy(levels=[[0]]), + channel_cap=1, + ) + assert stems.frame_budget == 1 + + +class TestSingleEntry: + def test_names_one_stem_over_every_channel(self) -> None: + channels = [ChannelName.PULSE1, ChannelName.TRIANGLE] + stems = StemsConfig.single_entry(channels) + + assert [entry.channels for entry in stems.entries] == [channels] + assert stems.hierarchy.levels == [[0]] + assert stems.covered_channels == frozenset(channels) + + def test_carries_the_cap_it_is_given(self) -> None: + stems = StemsConfig.single_entry([ChannelName.PULSE1, ChannelName.TRIANGLE], channel_cap=1) + assert stems.channel_cap == 1 + assert stems.frame_budget == 1 diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py index 669f57b77..5524d770c 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -1,4 +1,4 @@ -from typing import Dict, Final, Tuple +from typing import Dict, Final, Iterable, List, Tuple import numpy as np import pytest @@ -12,16 +12,30 @@ from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData from sampletones_core.reconstructions.reconstructor.selector.greedy import GreedySelector from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher -from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem -from sampletones_core.reconstructions.reconstructor.stems.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment -from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197) +def _config( + entries: Dict[int, Iterable[ChannelName]], + levels: List[List[int]], + mode: HierarchyMode, + channel_cap: int, +) -> StemsConfig: + return StemsConfig( + entries=[StemEntry(id=stem_id, channels=list(channels)) for stem_id, channels in entries.items()], + hierarchy=StemsHierarchy(levels=levels, mode=mode), + channel_cap=channel_cap, + ) + + class TestSingleStemEquivalence: def test_matches_the_greedy_baseline_exactly( self, @@ -31,17 +45,14 @@ def test_matches_the_greedy_baseline_exactly( extractor: FeatureExtractor, greedy_selector: GreedySelector, ) -> None: - stems = {0: Stem(id=0, channels=frozenset(channels))} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels)) assignment = assign_frame( synthetic_fragment, - stems, - hierarchy, + stems_config, channels, matcher, extractor, - len(channels), ) baseline = greedy_selector.reconstruct_fragment(synthetic_fragment) @@ -57,17 +68,14 @@ def test_matches_the_baseline_with_all_four_channels( extractor: FeatureExtractor, all_channels_selector: GreedySelector, ) -> None: - stems = {0: Stem(id=0, channels=frozenset(all_channels))} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + stems_config = _config({0: all_channels}, [[0]], HierarchyMode.STRICT, len(all_channels)) assignment = assign_frame( synthetic_fragment, - stems, - hierarchy, + stems_config, all_channels, matcher, extractor, - len(all_channels), ) baseline = all_channels_selector.reconstruct_fragment(synthetic_fragment) @@ -100,20 +108,19 @@ def test_matches_sequential_per_subset_baselines( expected = dict(baseline_first) expected.update(baseline_second) - stems = { - 0: Stem(id=0, channels=frozenset(subset_pulse_triangle)), - 1: Stem(id=1, channels=frozenset(subset_noise)), - } - hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.STRICT) + stems_config = _config( + {0: subset_pulse_triangle, 1: subset_noise}, + [[0], [1]], + HierarchyMode.STRICT, + len(channels), + ) assignment = assign_frame( synthetic_fragment, - stems, - hierarchy, + stems_config, channels, matcher, extractor, - len(channels), ) assert len(assignment.choices) == len(channels) @@ -134,41 +141,39 @@ def test_invariants_and_determinism( ) -> None: rng = np.random.default_rng(random_seed) fragment = _random_target_fragment(rng, config, window, extractor, library_data) - stems, hierarchy, channel_cap = _random_setup(rng, tuple(all_channels)) + stems_config = _random_setup(rng, tuple(all_channels)) assignment = assign_frame( fragment, - stems, - hierarchy, + stems_config, all_channels, matcher, extractor, - channel_cap, ) repeat = assign_frame( fragment, - stems, - hierarchy, + stems_config, all_channels, matcher, extractor, - channel_cap, ) assert _choice_keys(assignment.choices) == _choice_keys(repeat.choices) + assert assignment.resting == repeat.resting channels_assigned = [choice.channel_name for choice in assignment.choices] assert len(channels_assigned) == len(set(channels_assigned)) assert set(channels_assigned) <= set(all_channels) + assert set(channels_assigned) | set(assignment.resting) == stems_config.covered_channels counts: Dict[int, int] = {} for choice in assignment.choices: counts[choice.stem_id] = counts.get(choice.stem_id, 0) + 1 - assert choice.channel_name in stems[choice.stem_id].channels - assert all(count <= channel_cap for count in counts.values()) + assert choice.channel_name in stems_config.entries_by_id[choice.stem_id].channel_set + assert all(count <= stems_config.channel_cap for count in counts.values()) - if hierarchy.mode == HierarchyMode.STRICT: - _assert_strict_ordering(assignment, hierarchy) + if stems_config.hierarchy.mode == HierarchyMode.STRICT: + _assert_strict_ordering(assignment, stems_config.hierarchy) def _assert_same_picks( @@ -198,7 +203,7 @@ def _assert_same_fragment(left: Fragment, right: Fragment) -> None: def _assert_strict_ordering( assignment: StemFrameAssignment, - hierarchy: StemHierarchy, + hierarchy: StemsHierarchy, ) -> None: first_positions = [ index for index, choice in enumerate(assignment.choices) if choice.stem_id in hierarchy.levels[0] @@ -232,19 +237,18 @@ def _restricted_selector( def _random_setup( rng: np.random.Generator, channel_names: Tuple[ChannelName, ...], -) -> Tuple[Dict[int, Stem], StemHierarchy, int]: +) -> StemsConfig: shuffled = list(channel_names) rng.shuffle(shuffled) split = int(rng.integers(1, len(shuffled))) - stems = { - 0: Stem(id=0, channels=frozenset(shuffled[:split])), - 1: Stem(id=1, channels=frozenset(shuffled[split:])), - } mode = HierarchyMode.ROUND_ROBIN if bool(rng.integers(2)) else HierarchyMode.STRICT - hierarchy = StemHierarchy(levels=((0,), (1,)), mode=mode) - channel_cap = int(rng.integers(1, len(channel_names) + 1)) - return stems, hierarchy, channel_cap + return _config( + {0: shuffled[:split], 1: shuffled[split:]}, + [[0], [1]], + mode, + int(rng.integers(1, len(channel_names) + 1)), + ) def _random_target_fragment( diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py index 9a730f629..cc8c89da3 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -1,4 +1,4 @@ -from typing import Dict, FrozenSet, Sequence, Tuple +from typing import Dict, List, Sequence, Tuple import pytest @@ -7,60 +7,46 @@ from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher -from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem -from sampletones_core.reconstructions.reconstructor.stems.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment -from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy -DEFAULT_CHANNELS: FrozenSet[ChannelName] = frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE)) +DEFAULT_CHANNELS: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] + + +def _config( + entries: Dict[int, List[ChannelName]], + levels: List[List[int]], + mode: HierarchyMode, + channel_cap: int, +) -> StemsConfig: + return StemsConfig( + entries=[StemEntry(id=stem_id, channels=channels) for stem_id, channels in entries.items()], + hierarchy=StemsHierarchy(levels=levels, mode=mode), + channel_cap=channel_cap, + ) def _assign( fragment: Fragment, - stems: Dict[int, Stem], - hierarchy: StemHierarchy, + stems_config: StemsConfig, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, - channel_cap: int, ) -> StemFrameAssignment: return assign_frame( fragment, - stems, - hierarchy, + stems_config, channels, matcher, extractor, - channel_cap, ) class TestAssignFrameValidation: - def test_zero_cap_raises( - self, - synthetic_fragment: Fragment, - channels: Dict[ChannelName, GeneratorUnion], - matcher: FrameMatcher, - extractor: FeatureExtractor, - ) -> None: - stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) - with pytest.raises(ValueError, match="channel_cap"): - _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 0) - - def test_stem_id_disagreeing_with_its_key_raises( - self, - synthetic_fragment: Fragment, - channels: Dict[ChannelName, GeneratorUnion], - matcher: FrameMatcher, - extractor: FeatureExtractor, - ) -> None: - stems = {0: Stem(id=1, channels=DEFAULT_CHANNELS)} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) - with pytest.raises(ValueError, match="keyed"): - _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) - def test_channel_outside_enabled_channels_raises( self, synthetic_fragment: Fragment, @@ -68,49 +54,56 @@ def test_channel_outside_enabled_channels_raises( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = {0: Stem(id=0, channels=frozenset((ChannelName.PULSE2,)))} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) + stems_config = _config({0: [ChannelName.PULSE2]}, [[0]], HierarchyMode.STRICT, 1) with pytest.raises(ValueError, match="configuration lacks"): - _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + _assign(synthetic_fragment, stems_config, channels, matcher, extractor) - def test_hierarchy_duplicating_a_stem_raises( + +class TestFrameCompleteness: + """Every covered channel leaves the frame either picked or resting, never neither.""" + + def test_a_capped_frame_rests_the_channels_no_stem_took( self, synthetic_fragment: Fragment, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} - hierarchy = StemHierarchy(levels=((0,), (0,)), mode=HierarchyMode.STRICT) - with pytest.raises(ValueError, match="exactly once"): - _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) - def test_hierarchy_leaving_a_stem_out_raises( + assert len(assignment.choices) == 1 + assert set(assignment.by_channel) | set(assignment.resting) == stems_config.covered_channels + assert set(assignment.by_channel).isdisjoint(assignment.resting) + + def test_a_full_frame_rests_nothing( self, synthetic_fragment: Fragment, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = { - 0: Stem(id=0, channels=DEFAULT_CHANNELS), - 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), - } - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) - with pytest.raises(ValueError, match="exactly once"): - _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) - def test_hierarchy_naming_an_unknown_stem_raises( + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) + + assert assignment.resting == () + assert set(assignment.by_channel) == stems_config.covered_channels + + def test_a_channel_no_stem_may_occupy_stays_out_of_the_frame( self, synthetic_fragment: Fragment, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} - hierarchy = StemHierarchy(levels=((0,), (5,)), mode=HierarchyMode.STRICT) - with pytest.raises(ValueError, match="exactly once"): - _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 1) + stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) + + assert set(assignment.by_channel) == {ChannelName.PULSE1} + assert assignment.resting == () class TestChannelCap: @@ -121,13 +114,12 @@ def test_strict_mode_respects_the_cap( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.STRICT) - for cap, expected_count in ((1, 1), (2, 2), (5, 3)): - assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, cap) + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, cap) + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) assert len(assignment.choices) == expected_count assert {choice.stem_id for choice in assignment.choices} == {0} + assert len(assignment.resting) == len(DEFAULT_CHANNELS) - expected_count def test_round_robin_mode_respects_the_cap( self, @@ -136,11 +128,12 @@ def test_round_robin_mode_respects_the_cap( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = {0: Stem(id=0, channels=DEFAULT_CHANNELS)} - hierarchy = StemHierarchy(levels=((0,),), mode=HierarchyMode.ROUND_ROBIN) + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.ROUND_ROBIN, 2) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) - assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) assert len(assignment.choices) == 2 + assert len(assignment.resting) == 1 class TestTieBreakDeterminism: @@ -151,31 +144,23 @@ def test_equal_cost_choices_go_to_the_first_stem_in_level_order( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - pulse_only = frozenset((ChannelName.PULSE1,)) - stems = { - 0: Stem(id=0, channels=pulse_only), - 1: Stem(id=1, channels=pulse_only), - } + entries = {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]} first = _assign( synthetic_fragment, - stems, - StemHierarchy(levels=((0, 1),), mode=HierarchyMode.STRICT), + _config(entries, [[0, 1]], HierarchyMode.STRICT, 1), channels, matcher, extractor, - 1, ) assert [(choice.stem_id, choice.channel_name) for choice in first.choices] == [(0, ChannelName.PULSE1)] swapped = _assign( synthetic_fragment, - stems, - StemHierarchy(levels=((1, 0),), mode=HierarchyMode.STRICT), + _config(entries, [[1, 0]], HierarchyMode.STRICT, 1), channels, matcher, extractor, - 1, ) assert [(choice.stem_id, choice.channel_name) for choice in swapped.choices] == [(1, ChannelName.PULSE1)] @@ -186,15 +171,18 @@ def test_repeated_runs_give_identical_choices( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = { - 0: Stem(id=0, channels=frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE))), - 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), - } - hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.STRICT) + stems_config = _config( + {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.NOISE]}, + [[0], [1]], + HierarchyMode.STRICT, + 2, + ) + + first = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) + second = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) - first = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) - second = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) assert _choice_keys(first.choices) == _choice_keys(second.choices) + assert first.resting == second.resting class TestHierarchyOrdering: @@ -205,13 +193,14 @@ def test_strict_mode_exhausts_the_first_level_before_the_next( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = { - 0: Stem(id=0, channels=frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE))), - 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), - } - hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.STRICT) + stems_config = _config( + {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.NOISE]}, + [[0], [1]], + HierarchyMode.STRICT, + 2, + ) - assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) assert [choice.stem_id for choice in assignment.choices] == [0, 0, 1] assert {choice.channel_name for choice in assignment.choices[:2]} == { @@ -227,13 +216,14 @@ def test_round_robin_mode_alternates_levels_each_round( matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: - stems = { - 0: Stem(id=0, channels=frozenset((ChannelName.PULSE1, ChannelName.TRIANGLE))), - 1: Stem(id=1, channels=frozenset((ChannelName.NOISE,))), - } - hierarchy = StemHierarchy(levels=((0,), (1,)), mode=HierarchyMode.ROUND_ROBIN) + stems_config = _config( + {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.NOISE]}, + [[0], [1]], + HierarchyMode.ROUND_ROBIN, + 2, + ) - assignment = _assign(synthetic_fragment, stems, hierarchy, channels, matcher, extractor, 2) + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) assert [choice.stem_id for choice in assignment.choices] == [0, 1, 0] assert assignment.choices[1].channel_name == ChannelName.NOISE diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py index bcb985338..dcc69cca5 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py @@ -1,32 +1,65 @@ -from typing import Final, FrozenSet +from typing import Final, Tuple +import numpy as np + +from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_core.reconstructions.reconstructor.stems.configs.stem import Stem -from sampletones_core.reconstructions.reconstructor.stems.models.hierarchy import StemHierarchy +from sampletones_core.fft import Fragment +from sampletones_core.instructions import PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from sampletones_core.structures.histogram import Histogram + +FRAME_COST: Final[float] = 0.5 + + +def _fragment(config: Config) -> Fragment: + audio = np.zeros(config.frame_length, dtype=np.float32) + return Fragment( + audio=audio, + feature=Histogram(edges=np.array([0.0, 1.0], dtype=np.float32), values=np.zeros(1, dtype=np.float32)), + windowed_audio=audio, + config=config, + ) + + +def _choices(config: Config) -> Tuple[StemChoice, StemChoice]: + fragment = _fragment(config) + return ( + StemChoice( + stem_id=0, + channel_name=ChannelName.PULSE1, + instruction=PulseInstruction.default_instruction(), + approximation=fragment, + cost=FRAME_COST, + ), + StemChoice( + stem_id=1, + channel_name=ChannelName.TRIANGLE, + instruction=TriangleInstruction.default_instruction(), + approximation=fragment, + cost=FRAME_COST, + ), + ) + -PULSE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset((ChannelName.PULSE1, ChannelName.PULSE2)) +class TestStemFrameAssignment: + def test_by_channel_names_the_stem_holding_each_channel(self) -> None: + first, second = _choices(Config()) + assignment = StemFrameAssignment(choices=(first, second), resting=()) -class TestStem: - def test_is_frozen_and_hashable(self) -> None: - stem = Stem(id=0, channels=PULSE_CHANNELS) - assert stem == Stem(id=0, channels=PULSE_CHANNELS) - assert hash(stem) == hash(Stem(id=0, channels=PULSE_CHANNELS)) + assert assignment.by_channel == { + ChannelName.PULSE1: first, + ChannelName.TRIANGLE: second, + } - def test_fields(self) -> None: - stem = Stem(id=0, channels=PULSE_CHANNELS) - assert stem.id == 0 - assert stem.channels == PULSE_CHANNELS + def test_picked_and_resting_channels_stay_apart(self) -> None: + first, _ = _choices(Config()) + assignment = StemFrameAssignment(choices=(first,), resting=(ChannelName.NOISE,)) -class TestStemHierarchy: - def test_fields(self) -> None: - hierarchy = StemHierarchy( - levels=((0,), (1, 2)), - mode=HierarchyMode.ROUND_ROBIN, - ) - assert hierarchy.levels == ((0,), (1, 2)) - assert hierarchy.mode == HierarchyMode.ROUND_ROBIN + assert set(assignment.by_channel).isdisjoint(assignment.resting) class TestHierarchyMode: diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index 5d74546c4..54591c5fa 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -15,6 +15,7 @@ ) from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor from sampletones_core.reconstructions.reconstructor.state import ReconstructionState +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoLibraryDataError @@ -65,9 +66,8 @@ def test_uniform_audio_anchors_to_its_level( ) -> None: reconstructor = _make_reconstructor(config, library_data) audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5 - coefficient = reconstructor.get_coefficient(audio) - total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.channels.values()) - assert coefficient == pytest.approx(0.5 / total_mixer) + coefficient = reconstructor.get_coefficient(audio, _full_setup(config)) + assert coefficient == pytest.approx(0.5 / _total_mixer(reconstructor)) def test_coefficient_is_robust_to_a_lone_transient( self, @@ -76,10 +76,10 @@ def test_coefficient_is_robust_to_a_lone_transient( ) -> None: reconstructor = _make_reconstructor(config, library_data) frame_length = config.library.frame_length - total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.channels.values()) + total_mixer = _total_mixer(reconstructor) audio = np.full(frame_length * 24, 0.05, dtype=np.float32) audio[:frame_length] = 1.0 - coefficient = reconstructor.get_coefficient(audio) + coefficient = reconstructor.get_coefficient(audio, _full_setup(config)) assert coefficient == pytest.approx(0.05 / total_mixer, rel=1e-3) assert coefficient < 1.0 / total_mixer @@ -89,9 +89,32 @@ def test_louder_audio_produces_larger_coefficient( library_data: InstructionLibraryData, ) -> None: reconstructor = _make_reconstructor(config, library_data) + setup = _full_setup(config) quiet = np.ones(config.library.frame_length, dtype=np.float32) * 0.1 loud = np.ones(config.library.frame_length, dtype=np.float32) * 0.9 - assert reconstructor.get_coefficient(loud) > reconstructor.get_coefficient(quiet) + assert reconstructor.get_coefficient(loud, setup) > reconstructor.get_coefficient(quiet, setup) + + def test_a_capped_setup_anchors_to_what_one_frame_reaches( + self, + config: Config, + library_data: InstructionLibraryData, + ) -> None: + """One channel per frame reaches one channel's weight, so that is what the level is measured against.""" + reconstructor = _make_reconstructor(config, library_data) + capped = StemsConfig.single_entry(list(config.generation.channels), channel_cap=1) + audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5 + + loudest = max(MIXER_LEVELS[generator.class_name()] for generator in reconstructor.channels.values()) + + assert reconstructor.get_coefficient(audio, capped) == pytest.approx(0.5 / loudest) + + +def _full_setup(config: Config) -> StemsConfig: + return StemsConfig.single_entry(list(config.generation.channels)) + + +def _total_mixer(reconstructor: Reconstructor) -> float: + return sum(MIXER_LEVELS[generator.class_name()] for generator in reconstructor.channels.values()) class TestReconstructorGetFragments: diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py index cd8ddff32..5efe8bed5 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py @@ -2,29 +2,20 @@ from dataclasses import dataclass from typing import List -from unittest.mock import MagicMock import numpy as np import pytest from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import Fragment from sampletones_core.instructions import PulseInstruction -from sampletones_core.reconstructions.reconstructor.approximation import ( - ApproximationData, -) from sampletones_core.reconstructions.reconstructor.state import ReconstructionState from tests.suite.case import BaseTestCase _FRAME_LENGTH = 16 -def _make_approximation_data(channel_name: ChannelName) -> ApproximationData: - return ApproximationData( - channel_name=channel_name, - approximation=MagicMock(spec=Fragment), - instruction=PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0), - ) +def _make_instruction() -> PulseInstruction: + return PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0) def _make_audio(value: float = 1.0) -> np.ndarray: @@ -89,27 +80,48 @@ def state(self) -> ReconstructionState: return ReconstructionState.create([ChannelName.PULSE1, ChannelName.TRIANGLE]) def test_instruction_added_for_correct_generator(self, state: ReconstructionState) -> None: - approximation_data = _make_approximation_data(ChannelName.PULSE1) - state.append(approximation_data, _make_audio()) - assert state.instructions[ChannelName.PULSE1] == [approximation_data.instruction] + instruction = _make_instruction() + state.append(ChannelName.PULSE1, instruction, _make_audio()) + assert state.instructions[ChannelName.PULSE1] == [instruction] def test_approximation_added_for_correct_generator(self, state: ReconstructionState) -> None: audio = _make_audio(0.5) - state.append(_make_approximation_data(ChannelName.PULSE1), audio) + state.append(ChannelName.PULSE1, _make_instruction(), audio) assert len(state.approximations[ChannelName.PULSE1]) == 1 np.testing.assert_array_equal(state.approximations[ChannelName.PULSE1][0], audio) @pytest.mark.parametrize("case", ACCUMULATE_CASES, ids=lambda c: c.label) def test_multiple_appends_accumulate_in_order(self, case: TestCase, state: ReconstructionState) -> None: - for i in range(case.count): - state.append(_make_approximation_data(ChannelName.PULSE1), _make_audio(float(i))) + for index in range(case.count): + state.append(ChannelName.PULSE1, _make_instruction(), _make_audio(float(index))) assert len(state.instructions[ChannelName.PULSE1]) == case.count assert len(state.approximations[ChannelName.PULSE1]) == case.count def test_append_to_separate_generators_are_independent(self, state: ReconstructionState) -> None: - state.append(_make_approximation_data(ChannelName.PULSE1), _make_audio(1.0)) - state.append(_make_approximation_data(ChannelName.TRIANGLE), _make_audio(2.0)) + state.append(ChannelName.PULSE1, _make_instruction(), _make_audio(1.0)) + state.append(ChannelName.TRIANGLE, _make_instruction(), _make_audio(2.0)) assert len(state.instructions[ChannelName.PULSE1]) == 1 assert len(state.approximations[ChannelName.PULSE1]) == 1 assert len(state.instructions[ChannelName.TRIANGLE]) == 1 assert len(state.approximations[ChannelName.TRIANGLE]) == 1 + + +class TestReconstructionStateDrop: + @pytest.fixture + def state(self) -> ReconstructionState: + state = ReconstructionState.create([ChannelName.PULSE1, ChannelName.TRIANGLE]) + state.append(ChannelName.PULSE1, _make_instruction(), _make_audio()) + return state + + def test_dropping_a_channel_releases_its_stream(self, state: ReconstructionState) -> None: + state.drop(ChannelName.TRIANGLE) + + assert state.channel_names == [ChannelName.PULSE1] + assert set(state.instructions) == {ChannelName.PULSE1} + assert set(state.approximations) == {ChannelName.PULSE1} + + def test_the_remaining_channels_keep_their_frames(self, state: ReconstructionState) -> None: + state.drop(ChannelName.TRIANGLE) + + assert len(state.instructions[ChannelName.PULSE1]) == 1 + assert len(state.approximations[ChannelName.PULSE1]) == 1 From 4248fe83becd9e23e9ba91a92c7f7f6bd1f99fad Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 21 Aug 2026 23:44:55 +0200 Subject: [PATCH 039/142] Restored: the configured decoder over the stems assignment --- docs/concepts/reconstruction.md | 89 +++++--- docs/glossary.md | 10 +- src/sampletones_core/constants/algorithm.py | 2 + .../reconstructions/__init__.py | 13 +- .../reconstructor/approximation.py | 13 -- .../reconstructor/decoder/__init__.py | 22 ++ .../reconstructor/decoder/base.py | 38 ++++ .../reconstructor/decoder/greedy.py | 19 ++ .../{selector => decoder}/viterbi.py | 105 ++------- .../reconstructor/{selector => }/matching.py | 47 ++-- .../reconstructor/reconstructor.py | 211 +++++------------- .../reconstructor/selector/__init__.py | 21 -- .../reconstructor/selector/base.py | 79 ------- .../reconstructor/selector/greedy.py | 16 -- .../reconstructor/stems/assignment/frame.py | 9 +- .../reconstructor/stems/assignment/session.py | 130 +++++++++-- .../reconstructor/stems/assignment/track.py | 48 ++++ .../reconstructor/stems/models/choice.py | 9 + .../stems/models/frame_assignment.py | 7 +- .../reconstructor/stems/models/rest.py | 15 ++ .../reconstructions/reconstructor/worker.py | 55 ++--- .../reconstruction/test_decoding.py | 88 ++++++++ .../{selector => decoder}/__init__.py | 0 .../reconstructor/decoder/conftest.py | 42 ++++ .../reconstructor/decoder/test_decoders.py | 72 ++++++ .../reconstructor/decoder/test_viterbi.py | 67 ++++++ .../reconstructor/selector/test_viterbi.py | 145 ------------ .../reconstructor/stems/conftest.py | 71 +++--- .../reconstructor/stems/test_equivalence.py | 93 +++++--- .../reconstructor/stems/test_frame.py | 81 ++++++- .../reconstructor/stems/test_models.py | 114 ++++++++-- .../reconstructor/test_matching.py | 76 +++++++ .../reconstructor/test_reconstructor.py | 163 +++++--------- .../reconstructor/test_scorer.py | 4 +- .../reconstructor/test_selector.py | 49 ---- .../reconstructor/test_worker.py | 179 +++------------ 36 files changed, 1164 insertions(+), 1038 deletions(-) delete mode 100644 src/sampletones_core/reconstructions/reconstructor/approximation.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/decoder/base.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py rename src/sampletones_core/reconstructions/reconstructor/{selector => decoder}/viterbi.py (51%) rename src/sampletones_core/reconstructions/reconstructor/{selector => }/matching.py (73%) delete mode 100644 src/sampletones_core/reconstructions/reconstructor/selector/__init__.py delete mode 100644 src/sampletones_core/reconstructions/reconstructor/selector/base.py delete mode 100644 src/sampletones_core/reconstructions/reconstructor/selector/greedy.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py create mode 100644 tests/integration/reconstruction/test_decoding.py rename tests/unit/sampletones_core/reconstructions/reconstructor/{selector => decoder}/__init__.py (100%) create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py delete mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py delete mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index b069c1a30..deb1d6893 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -55,14 +55,16 @@ input through a fixed sequence of stages: instruction per frame. 4. **Describe each frame** by a spectral feature that captures its frequency content (§3). -5. **Select** the instructions — for every frame and channel, pick the candidate - that best matches, judged by the criterion (§5 and §4). -6. **Render** the chosen instructions back into audio through the generators, +5. **Assign** every frame's channels to the sources, and with each channel the + candidates it may sound there, judged by the criterion (§5 and §4). +6. **Decode** each channel's stream, reading its candidates across the whole + recording (§5). +7. **Render** the chosen instructions back into audio through the generators, keeping each oscillator continuous across frames. -7. **Reassemble** the channels into the final approximation and package it, with the +8. **Reassemble** the channels into the final approximation and package it, with the instruction streams, as a `Reconstruction`. -Stages 3–5 are where the algorithms described below live; the rest is preparation +Stages 3–6 are where the algorithms described below live; the rest is preparation and playback. ## 3. Representing a frame @@ -172,54 +174,77 @@ on machines with a GPU, runs on the array backend in `sampletones_shared`. ## 5. Choosing instructions -Both selectors live in `sampletones_core.reconstructions.reconstructor.selector` and -are chosen with `generation.decoder.selector`. They share the criterion and the -library; they differ only in how they search. The same candidate scoring drives -the stems assignment, which hands channels to several stems per frame -(see [Stems reconstruction](stems.md)). +Two questions settle what a frame plays, and each has its own owner. **Ownership** — +which channel a source holds this frame — is answered by the assignment in +`sampletones_core.reconstructions.reconstructor.stems.assignment`. **The stream** — +what a channel plays across the frames it holds — is answered by a decoder in +`sampletones_core.reconstructions.reconstructor.decoder`, named by +`generation.decoder.selector`. Both work from the same candidate scoring +(`reconstructor/matching.py`), the same criterion and the same library. -Both score candidates in two stages: every candidate is first ranked by the +The assignment leaves every channel in play a **column** per frame: the candidates +that channel may sound there, best first. The decoder reads those columns into one +candidate per frame. Each decoder states how wide a column it reads, and the +assignment builds columns to exactly that width. + +Candidates are scored in two stages: every candidate is first ranked by the phase-independent spectral term, and the best `top_k` are then re-scored with the full criterion, whose temporal term is evaluated on the candidate aligned to the target (`find_best_phase`). The aligned phase stands in for the rendered phase, which keeps each oscillator continuous across frames. -### 5.1 Greedy (per-frame) +### 5.1 Assigning channels -The greedy selector treats every frame independently: +A frame is assigned one pick at a time: ``` -remaining = {enabled channels} -while remaining: - pick the single (channel, instruction) with the lowest cost - across all candidates of all remaining channels +free = {channels the setup covers} +while a source may still take a channel and free is non-empty: + pick the single (source, channel, instruction) with the lowest cost + across every candidate of every channel that source may still take subtract its rendered contribution from the frame's residual - assign it and remove that channel from `remaining` + assign it and remove that channel from `free` ``` -It assigns each channel exactly once per frame, always letting the channel that fits -the residual best go first. When several channels share one generator kind, the -lowest remaining channel of that kind represents it during scoring, so successive -picks over one kind land on the lowest free channel. It is simple and fast, but it -has **no memory between frames**: nothing discourages the instruction streams from -jumping around frame to frame, which can sound jittery even when each individual -frame is well matched. +Every pick lets whichever channel fits the residual best go first. Where several +channels share one generator kind, the lowest free channel of that kind represents it +during scoring, so successive picks over one kind land on the lowest free channel. A +channel still free when the picks end **rests**: it holds its channel's null +instruction for that frame, which is what keeps every channel's stream in step with +the frames it describes. + +A classic single-file conversion is one source covering every enabled channel, so the +loop above assigns each channel exactly once per frame. Several sources, a precedence +hierarchy and a per-source channel cap are the general case, described in +[Stems reconstruction](stems.md). + +### 5.2 Greedy decoding -### 5.2 Viterbi (continuity-aware) +The greedy decoder plays each frame's best candidate, reading one candidate per +column. Each frame is then decided by its own cost alone, which is fast and +straightforward, and the instruction streams follow each frame's match wherever it +leads — audible as jitter even where every individual frame is well matched. -The Viterbi selector adds temporal continuity. For each frame it keeps the *top-k* -lowest-cost candidates per channel, forming a lattice of states over time. It then +### 5.3 Viterbi decoding + +The Viterbi decoder weighs a frame's candidates against the frames around them. It +reads `top_k` candidates per column, forming a lattice of states over time, and finds, per channel, the lowest-cost **path** through that lattice, where the path cost combines: - the per-frame **match cost** (the criterion, as an emission cost), and -- a **transition cost** between consecutive frames that penalizes discontinuity — - switching a channel on or off, and changing pitch, volume or timbre. +- a **transition cost** between consecutive frames that grows with what changes + between two instructions — turning a channel on or off, and changing pitch, volume + or timbre. Minimizing emission plus transition costs (the classic Viterbi dynamic program) yields instruction streams that track the audio while changing only when the -improvement in match quality outweighs the cost of the change. The result is -smoother and more musical than the greedy output. It is the default. +improvement in match quality outweighs the cost of the change. The result is smoother +and more musical than the greedy output. It is the default. + +A resting frame reaches the decoder as a column of one, so a channel that no source +took sits in the path as the off state it is, and coming back on costs what any other +on/off change costs. ## 6. Rendering and reassembly diff --git a/docs/glossary.md b/docs/glossary.md index 8e9780790..eb7c1f8a6 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -137,12 +137,12 @@ Perceptual weightings applied so each frequency bin counts in proportion to how the ear hears it: ERB spaces bins by auditory critical bands, and K-weighting applies a loudness curve. -### Selector +### Decoder -The strategy that searches the library for each frame's instructions. The -**greedy** selector treats every frame independently; the **Viterbi** selector -(the default) favours continuity, changing a channel only when the gain in match -quality outweighs the cost of the change. +The strategy that reads a channel's per-frame candidates into the stream it plays, +named by `generation.decoder.selector`. The **greedy** decoder plays each frame's +best candidate; the **Viterbi** decoder (the default) favours continuity, changing a +channel only when the gain in match quality outweighs the cost of the change. ### Calibration diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index c7d6cd776..d90c0afe8 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -53,6 +53,7 @@ SELECTOR: Final[SelectorName] = SelectorName.VITERBI DECODER_TOP_K: Final[int] = 8 +SINGLE_STATE_LATTICE_WIDTH: Final[int] = 1 TRANSITION_PITCH_WEIGHT: Final[float] = 0.03 TRANSITION_VOLUME_WEIGHT: Final[float] = 0.02 TRANSITION_TIMBRE_WEIGHT: Final[float] = 0.10 @@ -69,6 +70,7 @@ DEFAULT_STEMS_CHANNEL_CAP: Final[int] = ALL_STEMS_CHANNEL_CAP DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN RESTING_STEM_ID: Final[int] = -1 +RESTING_FRAME_COST: Final[float] = 0.0 # Execution diff --git a/src/sampletones_core/reconstructions/__init__.py b/src/sampletones_core/reconstructions/__init__.py index 4608b5499..dfebc7089 100644 --- a/src/sampletones_core/reconstructions/__init__.py +++ b/src/sampletones_core/reconstructions/__init__.py @@ -1,7 +1,8 @@ from .criterion import Criterion from .reconstruction.reconstruction import Reconstruction -from .reconstructor.approximation import ApproximationData from .reconstructor.candidates import CandidateProvider +from .reconstructor.decoder import Decoder, GreedyDecoder, ViterbiDecoder +from .reconstructor.matching import FrameMatcher, ScoredCandidate from .reconstructor.phase import ( CrossCorrelationPhaseAligner, PhaseAligner, @@ -9,7 +10,6 @@ ) from .reconstructor.reconstructor import Reconstructor from .reconstructor.scorer import Scorer -from .reconstructor.selector import GreedySelector, Selector, ViterbiSelector from .reconstructor.state import ( FragmentReconstructionState, ReconstructionState, @@ -17,19 +17,20 @@ from .reconstructor.worker import ReconstructorWorker __all__ = [ - "ApproximationData", "CandidateProvider", "Criterion", "CrossCorrelationPhaseAligner", + "Decoder", "FragmentReconstructionState", - "GreedySelector", + "FrameMatcher", + "GreedyDecoder", "PhaseAligner", "Reconstruction", "ReconstructionState", "Reconstructor", "ReconstructorWorker", + "ScoredCandidate", "Scorer", - "Selector", "SlidingRmsePhaseAligner", - "ViterbiSelector", + "ViterbiDecoder", ] diff --git a/src/sampletones_core/reconstructions/reconstructor/approximation.py b/src/sampletones_core/reconstructions/reconstructor/approximation.py deleted file mode 100644 index 0efe6dd55..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/approximation.py +++ /dev/null @@ -1,13 +0,0 @@ -from pydantic import BaseModel, ConfigDict - -from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import Fragment -from sampletones_core.instructions import InstructionUnion - - -class ApproximationData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) - - channel_name: ChannelName - approximation: Fragment - instruction: InstructionUnion diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py b/src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py new file mode 100644 index 000000000..d3bff8a82 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py @@ -0,0 +1,22 @@ +from typing import Dict, Type + +from sampletones_core.constants.enums import SelectorName + +from .base import ChannelLattice, Decoder, Lattices, Streams +from .greedy import GreedyDecoder +from .viterbi import ViterbiDecoder + +DECODERS: Dict[SelectorName, Type[Decoder]] = { + SelectorName.GREEDY: GreedyDecoder, + SelectorName.VITERBI: ViterbiDecoder, +} + +__all__ = [ + "DECODERS", + "ChannelLattice", + "Decoder", + "GreedyDecoder", + "Lattices", + "Streams", + "ViterbiDecoder", +] diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/base.py b/src/sampletones_core/reconstructions/reconstructor/decoder/base.py new file mode 100644 index 000000000..ed1b35b2e --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/decoder/base.py @@ -0,0 +1,38 @@ +from abc import ABC, abstractmethod +from typing import Dict, List + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName + +from ..matching import Column, ScoredCandidate + +ChannelLattice = List[Column] +Lattices = Dict[ChannelName, ChannelLattice] +Streams = Dict[ChannelName, List[ScoredCandidate]] + + +class Decoder(ABC): + """ + Chooses what each channel plays across the frames it was given. + + A frame assignment hands every channel in play one column per frame: the candidates that + channel may sound there, best first. A decoder reads those columns and answers one candidate + per frame per channel, which is the instruction stream the reconstruction records. Ownership + is settled before a decoder sees the frames, so a decoder decides the stream alone. + + ``lattice_width`` states how many alternatives per frame the decoder reads, and the + assignment builds columns to exactly that width, so a decoder that reads one candidate + per frame costs the assignment nothing beyond the pick it already made. + """ + + def __init__(self, config: Config) -> None: + self.config = config + + @property + @abstractmethod + def lattice_width(self) -> int: + """How many candidates per frame the decoder chooses among.""" + + @abstractmethod + def decode(self, lattices: Lattices) -> Streams: + """One candidate per frame for every channel, in frame order.""" diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py b/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py new file mode 100644 index 000000000..f16c73d94 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py @@ -0,0 +1,19 @@ +from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH + +from .base import Decoder, Lattices, Streams + + +class GreedyDecoder(Decoder): + """ + Plays each frame's best candidate, so every frame stands on its own. + + Reading one candidate per frame makes the frame's own cost the whole decision, which is + the classic behaviour: what the matching ranked first is what the channel plays. + """ + + @property + def lattice_width(self) -> int: + return SINGLE_STATE_LATTICE_WIDTH + + def decode(self, lattices: Lattices) -> Streams: + return {channel_name: [column[0] for column in frames] for channel_name, frames in lattices.items()} diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py similarity index 51% rename from src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py rename to src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py index f109a21fa..47f7ed721 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py +++ b/src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py @@ -1,102 +1,43 @@ import itertools -from typing import Dict, List, Tuple +from typing import List, Tuple import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import Fragment, FragmentedAudio, Window -from sampletones_core.fft.features import FeatureExtractor -from sampletones_core.generators import GeneratorUnion from sampletones_core.instructions import InstructionUnion -from ..approximation import ApproximationData -from ..candidates import CandidateProvider -from ..phase import PhaseAligner -from ..scorer import Scorer -from .base import Selector -from .matching import ScoredCandidate +from ..matching import ScoredCandidate +from .base import ChannelLattice, Decoder, Lattices, Streams -ChannelLattice = List[List[ScoredCandidate]] -FrameCandidates = Dict[ChannelName, List[ScoredCandidate]] +class ViterbiDecoder(Decoder): + """ + Plays the cheapest path through each channel's frames, cost and continuity together. -class ViterbiSelector(Selector): - def __init__( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - scorer: Scorer, - candidate_provider: CandidateProvider, - phase_aligner: PhaseAligner, - feature_extractor: FeatureExtractor, - ) -> None: - super().__init__( - config, - window, - channels, - scorer, - candidate_provider, - phase_aligner, - feature_extractor, - ) + A frame's candidates are weighed against the frames around them: on top of each + candidate's own cost, moving between two candidates costs what changes between their + instructions — pitch, volume, timbre, and turning a channel on or off. The path that + minimizes the total holds a steady note where per-frame costs alone would flicker. + """ + + def __init__(self, config: Config) -> None: + super().__init__(config) decoder = config.generation.decoder self.pitch_weight = decoder.pitch_weight self.volume_weight = decoder.volume_weight self.timbre_weight = decoder.timbre_weight self.on_off_weight = decoder.on_off_weight - def select( - self, - fragmented_audio: FragmentedAudio, - fragment_ids: List[int], - ) -> Dict[int, Dict[ChannelName, ApproximationData]]: - lattices = self._build_lattices(fragmented_audio, fragment_ids) - return self._decode_lattices(lattices, fragment_ids) - - def _build_lattices( - self, - fragmented_audio: FragmentedAudio, - fragment_ids: List[int], - ) -> Dict[ChannelName, ChannelLattice]: - lattices: Dict[ChannelName, ChannelLattice] = {name: [] for name in self.channels} - for fragment_id in fragment_ids: - for channel_name, states in self._frame_candidates(fragmented_audio[fragment_id]).items(): - lattices[channel_name].append(states) + @property + def lattice_width(self) -> int: + return self.config.generation.decoder.top_k - return lattices + def decode(self, lattices: Lattices) -> Streams: + return {channel_name: self._decode_channel(frames) for channel_name, frames in lattices.items()} - def _decode_lattices( - self, - lattices: Dict[ChannelName, ChannelLattice], - fragment_ids: List[int], - ) -> Dict[int, Dict[ChannelName, ApproximationData]]: - result: Dict[int, Dict[ChannelName, ApproximationData]] = {fragment_id: {} for fragment_id in fragment_ids} - for channel_name, frames in lattices.items(): - path = self._decode(frames) - for position, fragment_id in enumerate(fragment_ids): - state = frames[position][path[position]] - result[fragment_id][channel_name] = ApproximationData( - channel_name=channel_name, - approximation=state.approximation, - instruction=state.instruction, - ) - - return result - - def _frame_candidates(self, fragment: Fragment) -> FrameCandidates: - candidates: FrameCandidates = {} - residual = fragment - for channel_name, generator in self.channels.items(): - channel_states = self._channel_candidates(residual, generator) - candidates[channel_name] = channel_states - residual = self.feature_extractor.subtract(residual, channel_states[0].approximation) - - return candidates - - def _channel_candidates(self, residual: Fragment, generator: GeneratorUnion) -> List[ScoredCandidate]: - return self._score_candidates(residual, {generator.class_name(): generator}) + def _decode_channel(self, frames: ChannelLattice) -> List[ScoredCandidate]: + path = self._decode(frames) + return [frames[position][state] for position, state in enumerate(path)] def _decode(self, frames: ChannelLattice) -> List[int]: if not frames: @@ -125,7 +66,7 @@ def _forward_pass(self, frames: ChannelLattice) -> Tuple[List[List[int]], List[f def _best_predecessor( self, previous_costs: List[float], - previous_states: List[ScoredCandidate], + previous_states: Tuple[ScoredCandidate, ...], instruction: InstructionUnion, ) -> Tuple[int, float]: best_index = 0 diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/matching.py b/src/sampletones_core/reconstructions/reconstructor/matching.py similarity index 73% rename from src/sampletones_core/reconstructions/reconstructor/selector/matching.py rename to src/sampletones_core/reconstructions/reconstructor/matching.py index 26529255e..8e3a873b4 100644 --- a/src/sampletones_core/reconstructions/reconstructor/selector/matching.py +++ b/src/sampletones_core/reconstructions/reconstructor/matching.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from typing import Dict, List +from typing import Dict, List, Tuple from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.constants.enums import GeneratorClassName from sampletones_core.fft import Fragment from sampletones_core.generators import ( GeneratorUnion, @@ -10,10 +10,9 @@ ) from sampletones_core.instructions import InstructionUnion -from ..approximation import ApproximationData -from ..candidates import CandidateProvider -from ..phase import PhaseAligner -from ..scorer import Scorer +from .candidates import CandidateProvider +from .phase import PhaseAligner +from .scorer import Scorer @dataclass(frozen=True) @@ -23,14 +22,17 @@ class ScoredCandidate: approximation: Fragment +Column = Tuple[ScoredCandidate, ...] + + @dataclass(frozen=True) class FrameMatcher: """ Matches one target fragment against candidates of given generator classes. - Carries the matching machinery the selectors and the stems assignment share: the - two-stage criterion scoring, the winning channel's approximation, and the - per-candidate approximation build. + Carries the matching machinery the stems assignment works from: the two-stage criterion + scoring and the per-candidate approximation build. What the scoring produces is a column + of alternatives, which the assignment turns into ownership and the decoder into a stream. """ config: Config @@ -59,6 +61,10 @@ def score_candidates( phase stands in for the rendered phase, which keeps oscillator continuity across frames. + The shortlist is drawn by spectral rank, so scoring one generator class alone + returns every candidate of that class that a wider scoring would have kept, and + with it whichever of them the wider scoring picked. + Args: fragment: Target fragment to match. remaining_generator_classes: Generators still available for this fragment. @@ -101,29 +107,6 @@ def score_candidates( scored.sort(key=lambda candidate: candidate.cost) return scored - def best_approximation( - self, - fragment: Fragment, - remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], - ) -> ApproximationData: - """ - The winning channel's attribution, approximation, and instruction. - - Scores the candidates of the given generator classes and returns the best one - as the channel it belongs to, its rendered approximation, and its instruction. - """ - best = self.score_candidates(fragment, remaining_generator_classes)[0] - generator = get_generator_by_instruction( - best.instruction, - remaining_generator_classes, - ) - - return ApproximationData( - channel_name=ChannelName(generator.name), - approximation=best.approximation, - instruction=best.instruction, - ) - def build_approximation( self, fragment: Fragment, diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index acc0d2190..32d53a4ec 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -3,12 +3,9 @@ import numpy as np -from sampletones_core.audio import active_frame_level, load_audio, mix, silence +from sampletones_core.audio import active_frame_level, load_audio, mix from sampletones_core.configs import Config -from sampletones_core.constants.algorithm import ( - MINIMUM_AUDIO_LEVEL, - RESTING_STEM_ID, -) +from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import FragmentedAudio, Window from sampletones_core.generators import ( @@ -21,50 +18,17 @@ from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData -from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData -from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.decoder.base import Streams from sampletones_core.reconstructions.reconstructor.state import ReconstructionState from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame +from sampletones_core.reconstructions.reconstructor.stems.assignment.track import TrackAssignment from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig -from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker from sampletones_shared.exceptions import NoLibraryDataError from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.system.paths import to_path -def reconstruct( - fragments_ids: List[int], - fragmented_audio: FragmentedAudio, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - library_data: InstructionLibraryData, -) -> Dict[int, Dict[ChannelName, ApproximationData]]: - """Reconstructs the given fragments in a single worker pass. - - Args: - fragments_ids: Indices of the fragments to reconstruct. - fragmented_audio: The framed target audio. - config: The reconstruction configuration. - window: The analysis window. - channels: The channels to match against, each carrying its generator. - library_data: The instruction library the candidates are drawn from. - - Returns: - For each fragment id, the chosen approximation per channel. - """ - worker = ReconstructorWorker( - config=config, - window=window, - channels=channels, - library_data=library_data, - signal_length=fragmented_audio.audio.shape[0], - ) - - return worker(fragmented_audio, fragments_ids) - - class Reconstructor: """ Turns an audio file into a :class:`Reconstruction` of NES instructions. @@ -108,8 +72,7 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: The classic run is the stems pipeline's single-stem case: one stem covering every enabled channel on one precedence level, with the cap at the channel - count. The cap equals the channel count, so the greedy baseline plays - unchanged. + count, so every enabled channel is assigned in every frame. Args: path: Path to the audio file to reconstruct. @@ -132,8 +95,10 @@ def reconstruct_stems( Loads and normalizes every stem, matches the frames of the stems' mix against the library, and assigns each frame's channels to the stems following the - configured hierarchy and channel cap. The per-frame assignment is recorded in - the reconstruction's stems data. + configured hierarchy and channel cap. The assignment leaves every channel in + play a column of candidates per frame, which the configured decoder reads into + the stream that channel plays. The per-frame assignment is recorded in the + reconstruction's stems data. Args: paths: Paths to the stem audio files, one per stems entry. @@ -151,21 +116,16 @@ def reconstruct_stems( checked_paths = self._check_stem_paths(paths, stems_config) mixed = self._mix_stem_audios(checked_paths) fragmented_audio, coefficient = self._prepare_stem_frames(mixed, stems_config) - worker, matcher = self._build_stem_matcher(mixed) - assignments = self._assign_stem_frames( - fragmented_audio, - stems_config, - worker, - matcher, - ) - playing = self._drop_resting_channels(assignments) - stems_data = self._build_stems_data(stems_config, playing) + worker = self._build_worker(mixed) + assignment = self._assign_stem_frames(fragmented_audio, stems_config, worker) + self._drop_resting_channels(assignment) + self._record_streams(worker.decoder.decode(assignment.lattices)) return Reconstruction.from_state( self.state, self.config, coefficient, tuple(checked_paths), - stems_data=stems_data, + stems_data=self._build_stems_data(stems_config, assignment.stem_ids), ) @staticmethod @@ -212,100 +172,69 @@ def _prepare_stem_frames( self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered]) return self.get_fragments(mixed / coefficient), coefficient - def _build_stem_matcher( - self, - signal: np.ndarray, - ) -> Tuple[ReconstructorWorker, FrameMatcher]: - """Builds the worker and the frame matcher that score the stems' candidates.""" - worker = ReconstructorWorker( + def _build_worker(self, signal: np.ndarray) -> ReconstructorWorker: + """Builds the matching machinery and the decoder this recording runs through.""" + return ReconstructorWorker( config=self.config, window=self.window, channels=self.channels, library_data=self.library_data, signal_length=signal.shape[0], ) - matcher = FrameMatcher( - config=worker.config, - candidate_provider=worker.candidate_provider, - scorer=worker.scorer, - phase_aligner=worker.phase_aligner, - ) - return worker, matcher def _assign_stem_frames( self, fragmented_audio: FragmentedAudio, stems_config: StemsConfig, worker: ReconstructorWorker, - matcher: FrameMatcher, - ) -> Dict[ChannelName, List[int]]: - """Assigns every frame's channels to the stems and records both sides of the outcome. + ) -> TrackAssignment: + """Assigns every frame's channels to the stems and gathers the outcome per channel. - Each frame contributes one entry to every channel in play — a pick or a rest — so the - reconstruction state and the per-channel stem record stay parallel to the frames, and - stem id ``i`` names frame ``i`` of its channel. + Each frame answers every channel in play — a pick or a rest — so the lattices the + decoder reads and the per-channel stem record stay parallel to the frames, and stem + id ``i`` names frame ``i`` of its channel. """ - assignments: Dict[ChannelName, List[int]] = {name: [] for name in self.state.channel_names} + assignment = TrackAssignment(self.state.channel_names) for fragment_id in fragmented_audio.fragments_ids: - frame_assignment = assign_frame( - fragmented_audio[fragment_id], - stems_config, - self.channels, - matcher, - worker.feature_extractor, - ) - self._record_frame(frame_assignment, assignments) - - return assignments - - def _record_frame( - self, - frame_assignment: StemFrameAssignment, - assignments: Dict[ChannelName, List[int]], - ) -> None: - """Writes one frame's outcome into the state and the per-channel stem record.""" - for choice in frame_assignment.choices: - self._record( - choice.channel_name, - choice.instruction, - choice.approximation.audio, + assignment.add( + assign_frame( + fragmented_audio[fragment_id], + stems_config, + self.channels, + worker.matcher, + worker.feature_extractor, + worker.decoder.lattice_width, + ) ) - assignments[choice.channel_name].append(choice.stem_id) - for channel_name in frame_assignment.resting: - self._record_rest(channel_name) - assignments[channel_name].append(RESTING_STEM_ID) + return assignment - def _record_rest(self, channel_name: ChannelName) -> None: - """Records the frame of a channel no stem took: its null instruction, sounding nothing. - - The channel keeps its place in the frame, which is what holds every channel's stream - against the timeline the frames lay out, and the silent frame is what a cap or a - hierarchy leaving the channel free actually sounds like. - """ - generator = self.channels[channel_name] - instruction = generator.get_instruction_type().null_instruction() - self._record(channel_name, instruction, silence(generator.frame_length)) - - def _drop_resting_channels( - self, - assignments: Dict[ChannelName, List[int]], - ) -> Dict[ChannelName, List[int]]: - """Leaves the channels that sound, dropping those that rested through every frame. + def _drop_resting_channels(self, assignment: TrackAssignment) -> None: + """Leaves the channels that sound, releasing those that rested through every frame. A channel no stem ever took describes nothing, so it stands by: the state releases its stream and the record names it no more, which is what keeps a silent channel out of every export. """ - playing: Dict[ChannelName, List[int]] = {} - for channel_name, stem_ids in assignments.items(): - if all(stem_id == RESTING_STEM_ID for stem_id in stem_ids): - self.state.drop(channel_name) - continue + for channel_name in assignment.resting_channels: + self.state.drop(channel_name) + assignment.drop(channel_name) - playing[channel_name] = stem_ids + def _record_streams(self, streams: Streams) -> None: + """Folds the decoded streams into the state, one frame at a time. - return playing + Frame order is what carries a generator's oscillator phase from one frame into the + next, which is the continuity final regeneration renders against. + """ + for position in range(self._frame_count(streams)): + for channel_name in self.state.channel_names: + candidate = streams[channel_name][position] + self._record(channel_name, candidate.instruction, candidate.approximation.audio) + + @staticmethod + def _frame_count(streams: Streams) -> int: + """The frames the streams span; every channel in play answers each of them.""" + return max((len(stream) for stream in streams.values()), default=0) @staticmethod def _build_stems_data( @@ -394,29 +323,6 @@ def get_fragments(self, audio: np.ndarray) -> FragmentedAudio: """ return FragmentedAudio.create(audio, self.config, self.window) - def reconstruct(self, fragmented_audio: FragmentedAudio) -> None: - """Matches every fragment and records the chosen instructions in the state. - - Runs the matching worker over all fragments and folds each fragment's chosen - approximation into the running reconstruction state. - - Args: - fragmented_audio: The framed target audio to match. - """ - fragments_ids = fragmented_audio.fragments_ids - worker = ReconstructorWorker( - config=self.config, - window=self.window, - channels=self.channels, - library_data=self.library_data, - signal_length=fragmented_audio.audio.shape[0], - ) - - results = worker(fragmented_audio, fragments_ids) - for fragment_approximations in results.values(): - for fragment_approximation in fragment_approximations.values(): - self.update_state(fragment_approximation) - def load_library(self, library: Optional[InstructionLibrary] = None) -> InstructionLibraryData: """Loads and filters the instruction library for the enabled channels. @@ -446,19 +352,6 @@ def load_library(self, library: Optional[InstructionLibrary] = None) -> Instruct ), ) - def update_state(self, fragment_approximation: ApproximationData) -> None: - """Appends one fragment's chosen approximation to the reconstruction state. - - Args: - fragment_approximation: The chosen approximation for one fragment and - channel. - """ - self._record( - fragment_approximation.channel_name, - fragment_approximation.instruction, - fragment_approximation.approximation.audio, - ) - def _record( self, channel_name: ChannelName, diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py deleted file mode 100644 index 1d55e19f6..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Dict, Type - -from sampletones_core.constants.enums import SelectorName - -from .base import Selector -from .greedy import GreedySelector -from .matching import ScoredCandidate -from .viterbi import ViterbiSelector - -SELECTORS: Dict[SelectorName, Type[Selector]] = { - SelectorName.GREEDY: GreedySelector, - SelectorName.VITERBI: ViterbiSelector, -} - -__all__ = [ - "SELECTORS", - "GreedySelector", - "ScoredCandidate", - "Selector", - "ViterbiSelector", -] diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/base.py b/src/sampletones_core/reconstructions/reconstructor/selector/base.py deleted file mode 100644 index 09f753993..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/selector/base.py +++ /dev/null @@ -1,79 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Dict, List - -from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, GeneratorClassName -from sampletones_core.fft import Fragment, FragmentedAudio, Window -from sampletones_core.fft.features import FeatureExtractor -from sampletones_core.generators import ( - GeneratorUnion, - get_remaining_generator_classes, -) - -from ..approximation import ApproximationData -from ..candidates import CandidateProvider -from ..phase import PhaseAligner -from ..scorer import Scorer -from .matching import FrameMatcher, ScoredCandidate - - -class Selector(ABC): - def __init__( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - scorer: Scorer, - candidate_provider: CandidateProvider, - phase_aligner: PhaseAligner, - feature_extractor: FeatureExtractor, - ) -> None: - self.config = config - self.window = window - self.channels = channels - self.scorer = scorer - self.candidate_provider = candidate_provider - self.phase_aligner = phase_aligner - self.feature_extractor = feature_extractor - self.top_k = config.generation.decoder.top_k - self.matcher = FrameMatcher( - config=config, - candidate_provider=candidate_provider, - scorer=scorer, - phase_aligner=phase_aligner, - ) - - @abstractmethod - def select( - self, - fragmented_audio: FragmentedAudio, - fragment_ids: List[int], - ) -> Dict[int, Dict[ChannelName, ApproximationData]]: ... - - def reconstruct_fragment( - self, - fragment: Fragment, - ) -> Dict[ChannelName, ApproximationData]: - approximations: Dict[ChannelName, ApproximationData] = {} - remaining_channels = dict(self.channels.items()) - while remaining_channels: - remaining_generator_classes = get_remaining_generator_classes(remaining_channels) - approximation_data = self.matcher.best_approximation( - fragment, - remaining_generator_classes, - ) - fragment = self.feature_extractor.subtract( - fragment, - approximation_data.approximation, - ) - approximations[approximation_data.channel_name] = approximation_data - del remaining_channels[approximation_data.channel_name] - - return approximations - - def _score_candidates( - self, - fragment: Fragment, - remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], - ) -> List[ScoredCandidate]: - return self.matcher.score_candidates(fragment, remaining_generator_classes) diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py b/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py deleted file mode 100644 index e16254a6e..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Dict, List - -from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import FragmentedAudio - -from ..approximation import ApproximationData -from .base import Selector - - -class GreedySelector(Selector): - def select( - self, - fragmented_audio: FragmentedAudio, - fragment_ids: List[int], - ) -> Dict[int, Dict[ChannelName, ApproximationData]]: - return {fragment_id: self.reconstruct_fragment(fragmented_audio[fragment_id]) for fragment_id in fragment_ids} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py index 089094fef..11d0879b8 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py @@ -4,7 +4,7 @@ from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion -from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher from sampletones_core.reconstructions.reconstructor.stems.assignment.session import AssignmentSession from sampletones_core.reconstructions.reconstructor.stems.assignment.validation import validate_stems_config from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -17,6 +17,7 @@ def assign_frame( channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, + lattice_width: int, ) -> StemFrameAssignment: """ Assigns one target frame's channels to stems, one pick at a time. @@ -30,7 +31,9 @@ def assign_frame( The frame is answered whole: every covered channel is either picked or reported as resting, so a caller records one entry per channel per frame and the streams it - assembles stay parallel to the frames they describe. + assembles stay parallel to the frames they describe. Every channel leaves the frame + with a column of alternatives ``lattice_width`` wide, which is what the decoder + reading the frames chooses its stream from. Args: fragment: The frame to assign, matching the matcher and extractor feature @@ -39,6 +42,7 @@ def assign_frame( channels: The enabled channels with their generators. matcher: The candidate scoring machinery. extractor: The feature extractor whose subtraction forms the residual. + lattice_width: How many alternatives per channel the decoder reads. Returns: The picks in the order they were made, together with the channels left resting. @@ -53,5 +57,6 @@ def assign_frame( channels, matcher, extractor, + lattice_width, ) return session.run() diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py index 2ff308128..cc9ff659e 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py @@ -1,6 +1,15 @@ -from typing import Dict, List, Optional, Sequence, Set +from dataclasses import dataclass +from typing import Dict, List, Optional, Sequence, Set, Tuple -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.algorithm import ( + RESTING_FRAME_COST, + SINGLE_STATE_LATTICE_WIDTH, +) +from sampletones_core.constants.enums import ( + ChannelName, + GeneratorClassName, + HierarchyMode, +) from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import ( @@ -8,8 +17,10 @@ get_generator_by_instruction, get_remaining_generator_classes, ) -from sampletones_core.reconstructions.reconstructor.selector.matching import ( +from sampletones_core.reconstructions.reconstructor.matching import ( + Column, FrameMatcher, + ScoredCandidate, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import ( StemsConfig, @@ -20,6 +31,30 @@ from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import ( StemFrameAssignment, ) +from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest + + +@dataclass(frozen=True) +class StemOffer: + """What one stem offers for the current residual: a shortlist, and the channel it won with. + + The shortlist arrives best first, so its head is the candidate the stem competes with, and + the generator behind that head names the channel the stem would take. + """ + + stem_id: int + generator: GeneratorUnion + shortlist: Tuple[ScoredCandidate, ...] + generator_classes: Dict[GeneratorClassName, GeneratorUnion] + + @property + def candidate(self) -> ScoredCandidate: + return self.shortlist[0] + + @property + def class_restricted(self) -> bool: + """The shortlist covers this offer's generator class alone, so it is the channel's own column.""" + return len(self.generator_classes) == 1 class AssignmentSession: @@ -35,11 +70,14 @@ def __init__( channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, + lattice_width: int, ) -> None: + self.fragment = fragment self.stems_config = stems_config self.channels = channels self.matcher = matcher self.extractor = extractor + self.lattice_width = lattice_width self.channel_cap = stems_config.channel_cap self.residual = fragment covered = stems_config.covered_channels @@ -57,7 +95,7 @@ def run(self) -> StemFrameAssignment: return StemFrameAssignment( choices=tuple(self.choices), - resting=tuple(self.free_channels), + rests=self._rests(), ) def _round_robin(self) -> None: @@ -91,10 +129,11 @@ def _pick_from_level( if not eligible or not self.free_channels: return - choice = self._best_choice(eligible) - if choice is None: + offer = self._best_offer(eligible) + if offer is None: return + choice = self._choice(offer) self.choices.append(choice) self.used_channels[choice.stem_id] += 1 self.free_channels.remove(choice.channel_name) @@ -104,8 +143,8 @@ def _pick_from_level( ) picked_this_visit.add(choice.stem_id) - def _best_choice(self, stem_ids: Sequence[int]) -> Optional[StemChoice]: - best: Optional[StemChoice] = None + def _best_offer(self, stem_ids: Sequence[int]) -> Optional[StemOffer]: + best: Optional[StemOffer] = None for stem_id in stem_ids: remaining_channels = self._remaining_channels(stem_id) if not remaining_channels: @@ -116,23 +155,74 @@ def _best_choice(self, stem_ids: Sequence[int]) -> Optional[StemChoice]: self.residual, remaining_generator_classes, ) - candidate = scored[0] - generator = get_generator_by_instruction( - candidate.instruction, - remaining_generator_classes, - ) - choice = StemChoice( + offer = StemOffer( stem_id=stem_id, - channel_name=ChannelName(generator.name), - instruction=candidate.instruction, - approximation=candidate.approximation, - cost=candidate.cost, + generator=get_generator_by_instruction( + scored[0].instruction, + remaining_generator_classes, + ), + shortlist=tuple(scored), + generator_classes=remaining_generator_classes, ) - if best is None or choice.cost < best.cost: - best = choice + if best is None or offer.candidate.cost < best.candidate.cost: + best = offer return best + def _choice(self, offer: StemOffer) -> StemChoice: + return StemChoice( + stem_id=offer.stem_id, + channel_name=ChannelName(offer.generator.name), + instruction=offer.candidate.instruction, + approximation=offer.candidate.approximation, + cost=offer.candidate.cost, + column=self._column(offer), + ) + + def _column(self, offer: StemOffer) -> Column: + """The alternatives the decoder chooses among for the channel this offer won. + + A decoder reading one candidate per frame settles on the pick itself, so the frame is + answered by the scoring already done. A wider lattice scores the winning channel's own + candidates against the same residual, which reaches the alternatives a scoring across + several channels ranked below other channels' candidates. Where the offer was already + scored over one generator class, that scoring is the column. + """ + if self.lattice_width == SINGLE_STATE_LATTICE_WIDTH: + return (offer.candidate,) + + if offer.class_restricted: + return offer.shortlist[: self.lattice_width] + + generator = offer.generator + scored = self.matcher.score_candidates( + self.residual, + {generator.class_name(): generator}, + ) + return tuple(scored[: self.lattice_width]) + + def _rests(self) -> Tuple[StemRest, ...]: + """The channels no stem took, each holding its null instruction over a silent frame.""" + if not self.free_channels: + return () + + silent = self.fragment * 0.0 + return tuple( + StemRest( + channel_name=channel_name, + column=(self._resting_candidate(channel_name, silent),), + ) + for channel_name in self.free_channels + ) + + def _resting_candidate(self, channel_name: ChannelName, silent: Fragment) -> ScoredCandidate: + instruction = self.channels[channel_name].get_instruction_type().null_instruction() + return ScoredCandidate( + instruction=instruction, + cost=RESTING_FRAME_COST, + approximation=silent, + ) + def _remaining_channels( self, stem_id: int, diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py new file mode 100644 index 000000000..45c460bed --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py @@ -0,0 +1,48 @@ +from typing import Dict, List, Sequence + +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice, Lattices +from sampletones_core.reconstructions.reconstructor.matching import Column +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment + + +class TrackAssignment: + """ + Gathers a recording's frame assignments into what the rest of the reconstruction reads. + + Two records grow side by side, both in frame order: the lattice each channel offers the + decoder, and the stem that owns each of the channel's frames. Keeping them parallel is + what lets a stem selection name the frames it sounds in. + """ + + def __init__(self, channel_names: Sequence[ChannelName]) -> None: + self.lattices: Lattices = {channel_name: [] for channel_name in channel_names} + self.stem_ids: Dict[ChannelName, List[int]] = {channel_name: [] for channel_name in channel_names} + + def add(self, frame_assignment: StemFrameAssignment) -> None: + """Appends one frame: every channel in play gains its column and the stem that took it.""" + for choice in frame_assignment.choices: + self._append(choice.channel_name, choice.stem_id, choice.column) + + for rest in frame_assignment.rests: + self._append(rest.channel_name, RESTING_STEM_ID, rest.column) + + def drop(self, channel_name: ChannelName) -> None: + """Releases a channel's records, leaving it out of the reconstruction being assembled.""" + del self.lattices[channel_name] + del self.stem_ids[channel_name] + + @property + def resting_channels(self) -> List[ChannelName]: + """The channels that rested through every frame, which sound nothing anywhere.""" + return [ + channel_name + for channel_name, stem_ids in self.stem_ids.items() + if all(stem_id == RESTING_STEM_ID for stem_id in stem_ids) + ] + + def _append(self, channel_name: ChannelName, stem_id: int, column: Column) -> None: + lattice: ChannelLattice = self.lattices[channel_name] + lattice.append(column) + self.stem_ids[channel_name].append(stem_id) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py index a27133597..ab53af679 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py @@ -3,11 +3,20 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions.reconstructor.matching import Column class StemChoice(NamedTuple): + """One stem's claim on one channel for one frame. + + The instruction, approximation and cost are what won the channel and what the residual + the next pick sees was formed from. The column holds the alternatives the decoder chooses + among for the same channel and frame, the winner among them. + """ + stem_id: int channel_name: ChannelName instruction: InstructionUnion approximation: Fragment cost: float + column: Column diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py index 78fc6dfb3..64993a80c 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py @@ -2,6 +2,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest class StemFrameAssignment(NamedTuple): @@ -12,8 +13,12 @@ class StemFrameAssignment(NamedTuple): """ choices: Tuple[StemChoice, ...] - resting: Tuple[ChannelName, ...] + rests: Tuple[StemRest, ...] @property def by_channel(self) -> Dict[ChannelName, StemChoice]: return {choice.channel_name: choice for choice in self.choices} + + @property + def resting(self) -> Tuple[ChannelName, ...]: + return tuple(rest.channel_name for rest in self.rests) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py new file mode 100644 index 000000000..c42559492 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py @@ -0,0 +1,15 @@ +from typing import NamedTuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.matching import Column + + +class StemRest(NamedTuple): + """One channel's frame that every stem left free: the null instruction it holds, alone. + + A rest reaches the decoder as a column like any other, one state wide, so the channel keeps + its place in the frame and the silence it sounds is the frame's own answer. + """ + + channel_name: ChannelName + column: Column diff --git a/src/sampletones_core/reconstructions/reconstructor/worker.py b/src/sampletones_core/reconstructions/reconstructor/worker.py index 9971c32c9..39fcb6425 100644 --- a/src/sampletones_core/reconstructions/reconstructor/worker.py +++ b/src/sampletones_core/reconstructions/reconstructor/worker.py @@ -1,25 +1,31 @@ from dataclasses import dataclass, field -from typing import Dict, List +from typing import Dict from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, GeneratorClassName -from sampletones_core.fft import Fragment, FragmentedAudio, Window +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Window from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor -from sampletones_core.generators import ( - GeneratorUnion, - get_remaining_generator_classes, -) +from sampletones_core.generators import GeneratorUnion from sampletones_core.library import InstructionLibraryData -from .approximation import ApproximationData from .candidates import CandidateProvider +from .decoder import DECODERS, Decoder +from .matching import FrameMatcher from .phase import PHASE_ALIGNERS, PhaseAligner from .scorer import Scorer -from .selector import SELECTORS, Selector @dataclass(frozen=True) class ReconstructorWorker: + """ + Assembles the machinery one recording is matched and decoded with. + + Everything a reconstruction run needs beyond its target sits here, built once for the + signal it will work on: the scorer and candidate provider the matching draws from, the + phase aligner and feature extractor it measures with, the `FrameMatcher` the stems + assignment scores through, and the `Decoder` the configuration names. + """ + config: Config window: Window channels: Dict[ChannelName, GeneratorUnion] @@ -30,7 +36,8 @@ class ReconstructorWorker: candidate_provider: CandidateProvider = field(init=False) phase_aligner: PhaseAligner = field(init=False) feature_extractor: FeatureExtractor = field(init=False) - selector: Selector = field(init=False) + matcher: FrameMatcher = field(init=False) + decoder: Decoder = field(init=False) def __post_init__(self) -> None: scorer = Scorer(self.config, self.window, self.signal_length) @@ -38,35 +45,17 @@ def __post_init__(self) -> None: phase_aligner_class = PHASE_ALIGNERS[self.config.generation.calculation.phase_aligner] phase_aligner = phase_aligner_class(self.config, self.window, self.library_data) feature_extractor = get_feature_extractor(self.config, self.window) - selector_class = SELECTORS[self.config.generation.decoder.selector] - selector = selector_class( + matcher = FrameMatcher( config=self.config, - window=self.window, - channels=self.channels, - scorer=scorer, candidate_provider=candidate_provider, + scorer=scorer, phase_aligner=phase_aligner, - feature_extractor=feature_extractor, ) + decoder_class = DECODERS[self.config.generation.decoder.selector] object.__setattr__(self, "scorer", scorer) object.__setattr__(self, "candidate_provider", candidate_provider) object.__setattr__(self, "phase_aligner", phase_aligner) object.__setattr__(self, "feature_extractor", feature_extractor) - object.__setattr__(self, "selector", selector) - - def __call__( - self, - fragmented_audio: FragmentedAudio, - fragment_ids: List[int], - ) -> Dict[int, Dict[ChannelName, ApproximationData]]: - return self.selector.select(fragmented_audio, fragment_ids) - - def reconstruct(self, fragment: Fragment) -> Dict[ChannelName, ApproximationData]: - return self.selector.reconstruct_fragment(fragment) - - def get_remaining_generator_classes( - self, - remaining_generators: Dict[ChannelName, GeneratorUnion], - ) -> Dict[GeneratorClassName, GeneratorUnion]: - return get_remaining_generator_classes(remaining_generators) + object.__setattr__(self, "matcher", matcher) + object.__setattr__(self, "decoder", decoder_class(self.config)) diff --git a/tests/integration/reconstruction/test_decoding.py b/tests/integration/reconstruction/test_decoding.py new file mode 100644 index 000000000..62a772a1c --- /dev/null +++ b/tests/integration/reconstruction/test_decoding.py @@ -0,0 +1,88 @@ +from pathlib import Path +from typing import Dict, Final, List + +import numpy as np + +from sampletones_core.audio import write_wave +from sampletones_core.configs import Config +from sampletones_core.configs.generation import GenerationConfig +from sampletones_core.constants.enums import ChannelName, SelectorName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from tests.integration.assets.reconstruction import build_mini_library + +_DURATION_SECONDS: Final[float] = 1.0 +_LOWER_TONE: Final[float] = 440.0 +_UPPER_TONE: Final[float] = 661.0 +_NOISE_LEVEL: Final[float] = 0.08 +_NOISE_SEED: Final[int] = 17 + + +def _flickering_path(tmp_path: Path, config: Config) -> Path: + """A two-tone target under light noise, so the cheapest candidate wavers frame to frame.""" + sample_rate = config.library.sample_rate + count = int(sample_rate * _DURATION_SECONDS) + time = np.arange(count) / sample_rate + audio = 0.5 * np.sin(2 * np.pi * _LOWER_TONE * time) + 0.25 * np.sin(2 * np.pi * _UPPER_TONE * time) + audio += np.random.default_rng(_NOISE_SEED).normal(0.0, _NOISE_LEVEL, count) + + path = tmp_path / "flicker.wav" + write_wave(path, sample_rate, audio) + return path + + +def _reconstruct(selector_name: SelectorName, audio_path: Path) -> Reconstruction: + config = Config(generation=GenerationConfig(decoder={"selector": selector_name})) + reconstruction = Reconstructor(config, library=build_mini_library(config))(audio_path) + assert reconstruction is not None + return reconstruction + + +def _changes(stream: List[InstructionUnion]) -> int: + return sum(1 for previous, current in zip(stream, stream[1:]) if previous != current) + + +def _change_counts(reconstruction: Reconstruction) -> Dict[ChannelName, int]: + return { + channel_name: _changes(reconstruction.instructions[channel_name]) + for channel_name in reconstruction.playing_channels + } + + +class TestConfiguredDecoderReachesTheConversion: + """The decoder named in the configuration is the one a conversion is decoded with.""" + + def test_the_two_decoders_answer_the_same_target_differently(self, tmp_path: Path) -> None: + audio_path = _flickering_path(tmp_path, Config()) + + greedy = _reconstruct(SelectorName.GREEDY, audio_path) + viterbi = _reconstruct(SelectorName.VITERBI, audio_path) + + assert set(greedy.playing_channels) == set(viterbi.playing_channels) + assert any( + greedy.instructions[channel_name] != viterbi.instructions[channel_name] + for channel_name in greedy.playing_channels + ) + + def test_continuity_decoding_holds_instructions_longer(self, tmp_path: Path) -> None: + """Weighing transitions is what buys steadiness, so the decoded streams change less often.""" + audio_path = _flickering_path(tmp_path, Config()) + + greedy = _reconstruct(SelectorName.GREEDY, audio_path) + viterbi = _reconstruct(SelectorName.VITERBI, audio_path) + + assert sum(_change_counts(viterbi).values()) < sum(_change_counts(greedy).values()) + + def test_a_decoder_answers_every_frame_of_every_channel(self, tmp_path: Path) -> None: + audio_path = _flickering_path(tmp_path, Config()) + + for selector_name in SelectorName: + reconstruction = _reconstruct(selector_name, audio_path) + frame_counts = { + channel_name: len(reconstruction.instructions[channel_name]) + for channel_name in reconstruction.playing_channels + } + assert len(set(frame_counts.values())) == 1 + assert set(reconstruction.stems_data.assignments_by_channel) == set(reconstruction.playing_channels) + for channel_name, stem_ids in reconstruction.stems_data.assignments_by_channel.items(): + assert len(stem_ids) == frame_counts[channel_name] diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/__init__.py similarity index 100% rename from tests/unit/sampletones_core/reconstructions/reconstructor/selector/__init__.py rename to tests/unit/sampletones_core/reconstructions/reconstructor/decoder/__init__.py diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py new file mode 100644 index 000000000..ae7f3aced --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py @@ -0,0 +1,42 @@ +from typing import Any, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice +from sampletones_core.reconstructions.reconstructor.decoder.greedy import GreedyDecoder +from sampletones_core.reconstructions.reconstructor.decoder.viterbi import ViterbiDecoder +from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate + +STEADY = PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0) +JUMPED = PulseInstruction(on=True, pitch=72, volume=10, duty_cycle=0) + + +def state(instruction: PulseInstruction, cost: float) -> ScoredCandidate: + return ScoredCandidate(instruction=instruction, cost=cost, approximation=None) # type: ignore[arg-type] + + +def per_frame_best(frames: ChannelLattice) -> List[PulseInstruction]: + return [min(frame, key=lambda candidate: candidate.cost).instruction for frame in frames] + + +@pytest.fixture +def flickering_frames() -> ChannelLattice: + """Three frames whose per-frame best alternates, while one instruction stays cheap throughout.""" + return [ + (state(STEADY, 0.00), state(JUMPED, 0.05)), + (state(STEADY, 0.05), state(JUMPED, 0.00)), + (state(STEADY, 0.00), state(JUMPED, 0.05)), + ] + + +@pytest.fixture +def greedy_decoder(config: Config) -> GreedyDecoder: + return GreedyDecoder(config) + + +def viterbi_decoder(config: Config, **decoder_overrides: Any) -> ViterbiDecoder: + decoder = config.generation.decoder.model_copy(update=decoder_overrides) + updated_config = config.model_copy(update={"generation": config.generation.model_copy(update={"decoder": decoder})}) + return ViterbiDecoder(updated_config) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py new file mode 100644 index 000000000..3e7661d13 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py @@ -0,0 +1,72 @@ +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH +from sampletones_core.constants.enums import ChannelName, SelectorName +from sampletones_core.reconstructions.reconstructor.decoder import DECODERS +from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice, Lattices +from sampletones_core.reconstructions.reconstructor.decoder.greedy import GreedyDecoder + +from .conftest import JUMPED, STEADY, per_frame_best, state + + +class TestDecoderCatalog: + def test_every_selector_name_is_answered(self) -> None: + assert set(DECODERS) == set(SelectorName) + + @pytest.mark.parametrize("selector_name", list(SelectorName)) + def test_a_decoder_reads_at_least_one_candidate_per_frame( + self, + selector_name: SelectorName, + config: Config, + ) -> None: + assert DECODERS[selector_name](config).lattice_width >= SINGLE_STATE_LATTICE_WIDTH + + +class TestDecodedShape: + @staticmethod + def _lattices() -> Lattices: + return { + ChannelName.PULSE1: [(state(STEADY, 0.1),), (state(STEADY, 0.2),)], + ChannelName.TRIANGLE: [(state(STEADY, 0.3),), (state(STEADY, 0.4),)], + } + + @pytest.mark.parametrize("selector_name", list(SelectorName)) + def test_every_channel_answers_each_of_its_frames( + self, + selector_name: SelectorName, + config: Config, + ) -> None: + lattices = self._lattices() + + streams = DECODERS[selector_name](config).decode(lattices) + + assert set(streams) == set(lattices) + assert {name: len(stream) for name, stream in streams.items()} == { + name: len(frames) for name, frames in lattices.items() + } + + @pytest.mark.parametrize("selector_name", list(SelectorName)) + def test_a_channel_with_no_frames_answers_nothing( + self, + selector_name: SelectorName, + config: Config, + ) -> None: + assert DECODERS[selector_name](config).decode({ChannelName.PULSE1: []}) == {ChannelName.PULSE1: []} + + +class TestGreedyDecoder: + def test_reads_one_candidate_per_frame(self, greedy_decoder: GreedyDecoder) -> None: + assert greedy_decoder.lattice_width == SINGLE_STATE_LATTICE_WIDTH + + def test_plays_the_head_of_each_column(self, greedy_decoder: GreedyDecoder) -> None: + """A column arrives best first, so its head is what the frame's own cost chose.""" + frames: ChannelLattice = [ + (state(STEADY, 0.00), state(JUMPED, 0.05)), + (state(JUMPED, 0.00), state(STEADY, 0.05)), + ] + + streams = greedy_decoder.decode({ChannelName.PULSE1: frames}) + + assert [candidate.instruction for candidate in streams[ChannelName.PULSE1]] == per_frame_best(frames) + assert [candidate.instruction for candidate in streams[ChannelName.PULSE1]] == [STEADY, JUMPED] diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py new file mode 100644 index 000000000..aea17cd00 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py @@ -0,0 +1,67 @@ +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice + +from .conftest import STEADY, per_frame_best, viterbi_decoder + + +class TestViterbiContinuity: + def test_continuity_holds_a_steady_note_where_per_frame_choice_flickers( + self, + config: Config, + flickering_frames: ChannelLattice, + ) -> None: + decoder = viterbi_decoder(config, pitch_weight=1.0) + + streams = decoder.decode({ChannelName.PULSE1: flickering_frames}) + chosen = [candidate.instruction for candidate in streams[ChannelName.PULSE1]] + + assert len(set(per_frame_best(flickering_frames))) > 1 + assert len(set(chosen)) == 1 + + def test_zero_transition_weights_reduce_to_per_frame_choice( + self, + config: Config, + flickering_frames: ChannelLattice, + ) -> None: + decoder = viterbi_decoder( + config, + pitch_weight=0.0, + volume_weight=0.0, + timbre_weight=0.0, + on_off_weight=0.0, + ) + + streams = decoder.decode({ChannelName.PULSE1: flickering_frames}) + + assert [candidate.instruction for candidate in streams[ChannelName.PULSE1]] == per_frame_best(flickering_frames) + + +class TestViterbiLatticeWidth: + def test_reads_as_many_candidates_as_the_configured_shortlist(self, config: Config) -> None: + assert viterbi_decoder(config).lattice_width == config.generation.decoder.top_k + + +class TestViterbiTransitionCost: + def test_identical_instruction_has_no_cost(self, config: Config) -> None: + decoder = viterbi_decoder(config) + assert decoder._transition_cost(STEADY, STEADY) == 0.0 + + def test_larger_pitch_jump_costs_more(self, config: Config) -> None: + decoder = viterbi_decoder(config, pitch_weight=0.1) + near = PulseInstruction(on=True, pitch=61, volume=10, duty_cycle=0) + far = PulseInstruction(on=True, pitch=84, volume=10, duty_cycle=0) + assert decoder._transition_cost(STEADY, near) < decoder._transition_cost(STEADY, far) + + def test_toggling_on_off_costs_the_on_off_weight(self, config: Config) -> None: + decoder = viterbi_decoder(config, on_off_weight=0.25) + silence = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0) + assert decoder._transition_cost(STEADY, silence) == 0.25 + + def test_a_resting_frame_costs_the_on_off_weight_to_reach(self, config: Config) -> None: + """A rest is an off state, so a channel pays the same to fall silent as to start.""" + decoder = viterbi_decoder(config, on_off_weight=0.25) + resting = PulseInstruction.null_instruction() + assert decoder._transition_cost(STEADY, resting) == 0.25 + assert decoder._transition_cost(resting, resting) == 0.0 diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py b/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py deleted file mode 100644 index a6e624827..000000000 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, List - -from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import Window -from sampletones_core.generators import GeneratorUnion -from sampletones_core.instructions import PulseInstruction -from sampletones_core.reconstructions.reconstructor.selector.base import ScoredCandidate -from sampletones_core.reconstructions.reconstructor.selector.viterbi import ( - ViterbiSelector, -) -from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker - - -def _selector( - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - worker: ReconstructorWorker, - **decoder_overrides: Any, -) -> ViterbiSelector: - decoder = config.generation.decoder.model_copy(update=decoder_overrides) - updated_config = config.model_copy(update={"generation": config.generation.model_copy(update={"decoder": decoder})}) - return ViterbiSelector( - updated_config, - window, - channels, - worker.scorer, - worker.candidate_provider, - worker.phase_aligner, - worker.feature_extractor, - ) - - -def _state(instruction: PulseInstruction, cost: float) -> ScoredCandidate: - return ScoredCandidate(instruction=instruction, cost=cost, approximation=None) # type: ignore[arg-type] - - -def _per_frame_best(frames: List[List[ScoredCandidate]]) -> List[PulseInstruction]: - return [min(frame, key=lambda state: state.cost).instruction for frame in frames] - - -STEADY = PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0) -JUMPED = PulseInstruction(on=True, pitch=72, volume=10, duty_cycle=0) - - -def _flickering_frames() -> List[List[ScoredCandidate]]: - return [ - [_state(STEADY, 0.00), _state(JUMPED, 0.05)], - [_state(STEADY, 0.05), _state(JUMPED, 0.00)], - [_state(STEADY, 0.00), _state(JUMPED, 0.05)], - ] - - -class TestViterbiContinuity: - def test_continuity_holds_a_steady_note_where_per_frame_choice_flickers( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - worker: ReconstructorWorker, - ) -> None: - selector = _selector(config, window, channels, worker, pitch_weight=1.0) - frames = _flickering_frames() - - path = selector._decode(frames) - chosen = [frames[position][path[position]].instruction for position in range(len(frames))] - - assert len({id(instruction) for instruction in _per_frame_best(frames)}) > 1 - assert len(set(chosen)) == 1 - - def test_zero_transition_weights_reduce_to_per_frame_choice( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - worker: ReconstructorWorker, - ) -> None: - selector = _selector( - config, - window, - channels, - worker, - pitch_weight=0.0, - volume_weight=0.0, - timbre_weight=0.0, - on_off_weight=0.0, - ) - frames = _flickering_frames() - - path = selector._decode(frames) - chosen = [frames[position][path[position]].instruction for position in range(len(frames))] - - assert chosen == _per_frame_best(frames) - - -class TestViterbiTransitionCost: - def test_identical_instruction_has_no_cost( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - worker: ReconstructorWorker, - ) -> None: - selector = _selector(config, window, channels, worker) - assert selector._transition_cost(STEADY, STEADY) == 0.0 - - def test_larger_pitch_jump_costs_more( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - worker: ReconstructorWorker, - ) -> None: - selector = _selector(config, window, channels, worker, pitch_weight=0.1) - near = PulseInstruction(on=True, pitch=61, volume=10, duty_cycle=0) - far = PulseInstruction(on=True, pitch=84, volume=10, duty_cycle=0) - assert selector._transition_cost(STEADY, near) < selector._transition_cost(STEADY, far) - - def test_toggling_on_off_costs_the_on_off_weight( - self, - config: Config, - window: Window, - channels: Dict[ChannelName, GeneratorUnion], - worker: ReconstructorWorker, - ) -> None: - selector = _selector(config, window, channels, worker, on_off_weight=0.25) - silence = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0) - assert selector._transition_cost(STEADY, silence) == 0.25 - - -class TestViterbiSelectIntegration: - def test_select_is_deterministic( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - ) -> None: - fragment_ids = fragmented_audio.fragments_ids - first = worker(fragmented_audio, fragment_ids) - second = worker(fragmented_audio, fragment_ids) - for fragment_id in fragment_ids: - for channel_name in first[fragment_id]: - assert first[fragment_id][channel_name].instruction == second[fragment_id][channel_name].instruction diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py index 27f953ab1..a3b784f66 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py @@ -3,21 +3,23 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH from sampletones_core.constants.enums import ChannelName -from sampletones_core.generators import GeneratorUnion, get_generators_by_channels -from sampletones_core.reconstructions.reconstructor.selector.greedy import GreedySelector -from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import ( + GeneratorUnion, + get_generator_by_instruction, + get_generators_by_channels, + get_remaining_generator_classes, +) +from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher, ScoredCandidate from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker @pytest.fixture(scope="module") def matcher(worker: ReconstructorWorker) -> FrameMatcher: - return FrameMatcher( - config=worker.config, - candidate_provider=worker.candidate_provider, - scorer=worker.scorer, - phase_aligner=worker.phase_aligner, - ) + return worker.matcher @pytest.fixture(scope="module") @@ -25,29 +27,34 @@ def all_channels(config: Config) -> Dict[ChannelName, GeneratorUnion]: return get_generators_by_channels(config, ChannelName.items()) -@pytest.fixture(scope="module") -def greedy_selector(worker: ReconstructorWorker) -> GreedySelector: - return _build_greedy_selector(worker, worker.channels) +def greedy_baseline( + fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, +) -> Dict[ChannelName, ScoredCandidate]: + """The classic per-frame reconstruction, restated here as the assignment's reference. + + Takes the cheapest candidate across every channel still free, subtracts it from the + residual, and repeats until each channel is answered. A one-stem setup at full cap runs + exactly this order, which is what makes the two comparable frame by frame. + """ + answers: Dict[ChannelName, ScoredCandidate] = {} + remaining_channels = dict(channels) + residual = fragment + while remaining_channels: + remaining_generator_classes = get_remaining_generator_classes(remaining_channels) + best = matcher.score_candidates(residual, remaining_generator_classes)[0] + generator = get_generator_by_instruction(best.instruction, remaining_generator_classes) + channel_name = ChannelName(generator.name) + answers[channel_name] = best + residual = extractor.subtract(residual, best.approximation) + del remaining_channels[channel_name] + + return answers @pytest.fixture(scope="module") -def all_channels_selector( - worker: ReconstructorWorker, - all_channels: Dict[ChannelName, GeneratorUnion], -) -> GreedySelector: - return _build_greedy_selector(worker, all_channels) - - -def _build_greedy_selector( - worker: ReconstructorWorker, - channels: Dict[ChannelName, GeneratorUnion], -) -> GreedySelector: - return GreedySelector( - config=worker.config, - window=worker.window, - channels=channels, - scorer=worker.scorer, - candidate_provider=worker.candidate_provider, - phase_aligner=worker.phase_aligner, - feature_extractor=worker.feature_extractor, - ) +def lattice_width() -> int: + """The width a greedy decoder reads, which is what the equivalence baseline assumes.""" + return SINGLE_STATE_LATTICE_WIDTH diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py index 5524d770c..96a301858 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -4,21 +4,21 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.fft import Fragment, Window from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion from sampletones_core.library import InstructionLibraryData -from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData -from sampletones_core.reconstructions.reconstructor.selector.greedy import GreedySelector -from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher, ScoredCandidate from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment -from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker + +from .conftest import greedy_baseline RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197) @@ -43,7 +43,6 @@ def test_matches_the_greedy_baseline_exactly( channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, - greedy_selector: GreedySelector, ) -> None: stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels)) @@ -53,8 +52,9 @@ def test_matches_the_greedy_baseline_exactly( channels, matcher, extractor, + SINGLE_STATE_LATTICE_WIDTH, ) - baseline = greedy_selector.reconstruct_fragment(synthetic_fragment) + baseline = greedy_baseline(synthetic_fragment, channels, matcher, extractor) assert len(assignment.choices) == len(channels) assert len(baseline) == len(channels) @@ -66,7 +66,6 @@ def test_matches_the_baseline_with_all_four_channels( all_channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, - all_channels_selector: GreedySelector, ) -> None: stems_config = _config({0: all_channels}, [[0]], HierarchyMode.STRICT, len(all_channels)) @@ -76,18 +75,60 @@ def test_matches_the_baseline_with_all_four_channels( all_channels, matcher, extractor, + SINGLE_STATE_LATTICE_WIDTH, ) - baseline = all_channels_selector.reconstruct_fragment(synthetic_fragment) + baseline = greedy_baseline(synthetic_fragment, all_channels, matcher, extractor) assert len(assignment.choices) == len(all_channels) assert len(baseline) == len(all_channels) _assert_same_picks(assignment, baseline) +class TestLatticeWidthLeavesOwnership: + """A wider lattice grows what the decoder may choose from, and the picks stay put.""" + + def test_ownership_and_picks_hold_across_widths( + self, + synthetic_fragment: Fragment, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems_config = _config( + {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.PULSE2, ChannelName.NOISE]}, + [[0], [1]], + HierarchyMode.STRICT, + 2, + ) + + narrow = assign_frame( + synthetic_fragment, + stems_config, + all_channels, + matcher, + extractor, + SINGLE_STATE_LATTICE_WIDTH, + ) + wide = assign_frame( + synthetic_fragment, + stems_config, + all_channels, + matcher, + extractor, + matcher.top_k, + ) + + assert _choice_keys(narrow.choices) == _choice_keys(wide.choices) + assert narrow.resting == wide.resting + for narrow_choice, wide_choice in zip(narrow.choices, wide.choices): + assert narrow_choice.instruction == wide_choice.instruction + _assert_same_fragment(narrow_choice.approximation, wide_choice.approximation) + assert len(wide_choice.column) >= len(narrow_choice.column) + + class TestStrictDisjointStems: def test_matches_sequential_per_subset_baselines( self, - worker: ReconstructorWorker, synthetic_fragment: Fragment, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, @@ -99,11 +140,11 @@ def test_matches_sequential_per_subset_baselines( } subset_noise = {ChannelName.NOISE: channels[ChannelName.NOISE]} - baseline_first = _restricted_selector(worker, subset_pulse_triangle).reconstruct_fragment(synthetic_fragment) + baseline_first = greedy_baseline(synthetic_fragment, subset_pulse_triangle, matcher, extractor) residual = synthetic_fragment - for approximation_data in baseline_first.values(): - residual = extractor.subtract(residual, approximation_data.approximation) - baseline_second = _restricted_selector(worker, subset_noise).reconstruct_fragment(residual) + for candidate in baseline_first.values(): + residual = extractor.subtract(residual, candidate.approximation) + baseline_second = greedy_baseline(residual, subset_noise, matcher, extractor) expected = dict(baseline_first) expected.update(baseline_second) @@ -121,6 +162,7 @@ def test_matches_sequential_per_subset_baselines( channels, matcher, extractor, + SINGLE_STATE_LATTICE_WIDTH, ) assert len(assignment.choices) == len(channels) @@ -149,6 +191,7 @@ def test_invariants_and_determinism( all_channels, matcher, extractor, + SINGLE_STATE_LATTICE_WIDTH, ) repeat = assign_frame( fragment, @@ -156,6 +199,7 @@ def test_invariants_and_determinism( all_channels, matcher, extractor, + SINGLE_STATE_LATTICE_WIDTH, ) assert _choice_keys(assignment.choices) == _choice_keys(repeat.choices) @@ -178,15 +222,15 @@ def test_invariants_and_determinism( def _assert_same_picks( assignment: StemFrameAssignment, - baseline: Dict[ChannelName, ApproximationData], + baseline: Dict[ChannelName, ScoredCandidate], ) -> None: assert [choice.channel_name for choice in assignment.choices] == list(baseline.keys()) assert set(assignment.by_channel) == set(baseline) - for channel_name, approximation_data in baseline.items(): + for channel_name, candidate in baseline.items(): choice = assignment.by_channel[channel_name] - assert choice.instruction == approximation_data.instruction - _assert_same_fragment(choice.approximation, approximation_data.approximation) + assert choice.instruction == candidate.instruction + _assert_same_fragment(choice.approximation, candidate.approximation) def _assert_same_fragment(left: Fragment, right: Fragment) -> None: @@ -219,21 +263,6 @@ def _choice_keys(choices: Tuple[StemChoice, ...]) -> Tuple[Tuple[int, ChannelNam return tuple((choice.stem_id, choice.channel_name) for choice in choices) -def _restricted_selector( - worker: ReconstructorWorker, - channels: Dict[ChannelName, GeneratorUnion], -) -> GreedySelector: - return GreedySelector( - config=worker.config, - window=worker.window, - channels=channels, - scorer=worker.scorer, - candidate_provider=worker.candidate_provider, - phase_aligner=worker.phase_aligner, - feature_extractor=worker.feature_extractor, - ) - - def _random_setup( rng: np.random.Generator, channel_names: Tuple[ChannelName, ...], diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py index cc8c89da3..0d44754ed 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -1,12 +1,14 @@ from typing import Dict, List, Sequence, Tuple +import numpy as np import pytest +from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion -from sampletones_core.reconstructions.reconstructor.selector.matching import FrameMatcher +from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -36,6 +38,7 @@ def _assign( channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, + lattice_width: int = SINGLE_STATE_LATTICE_WIDTH, ) -> StemFrameAssignment: return assign_frame( fragment, @@ -43,6 +46,7 @@ def _assign( channels, matcher, extractor, + lattice_width, ) @@ -106,6 +110,81 @@ def test_a_channel_no_stem_may_occupy_stays_out_of_the_frame( assert assignment.resting == () +class TestColumns: + """Every channel leaves the frame with the alternatives the decoder reads.""" + + def test_a_narrow_lattice_answers_each_pick_with_itself( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor) + + for choice in assignment.choices: + assert len(choice.column) == SINGLE_STATE_LATTICE_WIDTH + assert choice.column[0].instruction == choice.instruction + + def test_a_wide_lattice_holds_the_pick_among_its_alternatives( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + """A wider column reaches the winning channel's own candidates, the pick among them.""" + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + width = matcher.top_k + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor, width) + + for choice in assignment.choices: + instructions = [candidate.instruction for candidate in choice.column] + assert 0 < len(choice.column) <= width + assert choice.instruction in instructions + assert len(set(instructions)) == len(instructions) + + def test_a_wide_lattice_offers_one_channel_its_own_candidates( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor, matcher.top_k) + + column = assignment.by_channel[ChannelName.PULSE1].column + pulse_class = channels[ChannelName.PULSE1].class_name() + expected = matcher.score_candidates(synthetic_fragment, {pulse_class: channels[ChannelName.PULSE1]}) + assert [candidate.instruction for candidate in column] == [ + candidate.instruction for candidate in expected[: matcher.top_k] + ] + + def test_a_resting_channel_holds_its_null_instruction_alone( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + """A rest is a column of one, so a channel no stem took still answers its frame.""" + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor, matcher.top_k) + + assert assignment.rests + for rest in assignment.rests: + assert len(rest.column) == SINGLE_STATE_LATTICE_WIDTH + candidate = rest.column[0] + assert candidate.instruction == channels[rest.channel_name].get_instruction_type().null_instruction() + assert not np.any(np.asarray(candidate.approximation.audio)) + + class TestChannelCap: def test_strict_mode_respects_the_cap( self, diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py index dcc69cca5..018434847 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py @@ -3,11 +3,20 @@ import numpy as np from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_FRAME_COST, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.fft import Fragment -from sampletones_core.instructions import PulseInstruction, TriangleInstruction +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate +from sampletones_core.reconstructions.reconstructor.stems.assignment.track import TrackAssignment from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest from sampletones_core.structures.histogram import Histogram FRAME_COST: Final[float] = 0.5 @@ -26,20 +35,33 @@ def _fragment(config: Config) -> Fragment: def _choices(config: Config) -> Tuple[StemChoice, StemChoice]: fragment = _fragment(config) return ( - StemChoice( - stem_id=0, - channel_name=ChannelName.PULSE1, - instruction=PulseInstruction.default_instruction(), - approximation=fragment, - cost=FRAME_COST, - ), - StemChoice( - stem_id=1, - channel_name=ChannelName.TRIANGLE, - instruction=TriangleInstruction.default_instruction(), - approximation=fragment, - cost=FRAME_COST, - ), + _choice(0, ChannelName.PULSE1, PulseInstruction.default_instruction(), fragment), + _choice(1, ChannelName.TRIANGLE, TriangleInstruction.default_instruction(), fragment), + ) + + +def _choice( + stem_id: int, + channel_name: ChannelName, + instruction: InstructionUnion, + fragment: Fragment, +) -> StemChoice: + return StemChoice( + stem_id=stem_id, + channel_name=channel_name, + instruction=instruction, + approximation=fragment, + cost=FRAME_COST, + column=(ScoredCandidate(instruction=instruction, cost=FRAME_COST, approximation=fragment),), + ) + + +def _rest(config: Config) -> StemRest: + fragment = _fragment(config) + instruction = NoiseInstruction.null_instruction() + return StemRest( + channel_name=ChannelName.NOISE, + column=(ScoredCandidate(instruction=instruction, cost=RESTING_FRAME_COST, approximation=fragment),), ) @@ -47,7 +69,7 @@ class TestStemFrameAssignment: def test_by_channel_names_the_stem_holding_each_channel(self) -> None: first, second = _choices(Config()) - assignment = StemFrameAssignment(choices=(first, second), resting=()) + assignment = StemFrameAssignment(choices=(first, second), rests=()) assert assignment.by_channel == { ChannelName.PULSE1: first, @@ -55,13 +77,69 @@ def test_by_channel_names_the_stem_holding_each_channel(self) -> None: } def test_picked_and_resting_channels_stay_apart(self) -> None: - first, _ = _choices(Config()) + config = Config() + first, _ = _choices(config) - assignment = StemFrameAssignment(choices=(first,), resting=(ChannelName.NOISE,)) + assignment = StemFrameAssignment(choices=(first,), rests=(_rest(config),)) + assert assignment.resting == (ChannelName.NOISE,) assert set(assignment.by_channel).isdisjoint(assignment.resting) +class TestTrackAssignment: + def test_a_frame_reaches_every_channel_it_answers(self) -> None: + config = Config() + first, second = _choices(config) + rest = _rest(config) + + track = TrackAssignment([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]) + track.add(StemFrameAssignment(choices=(first, second), rests=(rest,))) + + assert track.stem_ids == { + ChannelName.PULSE1: [first.stem_id], + ChannelName.TRIANGLE: [second.stem_id], + ChannelName.NOISE: [RESTING_STEM_ID], + } + assert track.lattices[ChannelName.PULSE1] == [first.column] + assert track.lattices[ChannelName.NOISE] == [rest.column] + + def test_a_channel_resting_throughout_is_named_resting(self) -> None: + config = Config() + first, _ = _choices(config) + rest = _rest(config) + + track = TrackAssignment([ChannelName.PULSE1, ChannelName.NOISE]) + for _ in range(3): + track.add(StemFrameAssignment(choices=(first,), rests=(rest,))) + + assert track.resting_channels == [ChannelName.NOISE] + + def test_a_channel_that_sounds_once_keeps_its_place(self) -> None: + config = Config() + first, _ = _choices(config) + rest = _rest(config) + noise_choice = _choice(0, ChannelName.NOISE, NoiseInstruction.default_instruction(), _fragment(config)) + + track = TrackAssignment([ChannelName.PULSE1, ChannelName.NOISE]) + track.add(StemFrameAssignment(choices=(first,), rests=(rest,))) + track.add(StemFrameAssignment(choices=(first, noise_choice), rests=())) + + assert track.resting_channels == [] + assert track.stem_ids[ChannelName.NOISE] == [RESTING_STEM_ID, 0] + + def test_dropping_a_channel_releases_both_records(self) -> None: + config = Config() + first, _ = _choices(config) + rest = _rest(config) + + track = TrackAssignment([ChannelName.PULSE1, ChannelName.NOISE]) + track.add(StemFrameAssignment(choices=(first,), rests=(rest,))) + track.drop(ChannelName.NOISE) + + assert set(track.lattices) == {ChannelName.PULSE1} + assert set(track.stem_ids) == {ChannelName.PULSE1} + + class TestHierarchyMode: def test_values(self) -> None: assert tuple(HierarchyMode) == (HierarchyMode.ROUND_ROBIN, HierarchyMode.STRICT) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py new file mode 100644 index 000000000..e12e83bde --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.fft import Fragment, Window +from sampletones_core.generators import ( + get_generator_by_instruction, + get_remaining_generator_classes, +) +from sampletones_core.instructions import InstructionUnion +from sampletones_core.library import InstructionLibraryData +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker + + +class TestTwoStageScoring: + def test_shortlist_is_ranked_by_aligned_cost_best_first( + self, + worker: ReconstructorWorker, + synthetic_fragment: Fragment, + ) -> None: + remaining_generator_classes = get_remaining_generator_classes(dict(worker.channels)) + scored = worker.matcher.score_candidates(synthetic_fragment, remaining_generator_classes) + + assert 0 < len(scored) <= worker.matcher.top_k + costs = [candidate.cost for candidate in scored] + assert costs == sorted(costs) + + def test_phase_shifted_target_selects_its_source_instruction_at_near_zero_cost( + self, + worker: ReconstructorWorker, + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + config: Config, + window: Window, + ) -> None: + """ + A target that is a phase-shifted rendering of a library instruction wins with + a near-zero cost: the spectral shortlist is phase-independent, and the + temporal term is evaluated on the candidate aligned to the target, so the + phase accident carries no penalty. + """ + instruction = audible_instruction + library_fragment = library_data[instruction] + shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window) + + remaining_generator_classes = get_remaining_generator_classes(dict(worker.channels)) + scored = worker.matcher.score_candidates(shifted_target, remaining_generator_classes) + + assert scored[0].instruction == instruction + assert scored[0].cost == pytest.approx(0.0, abs=1e-3) + + +class TestClassRestrictedShortlist: + def test_one_class_keeps_the_candidate_a_wider_scoring_picked( + self, + worker: ReconstructorWorker, + synthetic_fragment: Fragment, + ) -> None: + """Scoring one generator class alone reaches the winner the wider scoring picked. + + The shortlist is drawn by spectral rank, so a candidate that outranked every other + class's candidates outranks its own class's rejects too. That is what lets a frame's + ownership be settled across classes while the column the decoder reads holds the + winning channel's own alternatives. + """ + wide_classes = get_remaining_generator_classes(dict(worker.channels)) + winner = worker.matcher.score_candidates(synthetic_fragment, wide_classes)[0] + generator = get_generator_by_instruction(winner.instruction, wide_classes) + + column = worker.matcher.score_candidates( + synthetic_fragment, + {generator.class_name(): generator}, + ) + + assert winner.instruction in [candidate.instruction for candidate in column] diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index 54591c5fa..3c61c6e16 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -7,14 +7,10 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.fft import Fragment, FragmentedAudio, Window +from sampletones_core.fft import Fragment, Window from sampletones_core.generators import MIXER_LEVELS from sampletones_core.library import InstructionLibraryData -from sampletones_core.reconstructions.reconstructor.approximation import ( - ApproximationData, -) from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor -from sampletones_core.reconstructions.reconstructor.state import ReconstructionState from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoLibraryDataError @@ -173,142 +169,83 @@ def test_reset_clears_generator_states( assert all(gen.previous_instruction is None for gen in reconstructor.channels.values()) -class TestReconstructorUpdateState: - def _setup( - self, - config: Config, - library_data: InstructionLibraryData, - synthetic_fragment: Fragment, - final_regeneration: bool, - ) -> tuple: - updated_config = config.model_copy( - update={ - "generation": config.generation.model_copy( - update={ - "final_regeneration": final_regeneration, - } - ) - } - ) - reconstructor = _make_reconstructor(updated_config, library_data) - channel_name = next(iter(reconstructor.channels)) - instruction = next( - instrument - for instrument, frag in library_data.data.items() - if frag.generator_class == reconstructor.channels[channel_name].class_name() and instrument.on - ) - approximation_data = ApproximationData( - channel_name=channel_name, - approximation=synthetic_fragment, - instruction=instruction, - ) - reconstructor.state = ReconstructionState.create(list(reconstructor.channels.keys())) - return reconstructor, channel_name, approximation_data - - def test_without_final_regeneration_stores_precomputed_audio_scaled_by_drive( +class TestReconstructorCall: + def test_non_path_argument_raises_type_error( self, config: Config, library_data: InstructionLibraryData, - synthetic_fragment: Fragment, ) -> None: - reconstructor, channel_name, approximation_data = self._setup( - config, - library_data, - synthetic_fragment, - final_regeneration=False, - ) - reconstructor.update_state(approximation_data) - expected = np.asarray(synthetic_fragment.audio) * reconstructor.config.generation.drive - np.testing.assert_array_almost_equal( - reconstructor.state.approximations[channel_name][0], - expected, - ) + reconstructor = _make_reconstructor(config, library_data) + with pytest.raises(TypeError): + reconstructor(42) # type: ignore[arg-type] - def test_without_final_regeneration_does_not_run_generator( + def test_returns_reconstruction_for_valid_audio_path( self, config: Config, library_data: InstructionLibraryData, synthetic_fragment: Fragment, + tmp_path: Path, ) -> None: - reconstructor, channel_name, approximation_data = self._setup( - config, - library_data, - synthetic_fragment, - final_regeneration=False, + from sampletones_core.audio import write_wave + from sampletones_core.reconstructions.reconstruction.reconstruction import ( + Reconstruction, ) - reconstructor.update_state(approximation_data) - assert reconstructor.channels[channel_name].previous_instruction is None - def test_with_final_regeneration_reruns_generator( - self, - config: Config, - library_data: InstructionLibraryData, - synthetic_fragment: Fragment, - ) -> None: - reconstructor, channel_name, approximation_data = self._setup( - config, - library_data, - synthetic_fragment, - final_regeneration=True, - ) - reconstructor.update_state(approximation_data) - assert reconstructor.channels[channel_name].previous_instruction is approximation_data.instruction + audio_path = tmp_path / "test.wav" + audio = np.tile(synthetic_fragment.audio, 3).astype(np.float32) + write_wave(audio_path, config.library.sample_rate, audio) + reconstructor = _make_reconstructor(config, library_data) + result = reconstructor(audio_path) + assert isinstance(result, Reconstruction) -class TestReconstructorReconstruct: - def test_state_is_populated_for_each_fragment( +class TestReconstructorFinalRegeneration: + """What a frame records: the instruction rendered afresh, or the audio it was matched on.""" + + def _tone_path(self, tmp_path: Path, config: Config, synthetic_fragment: Fragment) -> Path: + from sampletones_core.audio import write_wave + + audio_path = tmp_path / "tone.wav" + write_wave(audio_path, config.library.sample_rate, np.tile(synthetic_fragment.audio, 3).astype(np.float32)) + return audio_path + + def _reconstructor( self, config: Config, library_data: InstructionLibraryData, - fragmented_audio: FragmentedAudio, - ) -> None: - reconstructor = _make_reconstructor(config, library_data) - reconstructor.state = ReconstructionState.create(list(reconstructor.channels.keys())) - reconstructor.reconstruct(fragmented_audio) - fragment_count = len(fragmented_audio.fragments_ids) - for channel_name in reconstructor.channels: - assert len(reconstructor.state.instructions[channel_name]) == fragment_count - assert len(reconstructor.state.approximations[channel_name]) == fragment_count - - def test_approximations_have_correct_frame_length( + final_regeneration: bool, + ) -> Reconstructor: + updated_config = config.model_copy( + update={"generation": config.generation.model_copy(update={"final_regeneration": final_regeneration})} + ) + return _make_reconstructor(updated_config, library_data) + + def test_final_regeneration_reruns_every_channel_generator( self, config: Config, library_data: InstructionLibraryData, - fragmented_audio: FragmentedAudio, + synthetic_fragment: Fragment, + tmp_path: Path, ) -> None: - reconstructor = _make_reconstructor(config, library_data) - reconstructor.state = ReconstructionState.create(list(reconstructor.channels.keys())) - reconstructor.reconstruct(fragmented_audio) - for channel_name in reconstructor.channels: - for approximation in reconstructor.state.approximations[channel_name]: - assert len(approximation) == config.library.frame_length + reconstructor = self._reconstructor(config, library_data, final_regeneration=True) + reconstruction = reconstructor(self._tone_path(tmp_path, config, synthetic_fragment)) -class TestReconstructorCall: - def test_non_path_argument_raises_type_error( - self, - config: Config, - library_data: InstructionLibraryData, - ) -> None: - reconstructor = _make_reconstructor(config, library_data) - with pytest.raises(TypeError): - reconstructor(42) # type: ignore[arg-type] + assert reconstruction is not None + for channel_name in reconstruction.playing_channels: + generator = reconstructor.channels[channel_name] + assert generator.previous_instruction is reconstruction.instructions[channel_name][-1] - def test_returns_reconstruction_for_valid_audio_path( + def test_without_final_regeneration_the_matched_audio_stands( self, config: Config, library_data: InstructionLibraryData, synthetic_fragment: Fragment, tmp_path: Path, ) -> None: - from sampletones_core.audio import write_wave - from sampletones_core.reconstructions.reconstruction.reconstruction import ( - Reconstruction, - ) + reconstructor = self._reconstructor(config, library_data, final_regeneration=False) - audio_path = tmp_path / "test.wav" - audio = np.tile(synthetic_fragment.audio, 3).astype(np.float32) - write_wave(audio_path, config.library.sample_rate, audio) - reconstructor = _make_reconstructor(config, library_data) - result = reconstructor(audio_path) - assert isinstance(result, Reconstruction) + reconstruction = reconstructor(self._tone_path(tmp_path, config, synthetic_fragment)) + + assert reconstruction is not None + assert all(generator.previous_instruction is None for generator in reconstructor.channels.values()) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py index 5b6280777..b30eaea52 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py @@ -7,6 +7,7 @@ from sampletones_core.configs import Config from sampletones_core.fft import Fragment, Window +from sampletones_core.generators import get_remaining_generator_classes from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibraryData from sampletones_core.reconstructions.reconstructor.scorer import Scorer @@ -16,8 +17,7 @@ def _candidate_approximations( worker: ReconstructorWorker, ) -> Tuple[Tuple[InstructionUnion, ...], Fragment]: - remaining_channels = dict(worker.channels.items()) - remaining_generator_classes = worker.get_remaining_generator_classes(remaining_channels) + remaining_generator_classes = get_remaining_generator_classes(dict(worker.channels)) return worker.candidate_provider.candidates(remaining_generator_classes) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py deleted file mode 100644 index 5b5e2dd30..000000000 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -import pytest - -from sampletones_core.configs import Config -from sampletones_core.fft import Fragment, Window -from sampletones_core.instructions import InstructionUnion -from sampletones_core.library import InstructionLibraryData -from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker - - -class TestTwoStageScoring: - def test_shortlist_is_ranked_by_aligned_cost_best_first( - self, - worker: ReconstructorWorker, - synthetic_fragment: Fragment, - ) -> None: - remaining_channels = dict(worker.channels.items()) - remaining_generator_classes = worker.get_remaining_generator_classes(remaining_channels) - scored = worker.selector._score_candidates(synthetic_fragment, remaining_generator_classes) - - assert 0 < len(scored) <= worker.selector.top_k - costs = [candidate.cost for candidate in scored] - assert costs == sorted(costs) - - def test_phase_shifted_target_selects_its_source_instruction_at_near_zero_cost( - self, - worker: ReconstructorWorker, - library_data: InstructionLibraryData, - audible_instruction: InstructionUnion, - config: Config, - window: Window, - ) -> None: - """ - A target that is a phase-shifted rendering of a library instruction wins with - a near-zero cost: the spectral shortlist is phase-independent, and the - temporal term is evaluated on the candidate aligned to the target, so the - phase accident carries no penalty. - """ - instruction = audible_instruction - library_fragment = library_data[instruction] - shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window) - - remaining_channels = dict(worker.channels.items()) - remaining_generator_classes = worker.get_remaining_generator_classes(remaining_channels) - scored = worker.selector._score_candidates(shifted_target, remaining_generator_classes) - - assert scored[0].instruction == instruction - assert scored[0].cost == pytest.approx(0.0, abs=1e-3) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py index 29c9d47f6..8e0779aef 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py @@ -1,165 +1,54 @@ from __future__ import annotations -from typing import Any, Dict, List - -import numpy as np +from typing import Dict from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.fft import Fragment, Window +from sampletones_core.constants.enums import ChannelName, SelectorName +from sampletones_core.fft import Window from sampletones_core.generators import GeneratorUnion from sampletones_core.library import InstructionLibraryData -from sampletones_core.reconstructions.reconstructor.reconstructor import reconstruct +from sampletones_core.reconstructions.reconstructor.decoder import DECODERS from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker -class TestReconstructorWorkerHelpers: - def test_get_remaining_generator_classes_maps_by_class_name( - self, - worker: ReconstructorWorker, - channels: Dict[ChannelName, GeneratorUnion], - ) -> None: - remaining = dict(worker.channels.items()) - by_class = worker.get_remaining_generator_classes(remaining) - expected_class_names = {gen.class_name() for gen in channels.values()} - assert set(by_class.keys()) == expected_class_names - - -class TestReconstructorWorkerIntegration: - def test_call_returns_dict_keyed_by_fragment_id( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - ) -> None: - fragment_ids = fragmented_audio.fragments_ids - result = worker(fragmented_audio, fragment_ids) - assert set(result.keys()) == set(fragment_ids) - - def test_call_result_has_one_entry_per_generator( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - channels: Dict[ChannelName, GeneratorUnion], - ) -> None: - result = worker(fragmented_audio, [fragmented_audio.fragments_ids[0]]) - per_fragment = next(iter(result.values())) - assert set(per_fragment.keys()) == set(channels.keys()) - - def test_approximation_data_has_valid_generator_name( - self, - worker: ReconstructorWorker, - channels: Dict[ChannelName, GeneratorUnion], - synthetic_fragment: Fragment, - ) -> None: - result = worker.reconstruct(synthetic_fragment) - for channel_name in result: - assert channel_name in channels - - def test_combined_approximation_is_not_all_zeros( - self, - worker: ReconstructorWorker, - synthetic_fragment: Fragment, - ) -> None: - result = worker.reconstruct(synthetic_fragment) - combined = sum(np.asarray(approx_data.approximation.audio) for approx_data in result.values()) - assert not np.all(combined == 0.0) +def _worker_with_selector( + config: Config, + window: Window, + channels: Dict[ChannelName, GeneratorUnion], + library_data: InstructionLibraryData, + selector_name: SelectorName, +) -> ReconstructorWorker: + decoder = config.generation.decoder.model_copy(update={"selector": selector_name}) + updated_config = config.model_copy(update={"generation": config.generation.model_copy(update={"decoder": decoder})}) + return ReconstructorWorker( + config=updated_config, + window=window, + channels=channels, + library_data=library_data, + signal_length=config.library.frame_length, + ) - def test_reconstruction_reduces_residual( - self, - worker: ReconstructorWorker, - synthetic_fragment: Fragment, - ) -> None: - original_audio = np.asarray(synthetic_fragment.audio).copy() - result = worker.reconstruct(synthetic_fragment) - residual_audio = original_audio.copy() - for approximation_data in result.values(): - residual_audio = residual_audio - np.asarray(approximation_data.approximation.audio) - original_rmse = float(np.sqrt(np.mean(original_audio**2))) - residual_rmse = float(np.sqrt(np.mean(residual_audio**2))) - assert residual_rmse < original_rmse - def test_without_find_best_phase_produces_valid_approximation( +class TestWorkerDecoder: + def test_the_configured_selector_names_the_decoder( self, config: Config, window: Window, channels: Dict[ChannelName, GeneratorUnion], library_data: InstructionLibraryData, ) -> None: - updated_config = config.model_copy( - update={ - "generation": config.generation.model_copy( - update={ - "calculation": config.generation.calculation.model_copy( - update={ - "find_best_phase": False, - } - ) - } - ) - } - ) - active_instruction = next(instrument for instrument in library_data.keys() if instrument.on) - fragment = library_data[active_instruction].get_fragment(0, updated_config, window) - local_worker = ReconstructorWorker( - config=updated_config, - window=window, - channels=channels, - library_data=library_data, - signal_length=1 << 20, - ) - result = local_worker.reconstruct(fragment) - combined = sum(np.asarray(approximation_data.approximation.audio) for approximation_data in result.values()) - assert not np.all(combined == 0.0) + for selector_name in SelectorName: + worker = _worker_with_selector(config, window, channels, library_data, selector_name) + assert isinstance(worker.decoder, DECODERS[selector_name]) + def test_the_default_configuration_builds_the_decoder_it_names(self, worker: ReconstructorWorker) -> None: + assert isinstance(worker.decoder, DECODERS[worker.config.generation.decoder.selector]) -class TestModuleLevelReconstruct: - def _call( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - library_data: InstructionLibraryData, - fragment_ids: List[int], - ) -> Any: - return reconstruct( - fragments_ids=fragment_ids, - fragmented_audio=fragmented_audio, - config=worker.config, - window=worker.window, - channels=worker.channels, - library_data=library_data, - ) - def test_returns_dict_keyed_by_fragment_id( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - library_data: InstructionLibraryData, - ) -> None: - fragment_ids = [fragmented_audio.fragments_ids[0]] - result = self._call(worker, fragmented_audio, library_data, fragment_ids) - assert set(result.keys()) == set(fragment_ids) - - def test_covers_all_requested_fragment_ids( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - library_data: InstructionLibraryData, - ) -> None: - fragment_ids = fragmented_audio.fragments_ids - result = self._call(worker, fragmented_audio, library_data, fragment_ids) - assert set(result.keys()) == set(fragment_ids) - - def test_same_inputs_produce_same_instructions( - self, - worker: ReconstructorWorker, - fragmented_audio: Any, - library_data: InstructionLibraryData, - ) -> None: - fragment_ids = [fragmented_audio.fragments_ids[0]] - result_a = self._call(worker, fragmented_audio, library_data, fragment_ids) - result_b = self._call(worker, fragmented_audio, library_data, fragment_ids) - for channel_name in result_a[fragment_ids[0]]: - assert ( - result_a[fragment_ids[0]][channel_name].instruction - == result_b[fragment_ids[0]][channel_name].instruction - ) +class TestWorkerMatcher: + def test_the_matcher_scores_through_the_workers_own_machinery(self, worker: ReconstructorWorker) -> None: + """One scorer, provider and aligner serve both the matching and everything built on it.""" + assert worker.matcher.scorer is worker.scorer + assert worker.matcher.candidate_provider is worker.candidate_provider + assert worker.matcher.phase_aligner is worker.phase_aligner + assert worker.matcher.config is worker.config From cf399ae1d06553bddc5c86bbc89bff4fdee96e7b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 00:07:11 +0200 Subject: [PATCH 040/142] Cut: conversion at the job seam, uniformly over stems --- .../coordinators/tabs/main.py | 4 +- .../logic/main/converter.py | 63 +++++--- .../services/conversion.py | 17 +- .../services/result.py | 4 +- .../reconstructions/converter/__init__.py | 12 +- .../reconstructions/converter/conversion.py | 27 +++- .../reconstructions/converter/converter.py | 61 +++----- .../reconstructions/converter/job.py | 20 +++ .../reconstructions/converter/paths/utils.py | 20 +++ .../converter/plan/__init__.py | 9 ++ .../converter/plan/directory.py | 47 ++++++ .../reconstructions/converter/plan/group.py | 32 ++++ .../converter/plan/protocol.py | 16 ++ .../reconstructor/reconstructor.py | 6 +- .../scripts/reconstruction.py | 25 ++- .../reconstruction/test_conversion_jobs.py | 77 +++++++++ .../test_stems_reconstruction.py | 16 +- .../services/test_conversion.py | 76 +++++---- .../coordinators/tabs/test_main.py | 7 +- .../logic/main/test_converter.py | 89 +++++++++-- .../converter/plan/__init__.py | 0 .../converter/plan/test_plans.py | 138 ++++++++++++++++ .../converter/test_conversion.py | 51 +++--- .../converter/test_converter.py | 147 ++++++++---------- 24 files changed, 723 insertions(+), 241 deletions(-) create mode 100644 src/sampletones_core/reconstructions/converter/job.py create mode 100644 src/sampletones_core/reconstructions/converter/plan/__init__.py create mode 100644 src/sampletones_core/reconstructions/converter/plan/directory.py create mode 100644 src/sampletones_core/reconstructions/converter/plan/group.py create mode 100644 src/sampletones_core/reconstructions/converter/plan/protocol.py create mode 100644 tests/integration/reconstruction/test_conversion_jobs.py create mode 100644 tests/unit/sampletones_core/reconstructions/converter/plan/__init__.py create mode 100644 tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 6acc17483..e3ffa70b8 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -300,10 +300,10 @@ def _notify_converter_running(self) -> bool: def _on_conversion_success(self, success: ConversionSuccess) -> None: self._on_refresh_trees() - if success.is_file: + if success.is_single: message = self._language_manager["main.converter.message.load_file_prompt"] ok_label = self._language_manager["main.converter.label.load_button"] - path = success.output_path + path: Optional[Path] = success.written[0] else: message = self._language_manager["main.converter.message.load_directory_prompt"] ok_label = self._language_manager["main.converter.label.open_button"] diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index e945abdf7..b9f2212d5 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Optional, Protocol +from typing import Callable, Optional, Protocol, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -23,7 +23,13 @@ ) from sampletones_core.configs import Config from sampletones_core.parallelization import ETAEstimator, TaskProgress -from sampletones_core.reconstructions.converter import get_output_path +from sampletones_core.reconstructions.converter import ( + ConversionPlan, + DirectoryConversion, + GroupConversion, + get_output_path, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -33,13 +39,17 @@ @dataclass(frozen=True) class ConversionSuccess: - """The outcome a completed conversion hands to its listener. + """The outcome a completed conversion hands to its listener: the reconstructions it wrote. + + One written reconstruction is one the reader can open straight away; several are a batch, + which the reader reaches as a folder.""" - Carries what the follow-up load offer needs: whether a single file or a - directory was converted, and where its reconstruction was written.""" + written: Tuple[Path, ...] - is_file: bool - output_path: Optional[Path] + @property + def is_single(self) -> bool: + """One reconstruction was written, so it is the one a follow-up offer would load.""" + return len(self.written) == 1 class ConversionServiceProtocol(Protocol): @@ -52,7 +62,7 @@ class ConversionServiceProtocol(Protocol): def subscribe(self, handler: Callable[[ConversionResult], None]) -> None: ... - def start(self, config: Config, input_path: Path) -> None: ... + def start(self, config: Config, plan: ConversionPlan) -> None: ... def cancel(self) -> None: ... @@ -84,6 +94,7 @@ def __init__( self._phase: ConversionPhase = ConversionPhase.IDLE self._input_path: Optional[Path] = None self._output_path: Optional[Path] = None + self._written: Tuple[Path, ...] = () self._is_file: bool = True self._system_progress = SystemProgress() @@ -158,13 +169,13 @@ def close(self) -> None: self._service.cleanup() finally: self._system_progress.clear() + self._written = () self._phase = ConversionPhase.IDLE self._emit_view_model(self._msg_idle, 0.0) def handle_load_request(self) -> None: - if self._is_file: - if self._output_path: - self.call(self.on_load_file, self._output_path) + if len(self._written) == 1: + self.call(self.on_load_file, self._written[0]) else: self.call(self.on_load_directory) @@ -182,8 +193,8 @@ def _on_service_result(self, result: ConversionResult) -> None: self._handle_progress_result(progress) case ServiceIntermediate(data=progress): self._handle_library_progress(progress) - case ServiceSuccess(value=output_path): - self._on_conversion_complete(output_path) + case ServiceSuccess(value=written): + self._on_conversion_complete(written) case ServiceError(exception=exception): self._on_conversion_error(exception) case ServiceCancelled(): @@ -261,21 +272,25 @@ def _start_conversion(self) -> None: assert self._input_path is not None, "Input path is not set" config = self._config_manager.config.model_copy() self._system_progress.initialize() - self._service.start(config, self._input_path) + self._service.start(config, self._conversion_plan(config, self._input_path)) - def _on_conversion_complete(self, output_path: Path) -> None: - if output_path.exists(): - self._output_path = output_path + def _conversion_plan(self, config: Config, input_path: Path) -> ConversionPlan: + """What the request amounts to: one reconstruction from the selected file, or one per + audio file the selected directory holds.""" + stems = StemsConfig.single_entry(list(config.generation.channels)) + if self._is_file: + return GroupConversion(sources=(input_path,), stems=stems) + + return DirectoryConversion(directory=input_path, stems=stems) + + def _on_conversion_complete(self, written: Tuple[Path, ...]) -> None: + self._written = written + if len(written) == 1: + self._output_path = written[0] self._phase = ConversionPhase.COMPLETED self._emit_view_model(self._language_manager["main.converter.message.status_reconstruction_completed"], 1.0) - self.call( - self.on_success, - ConversionSuccess( - is_file=self._is_file, - output_path=self._output_path, - ), - ) + self.call(self.on_success, ConversionSuccess(written=written)) def _on_conversion_error(self, exception: Exception) -> None: self._system_progress.error() diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion.py index 5b07c9ed6..a72021b0e 100644 --- a/src/sampletones_application/services/conversion.py +++ b/src/sampletones_application/services/conversion.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from sampletones_application.services.base import ServiceBase from sampletones_application.services.result import ( @@ -13,7 +13,7 @@ ) from sampletones_core.configs import Config from sampletones_core.parallelization import ETAEstimator, TaskProgress, TaskStatus -from sampletones_core.reconstructions.converter import ReconstructionConverter +from sampletones_core.reconstructions.converter import ConversionPlan, ReconstructionConverter from sampletones_shared.logger import logger from sampletones_shared.utils.system.paths import to_path @@ -33,17 +33,12 @@ def __init__(self, priority: int = 0) -> None: self._converter: Optional[ReconstructionConverter] = None self._eta_estimator: Optional[ETAEstimator] = None - def start(self, config: Config, input_path: Path) -> None: + def start(self, config: Config, plan: ConversionPlan) -> None: if self._converter is not None and self._converter.is_running(): logger.warning("Conversion is already in progress") return - is_file = input_path.is_file() - self._converter = ReconstructionConverter( - config=config, - input_path=input_path, - is_file=is_file, - ) + self._converter = ReconstructionConverter(config=config, plan=plan) self._converter.set_callbacks( on_start=self._on_start, on_progress=self._on_progress, @@ -109,8 +104,8 @@ def _on_progress( case _: pass - def _on_completed(self, output_path: Path) -> None: - self._emit(ServiceSuccess(value=output_path)) + def _on_completed(self, written: Tuple[Path, ...]) -> None: + self._emit(ServiceSuccess(value=written)) def _on_error(self, exception: Exception) -> None: self._emit(ServiceError(exception=exception)) diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index 331013ab3..2f237ea9e 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Generic, Optional, TypeVar, Union +from typing import Generic, Optional, Tuple, TypeVar, Union from sampletones_core.parallelization import TaskProgress @@ -44,7 +44,7 @@ class ServiceIntermediate(Generic[T]): ServiceStarted, ServiceProgress[Path], ServiceIntermediate[TaskProgress], - ServiceSuccess[Path], + ServiceSuccess[Tuple[Path, ...]], ServiceError, ServiceCancelled, ] diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py index c85133963..ef2264a02 100644 --- a/src/sampletones_core/reconstructions/converter/__init__.py +++ b/src/sampletones_core/reconstructions/converter/__init__.py @@ -1,19 +1,27 @@ -from .conversion import reconstruct_file +from .conversion import reconstruct_job from .converter import ReconstructionConverter +from .job import ConversionJob from .paths.fields import ConfigDirectoryFields from .paths.utils import ( filter_files, get_audio_files, get_output_path, get_relative_path, + group_output_path, ) +from .plan import ConversionPlan, DirectoryConversion, GroupConversion __all__ = [ "ConfigDirectoryFields", + "ConversionJob", + "ConversionPlan", + "DirectoryConversion", + "GroupConversion", "ReconstructionConverter", "filter_files", "get_audio_files", "get_output_path", "get_relative_path", - "reconstruct_file", + "group_output_path", + "reconstruct_job", ] diff --git a/src/sampletones_core/reconstructions/converter/conversion.py b/src/sampletones_core/reconstructions/converter/conversion.py index 1d3dfe158..badc36b38 100644 --- a/src/sampletones_core/reconstructions/converter/conversion.py +++ b/src/sampletones_core/reconstructions/converter/conversion.py @@ -6,23 +6,36 @@ from sampletones_shared.logger import logger from ..reconstructor.reconstructor import Reconstructor +from .job import ConversionJob -def reconstruct_file(arguments: Tuple[Reconstructor, Path, Path]) -> Path: - reconstructor, input_path, output_path = arguments - output_path.parent.mkdir(parents=True, exist_ok=True) +def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob]) -> Path: + """Builds one job's reconstruction and writes it where the job says. + + Runs in a pool worker, so the job travels with the reconstructor that builds it. A source + in a format the loader has no reader for is reported and left, which keeps one such file + from ending a batch. + + Returns: + The file the job named, whether or not a reconstruction reached it. + + Raises: + KeyboardInterrupt: If the run is interrupted, so the pool stops. + """ + reconstructor, job = arguments + job.output_path.parent.mkdir(parents=True, exist_ok=True) reconstruction = None try: - reconstruction = reconstructor(input_path) + reconstruction = reconstructor.reconstruct(job.sources, job.stems) if reconstruction is not None: - reconstruction.save(output_path) + reconstruction.save(job.output_path) del reconstruction except KeyboardInterrupt: logger.info("Reconstruction interrupted by user.") raise except UnsupportedAudioFormatError: - logger.warning(f"Skipping file due to unsupported audio format: {input_path}") + logger.warning(f"Skipping job due to unsupported audio format: {job.sources}") finally: gc.collect() - return output_path + return job.output_path diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py index fac0b9c91..285c9d1b3 100644 --- a/src/sampletones_core/reconstructions/converter/converter.py +++ b/src/sampletones_core/reconstructions/converter/converter.py @@ -3,33 +3,33 @@ from sampletones_core.configs import Config from sampletones_core.parallelization import TaskProcessor -from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import LoggerProtocol from sampletones_shared.logger import logger as default_logger from ..reconstructor.reconstructor import Reconstructor -from .conversion import reconstruct_file -from .paths import ( - filter_files, - get_audio_files, - get_output_path, - get_relative_path, -) +from .conversion import reconstruct_job +from .job import ConversionJob +from .plan.protocol import ConversionPlan class ReconstructionConverter(TaskProcessor[Path]): + """Runs a conversion plan's jobs across a pool of worker processes. + + The plan is resolved once the run starts, on the monitor thread, so a plan that scans a + directory does its reading there. Every job is then built by one worker, and the run + reports the reconstructions it wrote. + """ + def __init__( self, config: Config, - input_path: Path, - is_file: bool, + plan: ConversionPlan, logger: LoggerProtocol = default_logger, ) -> None: super().__init__(max_workers=config.general.max_workers, logger=logger) self.config = config.model_copy() - self.input_path: Path = input_path - self.is_file: bool = is_file - self.audio_files: List[Path] = [] + self.plan: ConversionPlan = plan + self.jobs: List[ConversionJob] = [] self.current_file: Optional[str] = None @@ -42,38 +42,21 @@ def start(self) -> None: def _create_tasks(self) -> List[Any]: reconstructor = Reconstructor(self.config) - output_path = get_output_path(self.config, self.input_path) - - if self.is_file: - return [(reconstructor, self.input_path, output_path)] - - self.audio_files = get_audio_files(self.input_path) - self.audio_files = filter_files(self.audio_files, self.input_path, output_path) - - arguments: List[Tuple[Reconstructor, Path, Path]] = [] - for audio_file in self.audio_files: - target_path = get_relative_path(self.input_path, audio_file, output_path) - arguments.append((reconstructor, audio_file, target_path)) - - if not arguments: - raise NoFilesToProcessError(f"No audio files found in {self.input_path}") - - return arguments + self.jobs = self.plan.jobs(self.config) + return [(reconstructor, job) for job in self.jobs] def _get_task_function( self, - ) -> Callable[[Tuple[Reconstructor, Path, Path]], Path]: - return reconstruct_file - - def _process_results(self, results: List[Path]) -> Path: - if self.is_file: - return results[0] + ) -> Callable[[Tuple[Reconstructor, ConversionJob]], Path]: + return reconstruct_job - return self.input_path + def _process_results(self, results: List[Path]) -> Tuple[Path, ...]: + """The reconstructions the run wrote, in job order.""" + return tuple(output_path for output_path in results if output_path.exists()) def _notify_progress(self) -> None: - if self.completed_tasks > 0 and self.completed_tasks <= len(self.audio_files): - self.current_file = str(self.audio_files[self.completed_tasks - 1]) + if 0 < self.completed_tasks <= len(self.jobs): + self.current_file = str(self.jobs[self.completed_tasks - 1].sources[0]) self.current_item = self.current_file super()._notify_progress() diff --git a/src/sampletones_core/reconstructions/converter/job.py b/src/sampletones_core/reconstructions/converter/job.py new file mode 100644 index 000000000..5d63a0398 --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/job.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple + +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +@dataclass(frozen=True) +class ConversionJob: + """One reconstruction to build, and everything needed to build it. + + A job carries the recordings that are mixed into the target, the stems setup that hands + their channels out, and the file the result is written to. It is the unit a conversion + is divided into: a classic conversion is one job over one source, a stems conversion one + job over several, and a batch as many single-source jobs as the directory holds. + """ + + sources: Tuple[Path, ...] + stems: StemsConfig + output_path: Path diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 3069f11b1..84f3ca76f 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -5,6 +5,7 @@ from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) +from sampletones_core.reconstructions.naming.derive import derive_name from sampletones_shared.paths.extensions import ( EXT_FILE_RECONSTRUCTION, EXT_FILES_AUDIO, @@ -43,6 +44,25 @@ def get_output_path( raise OSError(f"Invalid path: {input_path}") +def group_output_path( + config: Config, + sources: Tuple[Path, ...], + suffix: str = EXT_FILE_RECONSTRUCTION, +) -> Path: + """Where the one reconstruction built from ``sources`` is written. + + The file sits in the configuration's own directory, under the name the source rules derive: + one source names it after itself, and several after what they share + (:func:`sampletones_core.reconstructions.naming.derive.derive_name`). + + Raises: + ValueError: If ``sources`` is empty. + """ + config_directory = ConfigDirectoryFields.generate_config_directory_name(config) + output_directory = to_path(config.general.reconstructions_directory) / config_directory + return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute()) + + def get_audio_files( input_directory: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO, diff --git a/src/sampletones_core/reconstructions/converter/plan/__init__.py b/src/sampletones_core/reconstructions/converter/plan/__init__.py new file mode 100644 index 000000000..7b7442d8e --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/__init__.py @@ -0,0 +1,9 @@ +from .directory import DirectoryConversion +from .group import GroupConversion +from .protocol import ConversionPlan + +__all__ = [ + "ConversionPlan", + "DirectoryConversion", + "GroupConversion", +] diff --git a/src/sampletones_core/reconstructions/converter/plan/directory.py b/src/sampletones_core/reconstructions/converter/plan/directory.py new file mode 100644 index 000000000..eb8383741 --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/directory.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.job import ConversionJob +from sampletones_core.reconstructions.converter.paths.utils import ( + filter_files, + get_audio_files, + get_output_path, + get_relative_path, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_shared.exceptions import NoFilesToProcessError + + +@dataclass(frozen=True) +class DirectoryConversion: + """One reconstruction per audio file under a directory, each built from that file alone. + + The scan reaches every audio file below the directory and keeps those whose reconstruction + is still to be written, so a repeated run picks up where the last one stopped. The output + tree mirrors the input tree, and every file is converted under the same stems setup. + """ + + directory: Path + stems: StemsConfig + + def jobs(self, config: Config) -> List[ConversionJob]: + """The single-source jobs the directory holds. + + Raises: + NoFilesToProcessError: If the directory holds no audio file still to be converted. + """ + output_path = get_output_path(config, self.directory) + audio_files = filter_files(get_audio_files(self.directory), self.directory, output_path) + if not audio_files: + raise NoFilesToProcessError(f"No audio files found in {self.directory}") + + return [self._job(audio_file, output_path) for audio_file in audio_files] + + def _job(self, audio_file: Path, output_path: Path) -> ConversionJob: + return ConversionJob( + sources=(audio_file,), + stems=self.stems, + output_path=get_relative_path(self.directory, audio_file, output_path), + ) diff --git a/src/sampletones_core/reconstructions/converter/plan/group.py b/src/sampletones_core/reconstructions/converter/plan/group.py new file mode 100644 index 000000000..eb86f0ebb --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/group.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Tuple + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.job import ConversionJob +from sampletones_core.reconstructions.converter.paths.utils import group_output_path +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +@dataclass(frozen=True) +class GroupConversion: + """One reconstruction from the recordings given, mixed together under one stems setup. + + One source is the classic conversion and several are the stems case; both amount to the + same single job, which is what lets one conversion path serve them. + """ + + sources: Tuple[Path, ...] + stems: StemsConfig + + def jobs(self, config: Config) -> List[ConversionJob]: + return [ + ConversionJob( + sources=self.sources, + stems=self.stems, + output_path=self._output_path(config), + ) + ] + + def _output_path(self, config: Config) -> Path: + return group_output_path(config, self.sources) diff --git a/src/sampletones_core/reconstructions/converter/plan/protocol.py b/src/sampletones_core/reconstructions/converter/plan/protocol.py new file mode 100644 index 000000000..e22e20bff --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/protocol.py @@ -0,0 +1,16 @@ +from typing import List, Protocol + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.job import ConversionJob + + +class ConversionPlan(Protocol): + """What a conversion request amounts to: the reconstructions it builds. + + A plan answers with the jobs the request divides into, in the order they are run, resolved + against the configuration the run uses, since that is what settles where each reconstruction + is written. Resolving happens on the converter's own thread, so a plan that reads the + filesystem does so away from the interface. + """ + + def jobs(self, config: Config) -> List[ConversionJob]: ... diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 32d53a4ec..42ae1ae8e 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -84,14 +84,14 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: TypeError: If ``path`` is not a string or ``Path``. """ stems_config = StemsConfig.single_entry(list(self.config.generation.channels)) - return self.reconstruct_stems([path], stems_config) + return self.reconstruct([path], stems_config) - def reconstruct_stems( + def reconstruct( self, paths: Sequence[Pathlike], stems_config: StemsConfig, ) -> Optional[Reconstruction]: - """Reconstructs the mix of several stem audio files into one reconstruction. + """Reconstructs the mix of one or more stem audio files into one reconstruction. Loads and normalizes every stem, matches the frames of the stems' mix against the library, and assigns each frame's channels to the stems following the diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py index 8037aa256..b3ab0de2d 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from tqdm import tqdm @@ -8,10 +8,13 @@ from sampletones_core.parallelization import TaskProgress, TaskStatus from sampletones_core.reconstructions import Reconstructor from sampletones_core.reconstructions.converter import ( + ConversionJob, + DirectoryConversion, ReconstructionConverter, get_output_path, + reconstruct_job, ) -from sampletones_core.reconstructions.converter import reconstruct_file as _reconstruct_file +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.scripts.library import generate_library from sampletones_shared.logger import logger, null_logger @@ -32,8 +35,12 @@ def reconstruct_file( raise IsADirectoryError(f"Expected a file path, got directory path: {input_path}") logger.info(f"Starting reconstruction for file {input_path}") - reconstructor = Reconstructor(config) - _reconstruct_file((reconstructor, input_path, output_path)) + job = ConversionJob( + sources=(input_path,), + stems=_classic_setup(config), + output_path=output_path, + ) + reconstruct_job((Reconstructor(config), job)) logger.info(f"Reconstruction file saved to {output_path}") @@ -59,7 +66,7 @@ def on_start() -> None: progress_bar.disable = False logger.info(f"Starting reconstruction for directory {input_path}") - def on_completed(_path: Path) -> None: + def on_completed(_written: Tuple[Path, ...]) -> None: logger.info(f"Reconstruction directory saved to {output_path}") progress_bar.close() @@ -96,8 +103,7 @@ def on_error(_exception: Exception) -> None: converter = ReconstructionConverter( config, - input_path=input_path, - is_file=False, + DirectoryConversion(directory=input_path, stems=_classic_setup(config)), logger=null_logger, ) @@ -116,3 +122,8 @@ def on_error(_exception: Exception) -> None: logger.info("Reconstruction interrupted by user") finally: progress_bar.close() + + +def _classic_setup(config: Config) -> StemsConfig: + """The setup a single-source conversion runs under: one stem over every enabled channel.""" + return StemsConfig.single_entry(list(config.generation.channels)) diff --git a/tests/integration/reconstruction/test_conversion_jobs.py b/tests/integration/reconstruction/test_conversion_jobs.py new file mode 100644 index 000000000..2a5dcba79 --- /dev/null +++ b/tests/integration/reconstruction/test_conversion_jobs.py @@ -0,0 +1,77 @@ +from pathlib import Path + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.converter import ( + DirectoryConversion, + GroupConversion, + reconstruct_job, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from tests.integration.assets.reconstruction import ( + build_mini_library, + three_stem_config, + three_stem_reconstruction_config, + write_three_stem_recordings, +) + + +def _writing_to(config: Config, directory: Path) -> Config: + general = config.general.model_copy(update={"reconstructions_directory": str(directory)}) + return config.model_copy(update={"general": general}) + + +class TestGroupConversionEndToEnd: + """Several recordings reach one written reconstruction through the job seam.""" + + def test_three_stems_convert_into_one_reconstruction_file(self, tmp_path: Path) -> None: + config = _writing_to(three_stem_reconstruction_config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + sources = write_three_stem_recordings(config, tmp_path) + + jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config) + + assert len(jobs) == 1 + written = reconstruct_job((reconstructor, jobs[0])) + + assert written.exists() + loaded = Reconstruction.load(written) + assert loaded.audio_filepath == sources + assert loaded.stems_data.config == three_stem_config() + assert set(loaded.stems_data.assignments_by_channel) == set(loaded.playing_channels) + + def test_one_source_converts_the_classic_way(self, tmp_path: Path) -> None: + config = _writing_to(Config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(list(config.generation.channels)) + + jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) + written = reconstruct_job((reconstructor, jobs[0])) + + loaded = Reconstruction.load(written) + assert written.stem == source.stem + assert loaded.audio_filepath == (source,) + assert loaded.stems_data.config == stems + + +class TestDirectoryConversionEndToEnd: + """A directory converts into one reconstruction per audio file, each from that file alone.""" + + def test_each_recording_is_written_on_its_own(self, tmp_path: Path) -> None: + config = _writing_to(Config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + recordings = tmp_path / "recordings" + recordings.mkdir() + sources = write_three_stem_recordings(config, recordings) + stems = StemsConfig.single_entry([ChannelName.PULSE1], channel_cap=1) + + jobs = DirectoryConversion(directory=recordings, stems=stems).jobs(config) + + assert len(jobs) == len(sources) + for job in jobs: + written = reconstruct_job((reconstructor, job)) + loaded = Reconstruction.load(written) + assert loaded.audio_filepath == job.sources + assert tuple(loaded.playing_channels) == (ChannelName.PULSE1,) diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 89948f592..1210a0476 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -65,7 +65,7 @@ def test_assigns_disjoint_stems_to_their_channels(self, tmp_path: Path) -> None: write_wave(tone_path, sample_rate, tone) write_wave(noise_path, sample_rate, noise) - reconstruction = reconstructor.reconstruct_stems( + reconstruction = reconstructor.reconstruct( [tone_path, noise_path], _stems_config(), ) @@ -97,7 +97,7 @@ def test_requires_one_path_per_entry(self, tmp_path: Path) -> None: reconstructor = Reconstructor(config, library=library) with pytest.raises(ValueError, match="stem paths"): - reconstructor.reconstruct_stems( + reconstructor.reconstruct( [tmp_path / "only_one.wav"], _stems_config(), ) @@ -114,7 +114,7 @@ def test_builds_a_reconstruction_over_the_three_stems(self, tmp_path: Path) -> N stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) - reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + reconstruction = reconstructor.reconstruct(list(paths), stems_config) assert reconstruction is not None assert reconstruction.audio_filepath == paths @@ -148,7 +148,7 @@ def test_every_channel_in_play_carries_one_entry_per_frame(self, tmp_path: Path) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) - reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + reconstruction = reconstructor.reconstruct(list(paths), stems_config) assert reconstruction is not None frame_count = _frame_count(config, STEM_RECORDING_DURATION_SECONDS) @@ -171,7 +171,7 @@ def test_round_trips_through_the_file(self, tmp_path: Path) -> None: reconstructor = Reconstructor(config, library=library) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) - reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + reconstruction = reconstructor.reconstruct(list(paths), stems_config) assert reconstruction is not None save_path = tmp_path / "three_stems.stn" @@ -196,7 +196,7 @@ def test_selection_filters_the_waveform_and_partials(self, tmp_path: Path) -> No reconstructor = Reconstructor(config, library=library) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) - reconstruction = reconstructor.reconstruct_stems(list(paths), stems_config) + reconstruction = reconstructor.reconstruct(list(paths), stems_config) assert reconstruction is not None save_path = tmp_path / "three_stems.stn" @@ -285,7 +285,7 @@ def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> Non write_wave(tone_path, sample_rate, tone) write_wave(noise_path, sample_rate, noise) - reconstruction = reconstructor.reconstruct_stems( + reconstruction = reconstructor.reconstruct( [tone_path, noise_path], _stems_config(), ) @@ -350,7 +350,7 @@ def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> reconstructor = Reconstructor(config, library=library) tone_path = self._tone_path(tmp_path, config) - reconstruction = reconstructor.reconstruct_stems( + reconstruction = reconstructor.reconstruct( [tone_path], StemsConfig.single_entry(list(config.generation.channels), channel_cap=1), ) diff --git a/tests/integration/sampletones_application/services/test_conversion.py b/tests/integration/sampletones_application/services/test_conversion.py index 57ecda625..8d8336c6e 100644 --- a/tests/integration/sampletones_application/services/test_conversion.py +++ b/tests/integration/sampletones_application/services/test_conversion.py @@ -1,49 +1,69 @@ +from pathlib import Path +from typing import Any, Dict from unittest.mock import MagicMock, patch from sampletones_application.services.conversion import ConversionService from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion +from sampletones_core.reconstructions.converter.plan.protocol import ConversionPlan +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +def _stems(config: Config) -> StemsConfig: + return StemsConfig.single_entry(list(config.generation.channels)) class TestConversionServiceArgumentRouting: - """Verify that ConversionService.start() routes Config and Path arguments to - ReconstructionConverter correctly, including deriving is_file from real filesystem state. + """The service hands the converter the configuration and the plan it was given, untouched. - ReconstructionConverter itself is still patched — running a real conversion requires WAV - files and a process pool. The integration value here is that input_path.is_file() is - evaluated against a real filesystem path (via tmp_path), not a MagicMock. + What a request converts is decided above the service, so the service's whole part is to + build a converter around that decision and drive its callbacks. The converter itself stays + patched here: running one needs real audio and a process pool. """ - def _start_with_captured_kwargs(self, config: Config, path) -> dict: - with patch("sampletones_application.services.conversion.ReconstructionConverter") as mock_cls: - mock_cls.return_value = MagicMock() - ConversionService().start(config, path) + def _start_with_captured_kwargs(self, config: Config, plan: ConversionPlan) -> Dict[str, Any]: + with patch("sampletones_application.services.conversion.ReconstructionConverter") as mock_class: + mock_class.return_value = MagicMock() + ConversionService().start(config, plan) - return mock_cls.call_args.kwargs + return dict(mock_class.call_args.kwargs) - def test_start_passes_is_file_true_for_regular_file(self, tmp_path, default_config) -> None: + def test_start_passes_config_to_converter(self, tmp_path: Path, default_config: Config) -> None: real_file = tmp_path / "sample.wav" real_file.write_bytes(b"") + plan = GroupConversion(sources=(real_file,), stems=_stems(default_config)) - call_kwargs = self._start_with_captured_kwargs(default_config, real_file) - assert call_kwargs["is_file"] is True - - def test_start_passes_is_file_false_for_directory(self, tmp_path, default_config) -> None: - real_dir = tmp_path / "samples" - real_dir.mkdir() + assert self._start_with_captured_kwargs(default_config, plan)["config"] is default_config - call_kwargs = self._start_with_captured_kwargs(default_config, real_dir) - assert call_kwargs["is_file"] is False - - def test_start_passes_config_to_converter(self, tmp_path, default_config) -> None: + def test_start_passes_the_plan_to_converter(self, tmp_path: Path, default_config: Config) -> None: real_file = tmp_path / "sample.wav" real_file.write_bytes(b"") + plan = GroupConversion(sources=(real_file,), stems=_stems(default_config)) - call_kwargs = self._start_with_captured_kwargs(default_config, real_file) - assert call_kwargs["config"] is default_config + assert self._start_with_captured_kwargs(default_config, plan)["plan"] is plan - def test_start_passes_input_path_to_converter(self, tmp_path, default_config) -> None: - real_file = tmp_path / "sample.wav" - real_file.write_bytes(b"") + def test_a_directory_plan_reaches_the_converter_as_it_is(self, tmp_path: Path, default_config: Config) -> None: + real_directory = tmp_path / "samples" + real_directory.mkdir() + plan = DirectoryConversion(directory=real_directory, stems=_stems(default_config)) + + assert self._start_with_captured_kwargs(default_config, plan)["plan"] is plan + + +class TestDirectoryPlanReadsTheFilesystem: + """A directory plan resolves against the real tree, which is where a batch's jobs come from.""" + + def test_every_audio_file_below_the_directory_becomes_a_job( + self, + tmp_path: Path, + default_config: Config, + ) -> None: + (tmp_path / "nested").mkdir() + (tmp_path / "a.wav").write_bytes(b"") + (tmp_path / "nested" / "b.wav").write_bytes(b"") + (tmp_path / "notes.txt").write_text("not audio") + + jobs = DirectoryConversion(directory=tmp_path, stems=_stems(default_config)).jobs(default_config) - call_kwargs = self._start_with_captured_kwargs(default_config, real_file) - assert call_kwargs["input_path"] == real_file + assert {job.sources[0].name for job in jobs} == {"a.wav", "b.wav"} + assert all(job.output_path.suffix == ".stn" for job in jobs) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index c1abf3a98..d1a1769d9 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -113,7 +113,7 @@ def test_file_success_refreshes_and_offers_to_load(self) -> None: coordinator = _success_coordinator() output_path = Path("/reconstructions/kick.rcn") - coordinator._on_conversion_success(ConversionSuccess(is_file=True, output_path=output_path)) + coordinator._on_conversion_success(ConversionSuccess(written=(output_path,))) coordinator._on_refresh_trees.assert_called_once_with() coordinator._dialogs.show_confirmation.assert_called_once() @@ -126,10 +126,11 @@ def test_file_success_refreshes_and_offers_to_load(self) -> None: assert kwargs["path"] == output_path assert kwargs["on_cancel"] == coordinator._converter_logic.close - def test_directory_success_offers_to_open_without_a_path(self) -> None: + def test_a_batch_offers_to_open_the_folder(self) -> None: coordinator = _success_coordinator() + written = (Path("/reconstructions/kick.rcn"), Path("/reconstructions/snare.rcn")) - coordinator._on_conversion_success(ConversionSuccess(is_file=False, output_path=Path("/reconstructions"))) + coordinator._on_conversion_success(ConversionSuccess(written=written)) _, kwargs = coordinator._dialogs.show_confirmation.call_args assert kwargs["ok_label"] == OPEN_BUTTON_KEY diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index c2a98a112..00872caf6 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -13,6 +13,9 @@ ConversionPhase, ConverterViewModel, ) +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from tests.suite.language import FakeLanguageManager TEXTS: Final[Dict[str, str]] = { @@ -279,24 +282,46 @@ def test_unexpected_failure_propagates( class TestConversionCompleteHandsOverOutcome: - """A completed conversion tells its listener what was produced, so the follow-up load offer can - target the single reconstruction (file) or the browser (directory).""" + """A completed conversion tells its listener what it wrote, so the follow-up offer can target + the single reconstruction or the folder holding a batch.""" - def test_success_carries_input_kind_and_output_path( + def test_success_carries_the_reconstructions_that_were_written( self, converter_logic: ConverterLogic, ) -> None: on_success = MagicMock() converter_logic.on_success = on_success - converter_logic._is_file = True - converter_logic._output_path = Path("/reconstructions/kick.rcn") + written = (Path("/reconstructions/kick.rcn"),) - converter_logic._on_conversion_complete(Path("/reconstructions/kick.rcn")) + converter_logic._on_conversion_complete(written) assert converter_logic._phase == ConversionPhase.COMPLETED - on_success.assert_called_once_with( - ConversionSuccess(is_file=True, output_path=Path("/reconstructions/kick.rcn")) - ) + on_success.assert_called_once_with(ConversionSuccess(written=written)) + + def test_one_written_reconstruction_becomes_the_displayed_output( + self, + converter_logic: ConverterLogic, + ) -> None: + written = (Path("/reconstructions/kick.rcn"),) + + converter_logic._on_conversion_complete(written) + + assert converter_logic._output_path == written[0] + + def test_a_batch_loads_the_folder_and_one_file_loads_itself( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic.on_load_file = MagicMock() + converter_logic.on_load_directory = MagicMock() + + converter_logic._on_conversion_complete((Path("/reconstructions/kick.rcn"),)) + converter_logic.handle_load_request() + converter_logic.on_load_file.assert_called_once_with(Path("/reconstructions/kick.rcn")) + + converter_logic._on_conversion_complete((Path("/reconstructions/kick.rcn"), Path("/reconstructions/snare.rcn"))) + converter_logic.handle_load_request() + converter_logic.on_load_directory.assert_called_once_with() class TestFailureReturnsToIdle: @@ -316,3 +341,49 @@ def test_failure_schedules_return_to_idle_and_reports( scheduled.assert_called_once() assert scheduled.call_args.args[0] == converter_logic.close converter_logic.on_error.assert_called_once() + + +class TestConversionPlan: + """What the converter asks the service to run: one reconstruction for a file, one per audio + file for a directory.""" + + def _prepare(self, converter_logic: ConverterLogic, input_path: Path, is_file: bool) -> Config: + config = Config() + converter_logic._config_manager.config = config + converter_logic._input_path = input_path + converter_logic._is_file = is_file + return config + + def test_a_file_becomes_one_group_over_that_file(self, converter_logic: ConverterLogic) -> None: + config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) + + plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) + + assert isinstance(plan, GroupConversion) + assert plan.sources == (Path("/audio/kick.wav"),) + + def test_a_directory_becomes_a_directory_conversion(self, converter_logic: ConverterLogic) -> None: + config = self._prepare(converter_logic, Path("/audio"), is_file=False) + + plan = converter_logic._conversion_plan(config, Path("/audio")) + + assert isinstance(plan, DirectoryConversion) + assert plan.directory == Path("/audio") + + def test_the_setup_covers_every_enabled_channel(self, converter_logic: ConverterLogic) -> None: + """With no stems chosen, one stem holds every channel the configuration enables.""" + config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) + + plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) + + assert plan.stems == StemsConfig.single_entry(list(config.generation.channels)) + + def test_starting_hands_the_plan_to_the_service(self, converter_logic: ConverterLogic) -> None: + config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) + converter_logic._config_manager.config = config + + converter_logic._start_conversion() + + started_config, started_plan = converter_logic._service.start.call_args.args + assert started_config == config + assert isinstance(started_plan, GroupConversion) diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/__init__.py b/tests/unit/sampletones_core/reconstructions/converter/plan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py new file mode 100644 index 000000000..907ea5162 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter.paths.utils import get_output_path, group_output_path +from sampletones_core.reconstructions.converter.plan.directory import DirectoryConversion +from sampletones_core.reconstructions.converter.plan.group import GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_shared.exceptions import NoFilesToProcessError + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config() + + +@pytest.fixture(scope="module") +def stems(config: Config) -> StemsConfig: + return StemsConfig.single_entry(list(config.generation.channels)) + + +def _write_audio_files(directory: Path, names: List[str]) -> List[Path]: + paths = [] + for name in names: + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + paths.append(path) + + return paths + + +class TestGroupConversion: + def test_one_source_makes_one_job_over_that_source( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["song.wav"])[0] + + jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) + + assert len(jobs) == 1 + assert jobs[0].sources == (source,) + assert jobs[0].stems == stems + assert jobs[0].output_path == get_output_path(config, source) + + def test_several_sources_make_one_job_over_all_of_them( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + """Several recordings mix into one reconstruction, so they amount to one job.""" + sources = tuple(_write_audio_files(tmp_path, ["bass.wav", "drums.wav", "lead.wav"])) + + jobs = GroupConversion(sources=sources, stems=stems).jobs(config) + + assert len(jobs) == 1 + assert jobs[0].sources == sources + assert jobs[0].output_path == group_output_path(config, sources) + + def test_the_setup_travels_with_the_job( + self, + config: Config, + tmp_path: Path, + ) -> None: + sources = tuple(_write_audio_files(tmp_path, ["a.wav", "b.wav"])) + targeted = StemsConfig.single_entry([ChannelName.PULSE1], channel_cap=1) + + jobs = GroupConversion(sources=sources, stems=targeted).jobs(config) + + assert jobs[0].stems == targeted + + +class TestDirectoryConversion: + def test_every_audio_file_becomes_its_own_single_source_job( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + _write_audio_files(tmp_path, ["a.wav", "nested/b.wav"]) + + jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) + + assert len(jobs) == 2 + assert all(len(job.sources) == 1 for job in jobs) + assert {job.sources[0].name for job in jobs} == {"a.wav", "b.wav"} + + def test_the_output_tree_mirrors_the_input_tree( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + _write_audio_files(tmp_path, ["nested/deeper/b.wav"]) + output_path = get_output_path(config, tmp_path) + + jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) + + assert jobs[0].output_path == output_path / "nested" / "deeper" / "b.stn" + + def test_every_job_carries_the_same_setup( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + _write_audio_files(tmp_path, ["a.wav", "b.wav"]) + + jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) + + assert [job.stems for job in jobs] == [stems, stems] + + def test_a_directory_holding_nothing_to_convert_raises( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + with pytest.raises(NoFilesToProcessError): + DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) + + +class TestGroupOutputPath: + def test_one_source_names_the_file_after_itself(self, config: Config, tmp_path: Path) -> None: + source = tmp_path / "song.wav" + source.touch() + assert group_output_path(config, (source,)) == get_output_path(config, source) + + def test_sources_sharing_a_directory_name_the_file_after_it(self, config: Config, tmp_path: Path) -> None: + sources = tuple(_write_audio_files(tmp_path / "session", ["a.wav", "b.wav"])) + assert group_output_path(config, sources).stem == "session" diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py index 741c36dbc..10fb0a867 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py @@ -3,8 +3,11 @@ import pytest -from sampletones_core.reconstructions.converter.conversion import reconstruct_file +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.conversion import reconstruct_job +from sampletones_core.reconstructions.converter.job import ConversionJob from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import UnsupportedAudioFormatError @@ -13,36 +16,52 @@ def mock_reconstructor() -> MagicMock: return MagicMock(spec=Reconstructor) -class TestReconstructFile: +def _job(tmp_path: Path, output_path: Path) -> ConversionJob: + return ConversionJob( + sources=(tmp_path / "song.wav",), + stems=StemsConfig.single_entry(list(Config().generation.channels)), + output_path=output_path, + ) + + +class TestReconstructJob: def test_creates_parent_directory_when_not_exist( self, mock_reconstructor: MagicMock, tmp_path: Path, ) -> None: output_path = tmp_path / "nested" / "dir" / "song.stn" - reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path)) + reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) assert output_path.parent.exists() + def test_builds_the_reconstruction_from_the_jobs_sources_and_setup( + self, + mock_reconstructor: MagicMock, + tmp_path: Path, + ) -> None: + job = _job(tmp_path, tmp_path / "song.stn") + reconstruct_job((mock_reconstructor, job)) + mock_reconstructor.reconstruct.assert_called_once_with(job.sources, job.stems) + def test_saves_reconstruction_to_output_path( self, mock_reconstructor: MagicMock, tmp_path: Path, ) -> None: mock_reconstruction = MagicMock() - mock_reconstructor.return_value = mock_reconstruction + mock_reconstructor.reconstruct.return_value = mock_reconstruction output_path = tmp_path / "song.stn" - reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path)) + reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) mock_reconstruction.save.assert_called_once_with(output_path) - def test_does_not_save_when_reconstructor_returns_none( + def test_reports_the_output_path_when_the_reconstruction_is_empty( self, mock_reconstructor: MagicMock, tmp_path: Path, ) -> None: - mock_reconstructor.return_value = None + mock_reconstructor.reconstruct.return_value = None output_path = tmp_path / "song.stn" - result = reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path)) - assert result == output_path + assert reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) == output_path def test_always_returns_output_path( self, @@ -50,26 +69,22 @@ def test_always_returns_output_path( tmp_path: Path, ) -> None: output_path = tmp_path / "song.stn" - result = reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path)) - assert result == output_path + assert reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) == output_path def test_unsupported_audio_format_error_is_swallowed( self, mock_reconstructor: MagicMock, tmp_path: Path, ) -> None: - mock_reconstructor.side_effect = UnsupportedAudioFormatError("bad format") + mock_reconstructor.reconstruct.side_effect = UnsupportedAudioFormatError("bad format") output_path = tmp_path / "song.stn" - result = reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path)) - assert result == output_path + assert reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) == output_path def test_keyboard_interrupt_is_reraised( self, mock_reconstructor: MagicMock, tmp_path: Path, ) -> None: - mock_reconstructor.side_effect = KeyboardInterrupt + mock_reconstructor.reconstruct.side_effect = KeyboardInterrupt with pytest.raises(KeyboardInterrupt): - reconstruct_file( - (mock_reconstructor, tmp_path / "song.wav", tmp_path / "song.stn"), - ) + reconstruct_job((mock_reconstructor, _job(tmp_path, tmp_path / "song.stn"))) diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py index 2819e3ccb..8eb435c76 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -5,9 +5,12 @@ from sampletones_core.configs import Config from sampletones_core.reconstructions.converter import ( + DirectoryConversion, + GroupConversion, ReconstructionConverter, - reconstruct_file, + reconstruct_job, ) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoFilesToProcessError _RECONSTRUCTOR_PATCH = "sampletones_core.reconstructions.converter.converter.Reconstructor" @@ -18,155 +21,143 @@ def config() -> Config: return Config() -class TestReconstructionConverterInit: - def test_stores_config_as_copy(self, config: Config, tmp_path: Path) -> None: - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) - assert converter.config is not config +@pytest.fixture(scope="module") +def stems(config: Config) -> StemsConfig: + return StemsConfig.single_entry(list(config.generation.channels)) - def test_stores_input_path(self, config: Config, tmp_path: Path) -> None: - input_path = tmp_path / "song.wav" - converter = ReconstructionConverter(config, input_path, is_file=True) - assert converter.input_path == input_path - def test_is_file_flag_stored(self, config: Config, tmp_path: Path) -> None: - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) - assert converter.is_file is True +def _group(path: Path, stems: StemsConfig) -> GroupConversion: + return GroupConversion(sources=(path,), stems=stems) - def test_tracking_variables_initialized_to_zero( - self, - config: Config, - tmp_path: Path, - ) -> None: - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) + +class TestReconstructionConverterInit: + def test_stores_config_as_copy(self, config: Config, stems: StemsConfig, tmp_path: Path) -> None: + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) + assert converter.config is not config + + def test_stores_the_plan_it_runs(self, config: Config, stems: StemsConfig, tmp_path: Path) -> None: + plan = _group(tmp_path / "song.wav", stems) + converter = ReconstructionConverter(config, plan) + assert converter.plan is plan class TestReconstructionConverterStart: def test_already_running_does_not_spawn_thread( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) converter.running = True converter.start() assert converter.monitor_thread is None class TestReconstructionConverterCreateTasks: - def test_file_input_returns_single_task( + def test_a_group_plan_returns_a_single_task( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: audio_file = tmp_path / "song.wav" audio_file.touch() - converter = ReconstructionConverter(config, audio_file, is_file=True) + converter = ReconstructionConverter(config, _group(audio_file, stems)) with patch(_RECONSTRUCTOR_PATCH): - result = converter._create_tasks() - assert len(result) == 1 + tasks = converter._create_tasks() + assert len(tasks) == 1 - def test_file_task_uses_input_path_as_source( + def test_each_task_carries_its_job( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: audio_file = tmp_path / "song.wav" audio_file.touch() - converter = ReconstructionConverter(config, audio_file, is_file=True) + converter = ReconstructionConverter(config, _group(audio_file, stems)) with patch(_RECONSTRUCTOR_PATCH): - result = converter._create_tasks() - assert result[0][1] == audio_file + tasks = converter._create_tasks() + assert tasks[0][1] is converter.jobs[0] + assert converter.jobs[0].sources == (audio_file,) - def test_directory_input_returns_one_task_per_audio_file( + def test_a_directory_plan_returns_one_task_per_audio_file( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: (tmp_path / "a.wav").touch() (tmp_path / "b.wav").touch() - converter = ReconstructionConverter(config, tmp_path, is_file=False) + converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems)) with patch(_RECONSTRUCTOR_PATCH): - result = converter._create_tasks() - assert len(result) == 2 + tasks = converter._create_tasks() + assert len(tasks) == 2 - def test_directory_with_no_audio_files_raises_no_files_to_process_error( + def test_a_directory_with_no_audio_files_raises_no_files_to_process_error( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - converter = ReconstructionConverter(config, tmp_path, is_file=False) + converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems)) with patch(_RECONSTRUCTOR_PATCH): with pytest.raises(NoFilesToProcessError): converter._create_tasks() class TestReconstructionConverterGetTaskFunction: - def test_returns_reconstruct_file_function( + def test_returns_the_job_conversion_function( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) - assert converter._get_task_function() is reconstruct_file + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) + assert converter._get_task_function() is reconstruct_job class TestReconstructionConverterProcessResults: - def test_file_mode_returns_first_result( + def test_reports_the_reconstructions_that_were_written( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - path_a = tmp_path / "a.stn" - path_b = tmp_path / "b.stn" - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) - assert converter._process_results([path_a, path_b]) == path_a - - def test_directory_mode_returns_input_path( + written = tmp_path / "a.stn" + written.touch() + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) + assert converter._process_results([written]) == (written,) + + def test_leaves_out_a_job_that_wrote_nothing( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - converter = ReconstructionConverter(config, tmp_path, is_file=False) - assert converter._process_results([tmp_path / "a.stn"]) == tmp_path + """A source the loader has no reader for names its output and writes none, so the run + reports only what a reader can open.""" + written = tmp_path / "a.stn" + written.touch() + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) + assert converter._process_results([written, tmp_path / "missing.stn"]) == (written,) class TestReconstructionConverterNotifyProgress: - def test_current_file_set_after_first_completion( + def test_current_file_names_the_job_that_completed( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - path_a = tmp_path / "a.wav" - path_b = tmp_path / "b.wav" - converter = ReconstructionConverter(config, tmp_path, is_file=False) - converter.audio_files = [path_a, path_b] - converter.total_tasks = 2 + (tmp_path / "a.wav").touch() + (tmp_path / "b.wav").touch() + converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems)) + with patch(_RECONSTRUCTOR_PATCH): + converter._create_tasks() + + converter.total_tasks = len(converter.jobs) converter.completed_tasks = 1 converter._notify_progress() - assert converter.current_file == str(path_a) - converter._notify_progress() - assert converter.current_file == str(path_a) + assert converter.current_file == str(converter.jobs[0].sources[0]) From 8e189dd214a42211505fc1ab0ce3a5d6e7b2d107 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 00:24:22 +0200 Subject: [PATCH 041/142] Modelled: the converter's stems setup --- .../constants/conversion.py | 5 + .../coordinators/tabs/main.py | 3 +- .../logic/main/converter.py | 166 ++++++++++++++++- .../logic/main/stems.py | 79 ++++++++ .../ui/panels/main/converter.py | 5 +- .../view_model/main/converter.py | 41 +++- .../logic/main/test_converter.py | 176 +++++++++++++++++- .../logic/main/test_stems.py | 113 +++++++++++ .../view_model/main/test_converter.py | 67 ++++++- 9 files changed, 642 insertions(+), 13 deletions(-) create mode 100644 src/sampletones_application/constants/conversion.py create mode 100644 src/sampletones_application/logic/main/stems.py create mode 100644 tests/unit/sampletones_application/logic/main/test_stems.py diff --git a/src/sampletones_application/constants/conversion.py b/src/sampletones_application/constants/conversion.py new file mode 100644 index 000000000..471eaab9f --- /dev/null +++ b/src/sampletones_application/constants/conversion.py @@ -0,0 +1,5 @@ +from typing import Final + +MAX_STEM_SOURCES: Final[int] = 8 +DEFAULT_STEM_LEVEL: Final[int] = 1 +MIN_CHANNEL_CAP: Final[int] = 1 diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index e3ffa70b8..a043ad17e 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -249,6 +249,7 @@ def __init__( self._converter_logic.on_load_directory = on_load_directory self._converter_logic.on_cancelled = on_cancelled self._converter_logic.generate_library = on_generate_library + config_manager.add_config_change_callback(self._converter_logic.refresh_view) library_manager.on_generation_progress_extra = conversion_service.forward_library_progress self._converter_panel.on_convert_requested = self._converter_logic.start_conversion @@ -267,7 +268,7 @@ def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: def _on_wave_file_clicked(self, filepath: Path) -> None: if not self._is_operation_active(): - self._converter_logic.set_input_path(filepath, convert=False) + self._converter_logic.select_source(filepath) def _on_directory_clicked(self, directory_path: Path) -> None: if not self._is_operation_active(): diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index b9f2212d5..6c2747fe4 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -1,10 +1,16 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Optional, Protocol, Tuple +from typing import Callable, FrozenSet, List, Optional, Protocol, Sequence, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.constants.conversion import ( + DEFAULT_STEM_LEVEL, + MAX_STEM_SOURCES, + MIN_CHANNEL_CAP, +) from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.main.stems import StemSource, derive_stems_config, effective_channels from sampletones_application.services.result import ( ConversionResult, ServiceCancelled, @@ -20,14 +26,18 @@ ACTIVE_PHASES, ConversionPhase, ConverterViewModel, + StemSourceRow, ) from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.parallelization import ETAEstimator, TaskProgress from sampletones_core.reconstructions.converter import ( ConversionPlan, DirectoryConversion, GroupConversion, get_output_path, + group_output_path, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoFilesToProcessError @@ -96,6 +106,10 @@ def __init__( self._output_path: Optional[Path] = None self._written: Tuple[Path, ...] = () self._is_file: bool = True + self._stems_mode: bool = False + self._sources: List[StemSource] = [] + self._channel_cap: int = len(ChannelName) + self._hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE self._system_progress = SystemProgress() self._service.subscribe(self._on_service_result) @@ -140,6 +154,74 @@ def set_input_path(self, input_path: Path, convert: bool = False) -> None: if convert: self.start_conversion() + def select_source(self, path: Path) -> None: + """Answers a recording picked in the explorer: it joins the list, or becomes the input. + + In stems mode a pick adds to the setup being built, so a reader gathers a conversion by + clicking the recordings it mixes. Otherwise it is the single thing to convert. + """ + if self._stems_mode: + self.add_sources([path]) + return + + self.set_input_path(path) + + def add_sources(self, paths: Sequence[Path]) -> None: + """Adds recordings to the stems list, up to the room it has left. + + A path already listed keeps the row it has, so adding it again leaves the setup as it is. + """ + enabled = frozenset(self._config_manager.config.generation.channels) + listed = {source.path for source in self._sources} + for path in paths: + if path in listed or len(self._sources) >= MAX_STEM_SOURCES: + continue + + self._sources.append(StemSource(path=path, channels=enabled, level=DEFAULT_STEM_LEVEL)) + listed.add(path) + + self._refresh_setup() + + def remove_source(self, path: Path) -> None: + """Takes a recording out of the stems list.""" + self._sources = [source for source in self._sources if source.path != path] + self._refresh_setup() + + def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: + """Names the channels one recording may take.""" + self._replace_source(path, lambda source: source.with_channels(channels)) + + def set_source_level(self, path: Path, level: int) -> None: + """Names the level one recording picks on.""" + self._replace_source(path, lambda source: source.with_level(max(level, DEFAULT_STEM_LEVEL))) + + def set_stems_mode(self, stems_mode: bool) -> None: + """Switches between converting one selection and mixing several recordings into one. + + Entering stems mode carries a selected file in as the first row. Leaving it keeps the + first row as the single selection, which is what the reader picked first. + """ + if stems_mode == self._stems_mode: + return + + self._stems_mode = stems_mode + if stems_mode: + self._enter_stems_mode() + else: + self._leave_stems_mode() + + self._refresh_setup() + + def set_channel_cap(self, channel_cap: int) -> None: + """Names how many channels one recording may hold in a frame, for every conversion.""" + self._channel_cap = min(max(channel_cap, MIN_CHANNEL_CAP), self._max_channel_cap()) + self._refresh_setup() + + def set_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: + """Names how the levels take turns: round by round, or one level exhausted before the next.""" + self._hierarchy_mode = hierarchy_mode + self._refresh_setup() + def start_conversion(self) -> None: if self._is_operation_active(): logger.warning("A conversion or library generation is already in progress") @@ -275,14 +357,82 @@ def _start_conversion(self) -> None: self._service.start(config, self._conversion_plan(config, self._input_path)) def _conversion_plan(self, config: Config, input_path: Path) -> ConversionPlan: - """What the request amounts to: one reconstruction from the selected file, or one per - audio file the selected directory holds.""" - stems = StemsConfig.single_entry(list(config.generation.channels)) + """What the request amounts to: one reconstruction from the recordings listed or the file + selected, or one per audio file the selected directory holds.""" + stems = self._stems_setup(config) + if self._stems_mode: + return GroupConversion(sources=self._source_paths, stems=stems) + if self._is_file: return GroupConversion(sources=(input_path,), stems=stems) return DirectoryConversion(directory=input_path, stems=stems) + def _stems_setup(self, config: Config) -> StemsConfig: + """The setup the conversion runs under: the rows a reader listed, or one stem over every + enabled channel where none were listed. Either way it carries the channel cap.""" + enabled = list(config.generation.channels) + if self._stems_mode and self._sources: + return derive_stems_config( + self._sources, + enabled, + channel_cap=self._effective_channel_cap, + hierarchy_mode=self._hierarchy_mode, + ) + + return StemsConfig.single_entry(enabled, channel_cap=self._effective_channel_cap) + + @property + def _source_paths(self) -> Tuple[Path, ...]: + return tuple(source.path for source in self._sources) + + @property + def _effective_channel_cap(self) -> int: + """The cap a run holds to: what the reader asked for, within the channels now enabled.""" + return min(self._channel_cap, self._max_channel_cap()) + + def _max_channel_cap(self) -> int: + return max(len(self._config_manager.config.generation.channels), MIN_CHANNEL_CAP) + + def _replace_source(self, path: Path, change: Callable[[StemSource], StemSource]) -> None: + """Rewrites one row of the stems list, leaving the others where they are.""" + self._sources = [change(source) if source.path == path else source for source in self._sources] + self._refresh_setup() + + def _enter_stems_mode(self) -> None: + enabled = frozenset(self._config_manager.config.generation.channels) + if not self._sources and self._input_path is not None and self._is_file: + self._sources = [StemSource(path=self._input_path, channels=enabled, level=DEFAULT_STEM_LEVEL)] + + def _leave_stems_mode(self) -> None: + if self._sources: + self._sources = self._sources[:1] + self._assign_paths(self._sources[0].path, self._config_manager.config) + + def _refresh_setup(self) -> None: + """Follows the setup wherever it changed: the destination it now names, and the view.""" + self._update_stems_output_path() + if not self.is_active: + self._phase = ConversionPhase.IDLE + self._emit_view_model(self._msg_idle, 0.0) + + def _update_stems_output_path(self) -> None: + if not self._stems_mode or not self._sources: + return + + self._output_path = group_output_path(self._config_manager.config, self._source_paths) + + def _stem_rows(self, config: Config) -> Tuple[StemSourceRow, ...]: + enabled = list(config.generation.channels) + return tuple( + StemSourceRow( + path=source.path, + channels=frozenset(effective_channels(source, enabled)), + level=source.level, + ) + for source in self._sources + ) + def _on_conversion_complete(self, written: Tuple[Path, ...]) -> None: self._written = written if len(written) == 1: @@ -337,6 +487,7 @@ def _emit_view_model( progress: float, input_path: Optional[Path] = None, ) -> None: + config = self._config_manager.config display_output = ( self._output_path if self._output_path is not None else self._config_manager.get_reconstructions_directory() ) @@ -350,5 +501,12 @@ def _emit_view_model( output_path=display_output, is_file=self._is_file, other_operation_active=self._is_operation_active(), + stems_mode=self._stems_mode, + stem_sources=self._stem_rows(config), + enabled_channels=frozenset(config.generation.channels), + channel_cap=self._effective_channel_cap, + max_channel_cap=self._max_channel_cap(), + hierarchy_mode=self._hierarchy_mode, + max_sources=MAX_STEM_SOURCES, ) self.call(self.on_view_changed, view_model) diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py new file mode 100644 index 000000000..8e05cc6aa --- /dev/null +++ b/src/sampletones_application/logic/main/stems.py @@ -0,0 +1,79 @@ +from collections import defaultdict +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Dict, FrozenSet, List, Self, Sequence + +from sampletones_application.constants.conversion import DEFAULT_STEM_LEVEL +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy + + +@dataclass(frozen=True) +class StemSource: + """One row of a stems conversion: a recording, the channels it may take, and when it picks. + + The level orders the picking: every source on the lowest level chooses before any source on + the next, which is how a reader puts a lead ahead of a pad. + """ + + path: Path + channels: FrozenSet[ChannelName] + level: int = DEFAULT_STEM_LEVEL + + def with_channels(self, channels: FrozenSet[ChannelName]) -> Self: + return replace(self, channels=channels) + + def with_level(self, level: int) -> Self: + return replace(self, level=level) + + +def effective_channels( + source: StemSource, + enabled_channels: Sequence[ChannelName], +) -> List[ChannelName]: + """The channels a source may take in the run being set up, in the order the run enables them. + + A source keeps whichever of its channels the configuration still enables. One left holding + none takes every enabled channel, so a source always has somewhere to sound. + """ + kept = [channel_name for channel_name in enabled_channels if channel_name in source.channels] + return kept if kept else list(enabled_channels) + + +def derive_stems_config( + sources: Sequence[StemSource], + enabled_channels: Sequence[ChannelName], + *, + channel_cap: int, + hierarchy_mode: HierarchyMode, +) -> StemsConfig: + """Turns the rows a reader set up into the stems setup a conversion runs under. + + A row's position in the list is its stem id, which is what the conversion records per frame + and what a stem selection later reads back. Rows sharing a level pick together, and the + levels follow their numbers upwards. + """ + return StemsConfig( + entries=[ + StemEntry(id=stem_id, channels=effective_channels(source, enabled_channels)) + for stem_id, source in enumerate(sources) + ], + hierarchy=_hierarchy(sources, hierarchy_mode), + channel_cap=channel_cap, + ) + + +def _hierarchy( + sources: Sequence[StemSource], + hierarchy_mode: HierarchyMode, +) -> StemsHierarchy: + grouped: Dict[int, List[int]] = defaultdict(list) + for stem_id, source in enumerate(sources): + grouped[source.level].append(stem_id) + + return StemsHierarchy( + levels=[grouped[level] for level in sorted(grouped)], + mode=hierarchy_mode, + ) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index a5de9f920..8e8b380ae 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -102,11 +102,10 @@ def update_view(self, view_model: ConverterViewModel) -> None: self._update_controls(view_model) def _update_visibility(self, view_model: ConverterViewModel) -> None: - has_input = view_model.input_path is not None dpg.configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_SUMMARY, show=not view_model.subpanel_visible) - dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not has_input) - dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=has_input) + dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not view_model.has_input) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=view_model.has_input) def _update_status(self, view_model: ConverterViewModel) -> None: dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STATUS, view_model.status_text) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 89d5e1e31..9b4119144 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -1,10 +1,11 @@ from enum import StrEnum from pathlib import Path -from typing import Final, FrozenSet, Optional +from typing import Final, FrozenSet, Optional, Tuple from pydantic import BaseModel from sampletones_application.view_model.shared.percent import format_percent +from sampletones_core.constants.enums import ChannelName, HierarchyMode class ConversionPhase(StrEnum): @@ -37,6 +38,18 @@ class ConverterAction(StrEnum): ) +class StemSourceRow(BaseModel, frozen=True): + """One recording in the converter's stems list, as the panel renders it.""" + + path: Path + channels: FrozenSet[ChannelName] + level: int + + @property + def name(self) -> str: + return self.path.name + + class ConverterViewModel(BaseModel, frozen=True): """ An immutable snapshot of converter state that defines what the panel is allowed to know. @@ -54,6 +67,13 @@ class ConverterViewModel(BaseModel, frozen=True): output_path: Optional[Path] is_file: bool other_operation_active: bool + stems_mode: bool + stem_sources: Tuple[StemSourceRow, ...] + enabled_channels: FrozenSet[ChannelName] + channel_cap: int + max_channel_cap: int + hierarchy_mode: HierarchyMode + max_sources: int @property def progress_overlay(self) -> str: @@ -68,9 +88,26 @@ def is_active(self) -> bool: def subpanel_visible(self) -> bool: return self.phase != ConversionPhase.IDLE + @property + def has_input(self) -> bool: + """Something is selected to convert: a listed recording in stems mode, a path otherwise.""" + if self.stems_mode: + return bool(self.stem_sources) + + return self.input_path is not None + + @property + def source_count(self) -> int: + return len(self.stem_sources) + + @property + def can_add_source(self) -> bool: + """The list has room for another recording.""" + return self.source_count < self.max_sources + @property def convert_button_enabled(self) -> bool: - return self.phase == ConversionPhase.IDLE and self.input_path is not None and not self.other_operation_active + return self.phase == ConversionPhase.IDLE and self.has_input and not self.other_operation_active @property def primary_action(self) -> ConverterAction: diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 00872caf6..6f7625ae8 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -4,6 +4,7 @@ import pytest +from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP from sampletones_application.logic.main.converter import ( ConversionSuccess, ConverterLogic, @@ -14,6 +15,7 @@ ConverterViewModel, ) from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from tests.suite.language import FakeLanguageManager @@ -371,12 +373,14 @@ def test_a_directory_becomes_a_directory_conversion(self, converter_logic: Conve assert plan.directory == Path("/audio") def test_the_setup_covers_every_enabled_channel(self, converter_logic: ConverterLogic) -> None: - """With no stems chosen, one stem holds every channel the configuration enables.""" + """With no stems listed, one stem holds every channel the configuration enables.""" config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) + channels = list(config.generation.channels) plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - assert plan.stems == StemsConfig.single_entry(list(config.generation.channels)) + assert plan.stems == StemsConfig.single_entry(channels, channel_cap=len(channels)) + assert plan.stems.covered_channels == frozenset(channels) def test_starting_hands_the_plan_to_the_service(self, converter_logic: ConverterLogic) -> None: config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) @@ -387,3 +391,171 @@ def test_starting_hands_the_plan_to_the_service(self, converter_logic: Converter started_config, started_plan = converter_logic._service.start.call_args.args assert started_config == config assert isinstance(started_plan, GroupConversion) + + +class TestStemsSetup: + """The rows a reader gathers, and the setup they turn into.""" + + def _with_config(self, converter_logic: ConverterLogic) -> Config: + config = Config() + converter_logic._config_manager.config = config + return config + + def test_selecting_a_recording_in_stems_mode_adds_it(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + + converter_logic.select_source(Path("/audio/bass.wav")) + converter_logic.select_source(Path("/audio/lead.wav")) + + assert converter_logic._source_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav")) + + def test_adding_a_listed_recording_leaves_the_list_as_it_is(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/bass.wav")]) + + converter_logic.set_source_level(Path("/audio/bass.wav"), 3) + converter_logic.add_sources([Path("/audio/bass.wav")]) + + assert converter_logic._source_paths == (Path("/audio/bass.wav"),) + assert converter_logic._sources[0].level == 3 + + def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + + converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES + 3)]) + + assert len(converter_logic._sources) == MAX_STEM_SOURCES + + def test_removing_a_recording_takes_it_out(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + + converter_logic.remove_source(Path("/audio/a.wav")) + + assert converter_logic._source_paths == (Path("/audio/b.wav"),) + + def test_entering_stems_mode_carries_the_selected_file_in(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + converter_logic._input_path = Path("/audio/kick.wav") + converter_logic._is_file = True + + converter_logic.set_stems_mode(True) + + assert converter_logic._source_paths == (Path("/audio/kick.wav"),) + + def test_leaving_stems_mode_keeps_the_first_recording(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + + converter_logic.set_stems_mode(False) + + assert converter_logic._source_paths == (Path("/audio/a.wav"),) + + def test_a_stems_conversion_groups_every_listed_recording(self, converter_logic: ConverterLogic) -> None: + config = self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + + plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) + + assert isinstance(plan, GroupConversion) + assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) + assert [entry.id for entry in plan.stems.entries] == [0, 1] + + def test_the_rows_channels_and_levels_reach_the_setup(self, converter_logic: ConverterLogic) -> None: + config = self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.PULSE1})) + converter_logic.set_source_level(Path("/audio/b.wav"), 2) + + plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) + + assert plan.stems.entries[0].channels == [ChannelName.PULSE1] + assert plan.stems.hierarchy.levels == [[0], [1]] + + def test_the_cap_holds_within_the_channels_enabled(self, converter_logic: ConverterLogic) -> None: + config = self._with_config(converter_logic) + channels = list(config.generation.channels) + + converter_logic.set_channel_cap(len(channels) + 5) + + assert converter_logic._effective_channel_cap == len(channels) + + def test_a_cap_below_one_is_refused(self, converter_logic: ConverterLogic) -> None: + self._with_config(converter_logic) + + converter_logic.set_channel_cap(0) + + assert converter_logic._effective_channel_cap == MIN_CHANNEL_CAP + + def test_the_cap_reaches_a_classic_conversion_too(self, converter_logic: ConverterLogic) -> None: + """One recording per frame is a choice a reader makes for every conversion, batch included.""" + config = self._with_config(converter_logic) + converter_logic._input_path = Path("/audio/kick.wav") + converter_logic._is_file = True + converter_logic.set_channel_cap(1) + + plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) + + assert plan.stems.channel_cap == 1 + + def test_the_hierarchy_mode_reaches_the_setup(self, converter_logic: ConverterLogic) -> None: + config = self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav")]) + + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) + assert plan.stems.hierarchy.mode == HierarchyMode.STRICT + + +class TestStemsView: + """What the panel is told about the setup being built.""" + + def _emitted(self, converter_logic: ConverterLogic) -> ConverterViewModel: + return converter_logic.on_view_changed.call_args.args[0] + + def test_the_rows_reach_the_view_in_list_order(self, converter_logic: ConverterLogic) -> None: + converter_logic._config_manager.config = Config() + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + + view_model = self._emitted(converter_logic) + + assert [row.name for row in view_model.stem_sources] == ["a.wav", "b.wav"] + assert view_model.stems_mode is True + assert view_model.has_input is True + + def test_a_row_shows_the_channels_it_may_take(self, converter_logic: ConverterLogic) -> None: + converter_logic._config_manager.config = Config() + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav")]) + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE})) + + assert self._emitted(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE}) + + def test_the_view_states_whether_another_recording_fits(self, converter_logic: ConverterLogic) -> None: + converter_logic._config_manager.config = Config() + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES)]) + + view_model = self._emitted(converter_logic) + + assert view_model.source_count == MAX_STEM_SOURCES + assert view_model.can_add_source is False + + def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: ConverterLogic) -> None: + converter_logic._config_manager.config = Config() + converter_logic.set_stems_mode(True) + + view_model = self._emitted(converter_logic) + + assert view_model.has_input is False + assert view_model.convert_button_enabled is False diff --git a/tests/unit/sampletones_application/logic/main/test_stems.py b/tests/unit/sampletones_application/logic/main/test_stems.py new file mode 100644 index 000000000..4fd7b48d7 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/test_stems.py @@ -0,0 +1,113 @@ +from pathlib import Path +from typing import FrozenSet, List + +from sampletones_application.constants.conversion import DEFAULT_STEM_LEVEL +from sampletones_application.logic.main.stems import ( + StemSource, + derive_stems_config, + effective_channels, +) +from sampletones_core.constants.enums import ChannelName, HierarchyMode + +ENABLED: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] + + +def _source(name: str, channels: FrozenSet[ChannelName], level: int = DEFAULT_STEM_LEVEL) -> StemSource: + return StemSource(path=Path(f"/audio/{name}.wav"), channels=channels, level=level) + + +class TestEffectiveChannels: + def test_a_source_keeps_the_channels_still_enabled(self) -> None: + source = _source("lead", frozenset({ChannelName.PULSE1, ChannelName.PULSE2})) + assert effective_channels(source, ENABLED) == [ChannelName.PULSE1] + + def test_a_source_left_with_none_takes_every_enabled_channel(self) -> None: + source = _source("lead", frozenset({ChannelName.PULSE2})) + assert effective_channels(source, ENABLED) == ENABLED + + def test_channels_follow_the_order_the_run_enables_them(self) -> None: + source = _source("lead", frozenset({ChannelName.NOISE, ChannelName.PULSE1})) + assert effective_channels(source, ENABLED) == [ChannelName.PULSE1, ChannelName.NOISE] + + +class TestDeriveStemsConfig: + def test_a_rows_position_is_its_stem_id(self) -> None: + sources = [_source("a", frozenset(ENABLED)), _source("b", frozenset(ENABLED))] + + setup = derive_stems_config( + sources, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert [entry.id for entry in setup.entries] == [0, 1] + + def test_rows_sharing_a_level_pick_together(self) -> None: + sources = [ + _source("a", frozenset(ENABLED), level=1), + _source("b", frozenset(ENABLED), level=2), + _source("c", frozenset(ENABLED), level=1), + ] + + setup = derive_stems_config( + sources, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.hierarchy.levels == [[0, 2], [1]] + + def test_levels_follow_their_numbers_upwards_with_the_gaps_closed(self) -> None: + """Levels the reader left unused hold no rows, so the hierarchy names the ones in use.""" + sources = [ + _source("a", frozenset(ENABLED), level=5), + _source("b", frozenset(ENABLED), level=2), + ] + + setup = derive_stems_config( + sources, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert setup.hierarchy.levels == [[1], [0]] + + def test_the_cap_and_the_mode_reach_the_setup(self) -> None: + setup = derive_stems_config( + [_source("a", frozenset(ENABLED))], + ENABLED, + channel_cap=2, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert setup.channel_cap == 2 + assert setup.hierarchy.mode == HierarchyMode.ROUND_ROBIN + + def test_a_disabled_channel_leaves_the_setup(self) -> None: + setup = derive_stems_config( + [_source("a", frozenset({ChannelName.PULSE1, ChannelName.PULSE2}))], + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.entries[0].channels == [ChannelName.PULSE1] + assert setup.covered_channels == frozenset({ChannelName.PULSE1}) + + def test_the_hierarchy_names_every_row(self) -> None: + """The setup validates that its hierarchy names each stem once, so a derivation that + dropped one would be refused rather than stored.""" + sources = [_source(name, frozenset(ENABLED), level=level) for name, level in (("a", 1), ("b", 3), ("c", 3))] + + setup = derive_stems_config( + sources, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + named = [stem_id for level in setup.hierarchy.levels for stem_id in level] + assert sorted(named) == [entry.id for entry in setup.entries] diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 7a1f61aca..29ef23201 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -1,12 +1,24 @@ from pathlib import Path +from typing import Final, FrozenSet, Optional, Tuple import pytest +from sampletones_application.constants.conversion import MAX_STEM_SOURCES from sampletones_application.view_model.main.converter import ( ConversionPhase, ConverterAction, ConverterViewModel, + StemSourceRow, ) +from sampletones_core.constants.enums import ChannelName, HierarchyMode + +ENABLED_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( + {ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE} +) + + +def _row(name: str, level: int = 1) -> StemSourceRow: + return StemSourceRow(path=Path(f"/audio/{name}.wav"), channels=ENABLED_CHANNELS, level=level) def _view_model( @@ -14,16 +26,28 @@ def _view_model( phase: ConversionPhase, other_operation_active: bool = False, progress: float = 0.0, + input_path: Optional[Path] = Path("/audio/sample.wav"), + stems_mode: bool = False, + stem_sources: Tuple[StemSourceRow, ...] = (), + channel_cap: int = len(ENABLED_CHANNELS), + max_sources: int = MAX_STEM_SOURCES, ) -> ConverterViewModel: return ConverterViewModel( phase=phase, status_text="", action_label="", progress=progress, - input_path=Path("/audio/sample.wav"), + input_path=input_path, output_path=Path("/reconstructions"), is_file=True, other_operation_active=other_operation_active, + stems_mode=stems_mode, + stem_sources=stem_sources, + enabled_channels=ENABLED_CHANNELS, + channel_cap=channel_cap, + max_channel_cap=len(ENABLED_CHANNELS), + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + max_sources=max_sources, ) @@ -105,3 +129,44 @@ def test_convert_disabled_in_terminal_phases(self, phase: ConversionPhase) -> No def test_convert_enabled_when_idle_with_input(self) -> None: assert _view_model(phase=ConversionPhase.IDLE).primary_action_enabled is True + + +class TestStemsSection: + """In stems mode the listed recordings are what there is to convert, and the list has a bound.""" + + def test_a_listed_recording_counts_as_an_input(self) -> None: + view_model = _view_model( + phase=ConversionPhase.IDLE, + input_path=None, + stems_mode=True, + stem_sources=(_row("bass"),), + ) + + assert view_model.has_input is True + assert view_model.source_count == 1 + assert view_model.convert_button_enabled is True + + def test_an_empty_list_offers_nothing_to_convert(self) -> None: + view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True) + + assert view_model.has_input is False + assert view_model.convert_button_enabled is False + + def test_the_selected_path_carries_a_classic_conversion(self) -> None: + view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=False) + + assert view_model.has_input is True + + def test_a_full_list_takes_no_more(self) -> None: + rows = tuple(_row(str(index)) for index in range(MAX_STEM_SOURCES)) + view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True, stem_sources=rows) + + assert view_model.can_add_source is False + + def test_a_list_with_room_takes_another(self) -> None: + view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True, stem_sources=(_row("bass"),)) + + assert view_model.can_add_source is True + + def test_a_row_names_itself_by_its_file(self) -> None: + assert _row("bass").name == "bass.wav" From 68c8bcd339523495814f5970a69d8127ad2cd1ea Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 00:47:47 +0200 Subject: [PATCH 042/142] Added: stems mode to the converter card --- src/sampletones_application/application.py | 12 + .../categories/elements/main.py | 22 ++ .../coordinators/tabs/main.py | 53 ++++ .../layout/tabs/main/converter.py | 8 + .../logic/main/converter.py | 31 +- src/sampletones_application/tags/main.py | 107 +++++++ .../ui/panels/dialogs/stem_selection.py | 140 +++++++++ .../ui/panels/main/converter.py | 267 +++++++++++++++++- .../ui/panels/main/explorer.py | 7 + src/sampletones_config/lang/en.yaml | 22 ++ .../layout/tabs/main/converter.yaml | 8 + .../reconstructions/converter/__init__.py | 2 + .../converter/paths/__init__.py | 4 + .../reconstructions/converter/paths/utils.py | 15 + .../coordinators/tabs/test_main.py | 128 +++++++++ .../sampletones_application/test_startup.py | 54 ++++ .../converter/paths/test_utils.py | 24 ++ 17 files changed, 901 insertions(+), 3 deletions(-) create mode 100644 src/sampletones_application/ui/panels/dialogs/stem_selection.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6b9230eb8..368f88c39 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -107,6 +107,7 @@ GUIProjectPropertiesWindow, ) from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow +from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -287,6 +288,16 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self.stem_selection_window: GUIStemSelectionWindow = GUIStemSelectionWindow( + layout=self.layout.tabs.main.converter.stem_selection, + title=self.language_manager["main.converter.title.stem_selection_dialog"], + message=self.language_manager["main.converter.message.stem_selection_prompt"], + limit_template=self.language_manager["main.converter.template.stem_selection_limit"], + add_label=self.language_manager["main.converter.label.add_stems_button"], + cancel_label=self.language_manager["global.dialog.label.cancel"], + key_router=self.key_router, + shortcut_source=self._shortcut_source, + ) self.render_window: GUIRenderWindow = GUIRenderWindow( layout=self.layout.settings, path_colors=self.layout.general.colors.paths, @@ -439,6 +450,7 @@ def __init__( on_cancelled=self._refresh_reconstruction_trees, on_refresh_trees=self._refresh_reconstruction_trees, on_generate_library=self._instructions_tab.ensure_library_loaded, + stem_selection_window=self.stem_selection_window, ) self._sequencer_tab = SequencerTabCoordinator( diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 963292a6b..a4bfacc88 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -70,6 +70,28 @@ class ConverterElements(AbstractElement): CANCEL_PROMPT = "cancel_prompt" PROGRESS_TEMPLATE = "progress_template" CONVERT_LABEL_TEMPLATE = "convert_label_template" + STEMS_MODE = "stems_mode" + STEMS_MODE_TOOLTIP = "stems_mode_tooltip" + CHANNEL_CAP = "channel_cap" + CHANNEL_CAP_TOOLTIP = "channel_cap_tooltip" + HIERARCHY_MODE = "hierarchy_mode" + HIERARCHY_MODE_TOOLTIP = "hierarchy_mode_tooltip" + HIERARCHY_ROUND_ROBIN = "hierarchy_round_robin" + HIERARCHY_STRICT = "hierarchy_strict" + STEM_LEVEL = "stem_level" + STEM_REMOVE = "stem_remove" + STEMS_EMPTY_HINT = "stems_empty_hint" + CONVERT_STEMS_BUTTON = "convert_stems_button" + DISCARD_STEMS_DIALOG = "discard_stems_dialog" + DISCARD_STEMS_PROMPT = "discard_stems_prompt" + DISCARD_STEMS_BUTTON = "discard_stems_button" + KEEP_STEMS_BUTTON = "keep_stems_button" + STEM_SELECTION_DIALOG = "stem_selection_dialog" + STEM_SELECTION_PROMPT = "stem_selection_prompt" + STEM_SELECTION_LIMIT = "stem_selection_limit" + ADD_STEMS_BUTTON = "add_stems_button" + STATUS_STEM_REMOVE = "status_stem_remove" + STATUS_STEMS_MODE = "status_stems_mode" class AdvancedElements(AbstractElement): diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index a043ad17e..64f564789 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -32,6 +32,7 @@ TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, TAG_MAIN_CONVERTER_DIALOG_LOAD, TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, @@ -42,6 +43,7 @@ from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns from sampletones_application.ui.elements.layout.responsive import expanded_side_width from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow from sampletones_application.ui.panels.main.advanced import GUIAdvancedSettingsPanel from sampletones_application.ui.panels.main.config import GUIConfigPanel from sampletones_application.ui.panels.main.converter import GUIConverterPanel @@ -62,6 +64,7 @@ ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter import top_level_audio_files from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -106,6 +109,7 @@ def __init__( on_cancelled: VoidCallback, on_refresh_trees: VoidCallback, on_generate_library: VoidCallback, + stem_selection_window: GUIStemSelectionWindow, ) -> None: self._language_manager = language_manager self._config_manager = config_manager @@ -118,6 +122,7 @@ def __init__( self._on_busy_state_changed = on_busy_state_changed self._on_refresh_trees = on_refresh_trees self._dialogs = dialogs + self._stem_selection_window = stem_selection_window self._geometry = layout.geometry self._side_panel_count: int @@ -222,6 +227,7 @@ def __init__( self._explorer_panel.set_callbacks( on_wave_file_clicked=self._on_wave_file_clicked, on_directory_clicked=self._on_directory_clicked, + on_directory_add_requested=self._on_directory_add_requested, on_reconstruct_file=self._request_reconstruct_file, on_reconstruct_directory=self._request_reconstruct_directory, on_load_reconstruction=on_load_reconstruction, @@ -254,6 +260,13 @@ def __init__( self._converter_panel.on_convert_requested = self._converter_logic.start_conversion self._converter_panel.on_cancel_requested = self._request_cancel_confirmation + self._converter_panel.on_stems_mode_changed = self._request_stems_mode + self._converter_panel.on_channel_cap_changed = self._converter_logic.set_channel_cap + self._converter_panel.on_hierarchy_mode_changed = self._converter_logic.set_hierarchy_mode + self._converter_panel.on_source_channels_changed = self._converter_logic.set_source_channels + self._converter_panel.on_source_level_changed = self._converter_logic.set_source_level + self._converter_panel.on_source_removed = self._converter_logic.remove_source + self._stem_selection_window.on_add = self._converter_logic.add_sources def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: """Repaints the row whose star was toggled: the explorer mirrors the disk, so a path is one row.""" @@ -321,6 +334,46 @@ def _on_conversion_success(self, success: ConversionSuccess) -> None: on_cancel=self._converter_logic.close, ) + def _request_stems_mode(self, stems_mode: bool) -> None: + """Answers the stems-mode switch, asking first where leaving it would drop recordings. + + Turning stems mode off keeps the first recording, so a list of several loses the rest; + that is what the prompt confirms. Every other switch takes effect straight away. + """ + if stems_mode or self._converter_logic.source_count <= 1: + self._converter_logic.set_stems_mode(stems_mode) + return + + self._dialogs.show_confirmation( + TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, + self._language_manager["main.converter.message.discard_stems_prompt"], + self._language_manager["main.converter.title.discard_stems_dialog"], + lambda: self._converter_logic.set_stems_mode(False), + ok_label=self._language_manager["main.converter.label.discard_stems_button"], + cancel_label=self._language_manager["main.converter.label.keep_stems_button"], + on_cancel=self._converter_logic.refresh_view, + ) + + def _on_directory_add_requested(self, directory_path: Path) -> None: + """Offers a folder's recordings to a stems conversion, asking which ones where they overflow. + + Where the folder holds no more than the list has room for, every recording joins at once. + A fuller folder raises the selection window, which shows what fits already ticked. + """ + if self._is_operation_active() or not self._converter_logic.stems_mode: + return + + candidates = top_level_audio_files(directory_path) + if not candidates: + return + + room = self._converter_logic.room_for_sources + if len(candidates) <= room: + self._converter_logic.add_sources(candidates) + return + + self._stem_selection_window.open(candidates, room) + def _request_cancel_confirmation(self) -> None: self._dialogs.show_confirmation( TAG_MAIN_CONVERTER_DIALOG_CANCEL, diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index 64dc2a5ba..89334ca2a 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -1,7 +1,15 @@ from pydantic import BaseModel +from sampletones_application.layout.primitives import Dimensions + class ConverterLayout(BaseModel, extra="forbid", frozen=True): width: int height: int button_height: int + stems_list_height: int + cap_input_width: int + hierarchy_combo_width: int + level_input_width: int + remove_button_width: int + stem_selection: Dimensions diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 6c2747fe4..12dfa0e37 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -126,6 +126,21 @@ def __init__( self.cancel_library_generation: Optional[VoidCallback] = None self.is_library_available: Optional[Callable[[], bool]] = None + @property + def stems_mode(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self._stems_mode + + @property + def source_count(self) -> int: + """How many recordings the stems list holds.""" + return len(self._sources) + + @property + def room_for_sources(self) -> int: + """How many more recordings the stems list has room for.""" + return MAX_STEM_SOURCES - self.source_count + @property def is_active(self) -> bool: """A conversion is occupying resources from the moment of request (the WAITING phase, during @@ -467,10 +482,13 @@ def _schedule_return_to_idle(self) -> None: def _compose_action_label(self, input_path: Optional[Path]) -> str: """The label the single action button shows: the cancel label while a conversion holds - resources, otherwise the convert label named after the selected input.""" + resources, otherwise the convert label named after what it would convert.""" if self._phase in ACTIVE_PHASES: return self._language_manager["main.converter.label.cancel_button"] + if self._stems_mode: + return self._compose_stems_action_label() + base = ( self._language_manager["main.converter.label.convert_sample_button"] if self._is_file @@ -481,6 +499,17 @@ def _compose_action_label(self, input_path: Optional[Path]) -> str: return self._language_manager["main.converter.template.convert_label_template"].format(base, input_path.name) + def _compose_stems_action_label(self) -> str: + """The stems label, named after how many recordings are gathered.""" + base = self._language_manager["main.converter.label.convert_stems_button"] + if not self._sources: + return base + + return self._language_manager["main.converter.template.convert_label_template"].format( + base, + len(self._sources), + ) + def _emit_view_model( self, status_text: str, diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 973135f9a..fc848cf8b 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -1,5 +1,6 @@ from sampletones_application.categories.hierarchy import Page, Panel, Widget from sampletones_application.categories.key.tag import TagName +from sampletones_application.tags.compose import compose_tag TAG_MAIN_CONFIG_PANEL_CONFIG_CELL = TagName( Page.MAIN, @@ -249,3 +250,109 @@ ) PRE_MAIN_RECONSTRUCTOR_CHANNEL = "channel" +TAG_MAIN_CONVERTER_GROUP_CONTROLS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.GROUP, + "controls", +) +TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.CHECKBOX, + "stems_mode", +) +TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.INPUT, + "channel_cap", +) +TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.COMBO, + "hierarchy_mode", +) +TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TOOLTIP, + "stems_mode", +) +TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TOOLTIP, + "channel_cap", +) +TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TOOLTIP, + "hierarchy_mode", +) +TAG_MAIN_CONVERTER_WINDOW_STEMS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.WINDOW, + "stems", +) +TAG_MAIN_CONVERTER_GROUP_STEMS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.GROUP, + "stems", +) +TAG_MAIN_CONVERTER_TEXT_STEMS_HINT = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TEXT, + "stems_hint", +) +TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.DIALOG, + "discard_stems", +) +TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.WINDOW, + "stem_selection", +) +PRE_MAIN_CONVERTER_STEM = compose_tag( + Page.MAIN, + Panel.CONVERTER, + "stem", +) +TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TEXT, + "stem_selection_limit", +) +TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.GROUP, + "stem_selection", +) +TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.BUTTON, + "add_stems", +) +TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.BUTTON, + "cancel_stems", +) +PRE_MAIN_CONVERTER_CANDIDATE = compose_tag( + Page.MAIN, + Panel.CONVERTER, + "candidate", +) diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py new file mode 100644 index 000000000..effe092eb --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -0,0 +1,140 @@ +from pathlib import Path +from typing import Any, Callable, Final, List, Optional, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.primitives import Dimensions +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, + TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, + TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +ADD_FOCUS_STOP: Final[int] = 1 + + +class GUIStemSelectionWindow(GUIDialogWindow): + """A modal offering the recordings a folder holds, with the ones that fit already ticked. + + A folder can hold more recordings than one conversion has room for, so the reader is shown + what was found and which of it fits: the first ones up to the room left arrive ticked, the + rest stand disabled beneath a line stating the limit. What comes back is the reader's choice. + """ + + def __init__( + self, + *, + layout: Dimensions, + title: str, + message: str, + limit_template: str, + add_label: str, + cancel_label: str, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._title = title + self._message = message + self._limit_template = limit_template + self._add_label = add_label + self._cancel_label = cancel_label + self._candidates: Tuple[Path, ...] = () + self._room = 0 + + self.on_add: Optional[Callable[[List[Path]], None]] = None + + super().__init__( + tag=TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, + width=layout.width, + height=layout.height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open(self, candidates: Sequence[Path], room: int) -> None: + """Shows the recordings found, ticking as many as the conversion still has room for.""" + self._candidates = tuple(candidates) + self._room = room + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The candidates and the room left are seeded by :meth:`open` before the tree rebuilds.""" + + def create_window(self) -> None: + with self.dialog_window(label=self._title, on_close=None): + dpg.add_text(self._message, wrap=self.width) + dpg.add_text(self._limit_text(), tag=TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) + dpg.add_separator() + with dpg.child_window( + tag=TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION, + width=-1, + height=-self.height // 4, + border=False, + ): + self._create_candidate_rows() + + dpg.add_separator() + self._create_action_buttons() + + self._install_navigation( + [ + FocusStop.button(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, self.hide), + FocusStop.button(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, self._add), + ], + on_escape=self.hide, + initial_index=ADD_FOCUS_STOP, + ) + + def _create_candidate_rows(self) -> None: + for index, candidate in enumerate(self._candidates): + fits = index < self._room + dpg.add_checkbox( + label=candidate.name, + tag=self._candidate_tag(candidate), + default_value=fits, + enabled=fits, + ) + + @table_wrapper(columns=2) + def _create_action_buttons(self) -> None: + GUIButton( + tag=TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, + label=self._cancel_label, + callback=self.hide, + width=-1, + ) + GUIButton( + tag=TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + label=self._add_label, + callback=self._add, + width=-1, + ) + + def _limit_text(self) -> str: + return self._limit_template.format(self._room, len(self._candidates)) + + def _selected(self) -> List[Path]: + return [ + candidate + for candidate in self._candidates + if dpg.does_item_exist(self._candidate_tag(candidate)) and dpg.get_value(self._candidate_tag(candidate)) + ] + + def _add(self) -> None: + selected = self._selected() + self.hide() + self.call(self.on_add, selected) + + @staticmethod + def _candidate_tag(candidate: Path) -> str: + return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, str(candidate)) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 8e8b380ae..c056a19ad 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -1,27 +1,48 @@ -from typing import Any, Optional +from pathlib import Path +from typing import Any, Callable, Dict, FrozenSet, List, Optional import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.conversion import DEFAULT_STEM_LEVEL, MIN_CHANNEL_CAP from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.tabs.main.converter import ConverterLayout +from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_GROUP, + SUF_INPUT, + SUF_TEXT, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_PANEL_EMPHASIS, TAG_GLOBAL_THEME_PRIMARY_BUTTON, ) from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_STEM, TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, TAG_MAIN_CONVERTER_GROUP, + TAG_MAIN_CONVERTER_GROUP_CONTROLS, TAG_MAIN_CONVERTER_GROUP_CONVERT, + TAG_MAIN_CONVERTER_GROUP_STEMS, TAG_MAIN_CONVERTER_GROUP_SUMMARY, + TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_CONVERTER_PATH_INPUT_PATH, TAG_MAIN_CONVERTER_PROGRESS, TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, TAG_MAIN_CONVERTER_TEXT_STATUS, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, + TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE, + TAG_MAIN_CONVERTER_WINDOW_STEMS, TAG_MAIN_CONVERTER_WINDOW_SUMMARY, ) from sampletones_application.ui.elements.button import GUIButton @@ -34,6 +55,7 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( dpg_configure_item, + dpg_delete_item, dpg_set_item_callback, dpg_set_value, ) @@ -41,8 +63,11 @@ from sampletones_application.view_model.main.converter import ( ConverterAction, ConverterViewModel, + StemSourceRow, ) -from sampletones_shared.types.callback import VoidCallback +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import PathCallback, VoidCallback class GUIConverterPanel(GUIPanel): @@ -65,6 +90,12 @@ def __init__( self.on_convert_requested: Optional[VoidCallback] = None self.on_cancel_requested: Optional[VoidCallback] = None + self.on_stems_mode_changed: Optional[Callable[[bool], None]] = None + self.on_channel_cap_changed: Optional[Callable[[int], None]] = None + self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None + self.on_source_channels_changed: Optional[Callable[[Path, FrozenSet[ChannelName]], None]] = None + self.on_source_level_changed: Optional[Callable[[Path, int], None]] = None + self.on_source_removed: Optional[PathCallback] = None self._layout = layout self._path_colors = path_colors @@ -72,6 +103,12 @@ def __init__( self._msg_destination = language_manager["global.status.message.destination"] self._msg_status_convert = language_manager["main.converter.message.status_convert"] self._status_action_message = self._msg_status_convert + self._hierarchy_labels: Dict[HierarchyMode, str] = { + HierarchyMode.ROUND_ROBIN: language_manager["main.converter.label.hierarchy_round_robin"], + HierarchyMode.STRICT: language_manager["main.converter.label.hierarchy_strict"], + } + self._hierarchy_modes: List[HierarchyMode] = list(self._hierarchy_labels) + self._stem_rows: List[Path] = [] super().__init__( tag=TAG_MAIN_CONVERTER_PANEL, @@ -89,6 +126,8 @@ def create_panel(self, parent: str) -> None: ): self._create_action_button() dpg.add_separator() + self._create_controls() + self._create_stems_list() self._create_summary() self._create_conversion_status() @@ -100,6 +139,7 @@ def update_view(self, view_model: ConverterViewModel) -> None: self._update_status(view_model) self._update_paths(view_model) self._update_controls(view_model) + self._update_setup(view_model) def _update_visibility(self, view_model: ConverterViewModel) -> None: dpg.configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) @@ -142,6 +182,229 @@ def _update_controls(self, view_model: ConverterViewModel) -> None: show=view_model.other_operation_active and view_model.primary_action == ConverterAction.CONVERT, ) + def _create_controls(self) -> None: + """The row of choices every conversion carries: stems mode, the cap, and the picking order.""" + with dpg.group(horizontal=True, tag=TAG_MAIN_CONVERTER_GROUP_CONTROLS): + dpg.add_checkbox( + label=self._language_manager["main.converter.label.stems_mode"], + tag=TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, + callback=self._on_stems_mode_toggled, + ) + dpg.add_input_int( + label=self._language_manager["main.converter.label.channel_cap"], + tag=TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + width=self._layout.cap_input_width, + min_value=MIN_CHANNEL_CAP, + min_clamped=True, + max_clamped=True, + step=1, + step_fast=1, + callback=self._on_channel_cap_changed, + ) + + with dpg.group(horizontal=True): + dpg.add_combo( + items=list(self._hierarchy_labels.values()), + label=self._language_manager["main.converter.label.hierarchy_mode"], + tag=TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + width=self._layout.hierarchy_combo_width, + default_value=self._hierarchy_labels[HierarchyMode.ROUND_ROBIN], + callback=self._on_hierarchy_mode_changed, + ) + + self._attach_control_tooltips() + self._status_bar.bind_to_item( + TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, + self._language_manager["main.converter.message.status_stems_mode"], + ) + + def _attach_control_tooltips(self) -> None: + for tag, message, tooltip_tag in ( + ( + TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, + self._language_manager["main.converter.message.stems_mode_tooltip"], + TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE, + ), + ( + TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + self._language_manager["main.converter.message.channel_cap_tooltip"], + TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, + ), + ( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + self._language_manager["main.converter.message.hierarchy_mode_tooltip"], + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + ), + ): + with dpg.tooltip(tag, tag=tooltip_tag): + dpg.add_text(message) + + def _create_stems_list(self) -> None: + """The recordings gathered so far, one row each, scrolling when the list outgrows its space.""" + with dpg.child_window( + tag=TAG_MAIN_CONVERTER_WINDOW_STEMS, + width=-1, + height=self._layout.stems_list_height, + border=False, + show=False, + ): + hint = dpg.add_text( + self._language_manager["main.converter.message.stems_empty_hint"], + tag=TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + wrap=0, + ) + FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) + dpg.add_group(tag=TAG_MAIN_CONVERTER_GROUP_STEMS) + + def _update_setup(self, view_model: ConverterViewModel) -> None: + dpg_set_value(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, view_model.stems_mode) + dpg_configure_item( + TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + max_value=view_model.max_channel_cap, + enabled=not view_model.is_active, + ) + dpg_set_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, view_model.channel_cap) + dpg_set_value( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + self._hierarchy_labels[view_model.hierarchy_mode], + ) + dpg_configure_item(TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, show=view_model.stems_mode) + dpg_configure_item(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, enabled=not view_model.is_active) + self._update_stems_list(view_model) + + def _update_stems_list(self, view_model: ConverterViewModel) -> None: + dpg_configure_item( + TAG_MAIN_CONVERTER_WINDOW_STEMS, + show=view_model.stems_mode and not view_model.subpanel_visible, + ) + dpg_configure_item( + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + show=view_model.source_count == 0, + ) + self._sync_stem_rows(view_model) + for row in view_model.stem_sources: + self._render_stem_row(row, view_model) + + self.set_expanded_height(self._card_height(view_model)) + + def _card_height(self, view_model: ConverterViewModel) -> int: + """The room the card takes: its own, plus the list's where the list is on show.""" + if view_model.stems_mode and not view_model.subpanel_visible: + return self._layout.height + self._layout.stems_list_height + + return self._layout.height + + def _sync_stem_rows(self, view_model: ConverterViewModel) -> None: + """Rebuilds the rows when the recordings change, keeps them otherwise.""" + listed = [row.path for row in view_model.stem_sources] + if listed == self._stem_rows: + return + + for path in self._stem_rows: + dpg_delete_item(self._row_tag(path, SUF_GROUP)) + + self._stem_rows = listed + for row in view_model.stem_sources: + self._create_stem_row(row, view_model) + + def _create_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: + with dpg.group( + horizontal=True, + tag=self._row_tag(row.path, SUF_GROUP), + parent=TAG_MAIN_CONVERTER_GROUP_STEMS, + ): + name = dpg.add_text(row.name, tag=self._row_tag(row.path, SUF_TEXT)) + FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) + with dpg.tooltip(name): + dpg.add_text(str(row.path)) + + for channel_name in ChannelName.items(): + dpg.add_checkbox( + label=channel_label(self._language_manager, channel_name), + tag=self._channel_tag(row.path, channel_name), + show=channel_name in view_model.enabled_channels, + default_value=channel_name in row.channels, + user_data=row.path, + callback=self._on_source_channels_changed, + ) + + dpg.add_input_int( + label=self._language_manager["main.converter.label.stem_level"], + tag=self._row_tag(row.path, SUF_INPUT), + width=self._layout.level_input_width, + min_value=DEFAULT_STEM_LEVEL, + min_clamped=True, + step=1, + step_fast=1, + default_value=row.level, + user_data=row.path, + callback=self._on_source_level_changed, + ) + remove = dpg.add_button( + label=self._language_manager["main.converter.label.stem_remove"], + tag=self._row_tag(row.path, SUF_BUTTON), + width=self._layout.remove_button_width, + user_data=row.path, + callback=self._on_source_removed, + ) + self._status_bar.bind_to_item( + remove, + self._language_manager["main.converter.message.status_stem_remove"], + ) + + def _render_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: + for channel_name in ChannelName.items(): + tag = self._channel_tag(row.path, channel_name) + dpg_configure_item( + tag, + show=channel_name in view_model.enabled_channels, + enabled=not view_model.is_active, + ) + dpg_set_value(tag, channel_name in row.channels) + + dpg_set_value(self._row_tag(row.path, SUF_INPUT), row.level) + dpg_configure_item(self._row_tag(row.path, SUF_INPUT), enabled=not view_model.is_active) + dpg_configure_item(self._row_tag(row.path, SUF_BUTTON), enabled=not view_model.is_active) + + def _on_stems_mode_toggled(self, _sender: Sender, value: bool) -> None: + self.call(self.on_stems_mode_changed, value) + + def _on_channel_cap_changed(self, _sender: Sender, value: int) -> None: + self.call(self.on_channel_cap_changed, value) + + def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: + for hierarchy_mode, label in self._hierarchy_labels.items(): + if label == value: + self.call(self.on_hierarchy_mode_changed, hierarchy_mode) + return + + def _on_source_channels_changed(self, _sender: Sender, _value: bool, user_data: Path) -> None: + channels = frozenset( + channel_name + for channel_name in ChannelName.items() + if dpg.get_value(self._channel_tag(user_data, channel_name)) + ) + self.call(self.on_source_channels_changed, user_data, channels) + + def _on_source_level_changed(self, _sender: Sender, value: int, user_data: Path) -> None: + self.call(self.on_source_level_changed, user_data, value) + + def _on_source_removed(self, _sender: Sender, _app_data: Any, user_data: Path) -> None: + self.call(self.on_source_removed, user_data) + + @staticmethod + def _row_tag(path: Path, suffix: str) -> str: + return compose_tag(PRE_MAIN_CONVERTER_STEM, str(path), suffix) + + @classmethod + def _channel_tag(cls, path: Path, channel_name: ChannelName) -> str: + return compose_tag( + PRE_MAIN_CONVERTER_STEM, + str(path), + SUF_CHANNELS, + compose_tag(channel_name, SUF_CHECKBOX), + ) + def _create_action_button(self) -> None: self._theme_convert = ThemeRegistry.get(TAG_GLOBAL_THEME_PRIMARY_BUTTON) self._theme_cancel = ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON) diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 45da542df..41339e46f 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -24,6 +24,7 @@ from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import ( FileSystemNode, @@ -93,6 +94,7 @@ def __init__( self.on_wave_file_clicked: Optional[PathCallback] = None self.on_directory_clicked: Optional[PathCallback] = None + self.on_directory_add_requested: Optional[PathCallback] = None self.on_reconstruct_directory: Optional[PathCallback] = None self.on_reconstruct_file: Optional[PathCallback] = None self.on_load_reconstruction: Optional[PathCallback] = None @@ -331,10 +333,15 @@ def _directory_node_clicked( node: FileSystemNode, node_tag: str, ) -> None: + """Answers a click on a folder: Ctrl offers its recordings, a plain click opens it.""" has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: return + if Modifier.CTRL in capture_modifiers(): + self.call(self.on_directory_add_requested, node.filepath) + return + self._toggle_directory_expansion(node, node_tag) self.call(self.on_directory_clicked, node.filepath) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index ed9bc3418..2def3a138 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -353,6 +353,28 @@ main.converter.message.load_directory_prompt: "The reconstructions are ready. Op main.converter.message.cancel_prompt: "Stop the current reconstruction?" main.converter.template.progress_template: "Progress: {}/{} files" main.converter.template.convert_label_template: "{}: {}" +main.converter.label.stems_mode: "Stems mode" +main.converter.label.channel_cap: "Channels per source" +main.converter.label.hierarchy_mode: "Order" +main.converter.label.hierarchy_round_robin: "Round robin" +main.converter.label.hierarchy_strict: "Strict" +main.converter.label.stem_level: "Level" +main.converter.label.stem_remove: "x" +main.converter.label.convert_stems_button: "Convert stems" +main.converter.label.discard_stems_button: "Keep the first" +main.converter.label.keep_stems_button: "Stay in stems mode" +main.converter.label.add_stems_button: "Add" +main.converter.message.stems_mode_tooltip: "Mix several recordings into one reconstruction, each holding the channels you give it." +main.converter.message.channel_cap_tooltip: "How many channels one recording may hold in a single frame." +main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round; strict fills a level before the next one picks." +main.converter.message.stems_empty_hint: "Click recordings in the browser to gather the sources of one reconstruction." +main.converter.message.discard_stems_prompt: "Leaving stems mode keeps the first recording and drops the rest. Continue?" +main.converter.message.stem_selection_prompt: "Pick the recordings to add." +main.converter.message.status_stem_remove: "Take this recording out of the conversion." +main.converter.message.status_stems_mode: "Mix several recordings into one reconstruction." +main.converter.title.discard_stems_dialog: "Leave stems mode?" +main.converter.title.stem_selection_dialog: "Add recordings" +main.converter.template.stem_selection_limit: "Room for {} more of the {} recordings found." # ============================================================================= # Main tab — Advanced diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index 955e82339..7479bf958 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -1,3 +1,11 @@ width: -1 height: 180 button_height: 45 +stems_list_height: 150 +cap_input_width: 60 +hierarchy_combo_width: 120 +level_input_width: 60 +remove_button_width: 24 +stem_selection: + width: 420 + height: 360 diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py index ef2264a02..d40870f70 100644 --- a/src/sampletones_core/reconstructions/converter/__init__.py +++ b/src/sampletones_core/reconstructions/converter/__init__.py @@ -8,6 +8,7 @@ get_output_path, get_relative_path, group_output_path, + top_level_audio_files, ) from .plan import ConversionPlan, DirectoryConversion, GroupConversion @@ -24,4 +25,5 @@ "get_relative_path", "group_output_path", "reconstruct_job", + "top_level_audio_files", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index 32e362fc2..4cb6f7fb1 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -6,6 +6,8 @@ get_audio_files, get_output_path, get_relative_path, + group_output_path, + top_level_audio_files, ) __all__ = [ @@ -14,4 +16,6 @@ "get_audio_files", "get_output_path", "get_relative_path", + "group_output_path", + "top_level_audio_files", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 84f3ca76f..4a2269790 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -75,6 +75,21 @@ def get_audio_files( return audio_files +def top_level_audio_files( + input_directory: Path, + extensions: Tuple[str, ...] = EXT_FILES_AUDIO, +) -> List[Path]: + """The audio files sitting directly in a directory, in name order. + + Where a batch reaches every recording below a folder, gathering the sources of one + reconstruction stays with the folder a reader pointed at, so what it offers is what that + folder itself holds. + """ + audio_files = [path for path in input_directory.iterdir() if path.is_file() and path.suffix.lower() in extensions] + audio_files.sort() + return audio_files + + def filter_files( audio_files: List[Path], base_directory: Path, diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index d1a1769d9..8eddaaa47 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -2,6 +2,7 @@ from typing import Final from unittest.mock import MagicMock +from sampletones_application.constants.conversion import MAX_STEM_SOURCES from sampletones_application.coordinators.tabs.main import MainTabCoordinator from sampletones_application.logic.main.converter import ConversionSuccess from sampletones_application.tags.main import ( @@ -156,3 +157,130 @@ def test_cancel_request_confirms_before_stopping(self) -> None: assert args[3] == coordinator._converter_logic.cancel assert kwargs["ok_label"] == STOP_BUTTON_KEY assert kwargs["cancel_label"] == CONTINUE_BUTTON_KEY + + +DISCARD_STEMS_PROMPT_KEY: Final[str] = "main.converter.message.discard_stems_prompt" +DISCARD_STEMS_BUTTON_KEY: Final[str] = "main.converter.label.discard_stems_button" +KEEP_STEMS_BUTTON_KEY: Final[str] = "main.converter.label.keep_stems_button" + + +def _stems_coordinator( + *, + operation_active: bool = False, + stems_mode: bool = True, + source_count: int = 0, + room: int = MAX_STEM_SOURCES, +) -> MainTabCoordinator: + coordinator = MainTabCoordinator.__new__(MainTabCoordinator) + coordinator._is_operation_active = lambda: operation_active + coordinator._dialogs = MagicMock() + coordinator._language_manager = FakeLanguageManager() + coordinator._converter_logic = MagicMock() + coordinator._converter_logic.stems_mode = stems_mode + coordinator._converter_logic.source_count = source_count + coordinator._converter_logic.room_for_sources = room + coordinator._stem_selection_window = MagicMock() + return coordinator + + +class TestStemsModeSwitch: + """Leaving stems mode drops every recording but the first, so a list of several asks first.""" + + def test_entering_stems_mode_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(stems_mode=False) + + coordinator._request_stems_mode(True) + + coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_leaving_with_one_recording_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(source_count=1) + + coordinator._request_stems_mode(False) + + coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_leaving_with_several_recordings_asks_first(self) -> None: + coordinator = _stems_coordinator(source_count=3) + + coordinator._request_stems_mode(False) + + coordinator._converter_logic.set_stems_mode.assert_not_called() + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[1] == DISCARD_STEMS_PROMPT_KEY + assert kwargs["ok_label"] == DISCARD_STEMS_BUTTON_KEY + assert kwargs["cancel_label"] == KEEP_STEMS_BUTTON_KEY + + def test_confirming_the_prompt_leaves_stems_mode(self) -> None: + coordinator = _stems_coordinator(source_count=3) + + coordinator._request_stems_mode(False) + args, _ = coordinator._dialogs.show_confirmation.call_args + args[3]() + + coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) + + def test_declining_the_prompt_repaints_the_checkbox(self) -> None: + """The checkbox already moved when it was clicked, so declining restores what stands.""" + coordinator = _stems_coordinator(source_count=3) + + coordinator._request_stems_mode(False) + _, kwargs = coordinator._dialogs.show_confirmation.call_args + kwargs["on_cancel"]() + + coordinator._converter_logic.set_stems_mode.assert_not_called() + coordinator._converter_logic.refresh_view.assert_called_once_with() + + +class TestDirectoryAdd: + """Ctrl-clicking a folder offers its recordings; a folder that overflows the list asks which.""" + + def test_a_folder_that_fits_is_added_whole(self, tmp_path: Path) -> None: + (tmp_path / "a.wav").touch() + (tmp_path / "b.wav").touch() + coordinator = _stems_coordinator(room=MAX_STEM_SOURCES) + + coordinator._on_directory_add_requested(tmp_path) + + added = coordinator._converter_logic.add_sources.call_args.args[0] + assert {path.name for path in added} == {"a.wav", "b.wav"} + coordinator._stem_selection_window.open.assert_not_called() + + def test_a_folder_that_overflows_raises_the_selection(self, tmp_path: Path) -> None: + for index in range(3): + (tmp_path / f"{index}.wav").touch() + coordinator = _stems_coordinator(room=2) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.add_sources.assert_not_called() + candidates, room = coordinator._stem_selection_window.open.call_args.args + assert len(candidates) == 3 + assert room == 2 + + def test_a_folder_holding_no_recordings_is_left_alone(self, tmp_path: Path) -> None: + (tmp_path / "notes.txt").write_text("not audio") + coordinator = _stems_coordinator() + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.add_sources.assert_not_called() + coordinator._stem_selection_window.open.assert_not_called() + + def test_a_classic_conversion_ignores_the_gesture(self, tmp_path: Path) -> None: + (tmp_path / "a.wav").touch() + coordinator = _stems_coordinator(stems_mode=False) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.add_sources.assert_not_called() + + def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: + (tmp_path / "a.wav").touch() + coordinator = _stems_coordinator(operation_active=True) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.add_sources.assert_not_called() diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index e236cf1bf..992c2abfa 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -12,6 +12,9 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.tags.general import SUF_BUTTON, SUF_GROUP, SUF_INPUT +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_WINDOW_STEMS +from sampletones_application.ui.panels.main.converter import GUIConverterPanel from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, @@ -399,3 +402,54 @@ def test_the_key_puts_its_tab_on_screen(self, app: Application, tab: Tab) -> Non def test_the_key_answers_while_a_field_is_edited(self, app: Application, tab: Tab) -> None: """Naming a tab reaches it the way stepping to the next one does, typing included.""" assert app._shortcut_source.shortcut(TAB_SHORTCUT_IDS[tab]).field_transparent + + +class TestConverterStemsCard: + """Gathering recordings paints the converter card: a row each, carrying what the reader set.""" + + def _gather(self, app: Application, tmp_path: Path, names: List[str]) -> List[Path]: + paths = [] + for name in names: + path = tmp_path / name + path.touch() + paths.append(path) + + converter_logic = app._main_tab._converter_logic + converter_logic.set_stems_mode(True) + converter_logic.add_sources(paths) + return paths + + def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Path) -> None: + paths = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + + for path in paths: + assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_GROUP)) + assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_INPUT)) + assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_BUTTON)) + + def test_a_rows_level_and_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None: + path = self._gather(app, tmp_path, ["a.wav"])[0] + converter_logic = app._main_tab._converter_logic + + converter_logic.set_source_level(path, 3) + converter_logic.set_source_channels(path, frozenset({ChannelName.NOISE})) + + assert dpg.get_value(GUIConverterPanel._row_tag(path, SUF_INPUT)) == 3 + assert dpg.get_value(GUIConverterPanel._channel_tag(path, ChannelName.NOISE)) is True + assert dpg.get_value(GUIConverterPanel._channel_tag(path, ChannelName.PULSE1)) is False + + def test_removing_a_recording_takes_its_row_with_it(self, app: Application, tmp_path: Path) -> None: + first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + + app._main_tab._converter_logic.remove_source(first) + + assert not dpg.does_item_exist(GUIConverterPanel._row_tag(first, SUF_GROUP)) + assert dpg.does_item_exist(GUIConverterPanel._row_tag(second, SUF_GROUP)) + + def test_leaving_stems_mode_hides_the_list(self, app: Application, tmp_path: Path) -> None: + self._gather(app, tmp_path, ["a.wav"]) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True + + app._main_tab._converter_logic.set_stems_mode(False) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is False diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index 6955b77ce..04f32ab78 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -9,6 +9,8 @@ get_audio_files, get_output_path, get_relative_path, + group_output_path, + top_level_audio_files, ) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @@ -115,3 +117,25 @@ def test_excludes_files_with_existing_output(self, tmp_path: Path) -> None: output_file.touch() result = filter_files([audio_file], tmp_path, output_directory) assert result == [] + + +class TestTopLevelAudioFiles: + """Gathering the sources of one reconstruction stays with the folder that was pointed at.""" + + def test_reports_the_audio_files_the_folder_itself_holds(self, tmp_path: Path) -> None: + (tmp_path / "b.wav").touch() + (tmp_path / "a.wav").touch() + (tmp_path / "notes.txt").write_text("not audio") + + assert [path.name for path in top_level_audio_files(tmp_path)] == ["a.wav", "b.wav"] + + def test_a_nested_recording_stays_where_it_is(self, tmp_path: Path) -> None: + nested = tmp_path / "nested" + nested.mkdir() + (nested / "deep.wav").touch() + (tmp_path / "a.wav").touch() + + assert [path.name for path in top_level_audio_files(tmp_path)] == ["a.wav"] + + def test_a_folder_of_nothing_reports_nothing(self, tmp_path: Path) -> None: + assert top_level_audio_files(tmp_path) == [] From 72fc480140a49ba19e3a99c761fb9fbf58565573 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 02:03:28 +0200 Subject: [PATCH 043/142] Documented: the stems conversion pipeline --- CHANGELOG.md | 154 +++++++++++++++--------------- docs/concepts/stems.md | 127 ++++++++++++++++++------ docs/development/compatibility.md | 7 ++ docs/formats/reconstructions.md | 23 +++-- docs/guide/interface.md | 24 ++++- 5 files changed, 218 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3cdbd3c..1fb2c66e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,76 +1,78 @@ -# SampleToNES - -## v0.3.2 - -* Added NSF player and export. -* Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`. - -## v0.3.1 [2026-08-18] - -* Added support to [Bitphase](https://github.com/paator/bitphase). -* Fixed arpeggio editing shifting a sample's pitch permanently. -* Enhanced application options: - * Display settings - * Theme selector - * Keybinding settings -* Bumped the reconstruction data-version to `2.1`. -* Improved Sequencer module playback. -* Added song export to WAV/MP3. -* Added tracker selection operations. -* Added a _SampleToNES_ logo. - -## v0.3.0 [2026-07-31] - -* Added a _Sequencer_ view with FamiTracker-style patterns. -* Added project export in a FamiTracker-compatible format. -* Improved matching algorithms and extended available methods (`LogFFT`, `CQT`). -* Improved the general layout of the application. -* Changed the internal file formats (`.stn`, `.ins`). -* Switched to `uv` as the package manager. -* Detect the NVIDIA driver at setup and install the matching _CuPy_ build automatically, on Linux and Windows. - -## v0.2.3 [2026-01-09] - -* Improved the application's graphical interface. -* Added the main page with filesystem explorer. -* Added editing and saving reconstructions. -* Added audio settings panel. -* Implemented instructions library autogeneration. -* Simplified instructions library tree. - -## v0.2.2 [2025-11-21] - -* Added GPU support via _CuPy_. -* Fixed generation bugs. -* Made minor visual improvements. -* Improved code quality. -* Released the application. - -## v0.2.1 [2025-11-17] - -* Optimized the output file structure. -* Improved application error handling. -* Enhanced task processing communication with the GUI. -* Fixed installer bugs. -* Created a Python package and an application installer via _PyInstaller_. - -## v0.2.0 [2025-11-08] - -* Created a graphical interface for the application. -* Added application content: - * Instruction instruction data explorer - * Audio reconstruction viewer -* Included instruction data creation and converter windows. -* Added audio graphs and spectrum plots. -* Implemented audio playback. - -## v0.1.0 [2025-10-23] - -* Added spectral features and FFT windows for the sample approximator. -* Included mixer levels for adjusting the general amplitude of sound. -* Created an instruction instruction data for reconstruction optimization. -* Added instruction data and generation configurations. - -## v0.0.1 [2025-09-24] - -First version of _SampleToNES_, containing basic reconstruction scripts and all 2A03 generators. +# SampleToNES + +## v0.3.2 + +* Added NSF player and export. +* Added stems conversion: mix several recordings into one reconstruction +* Added a per-source channel cap +* Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`. + +## v0.3.1 [2026-08-18] + +* Added support to [Bitphase](https://github.com/paator/bitphase). +* Fixed arpeggio editing shifting a sample's pitch permanently. +* Enhanced application options: + * Display settings + * Theme selector + * Keybinding settings +* Bumped the reconstruction data-version to `2.1`. +* Improved Sequencer module playback. +* Added song export to WAV/MP3. +* Added tracker selection operations. +* Added a _SampleToNES_ logo. + +## v0.3.0 [2026-07-31] + +* Added a _Sequencer_ view with FamiTracker-style patterns. +* Added project export in a FamiTracker-compatible format. +* Improved matching algorithms and extended available methods (`LogFFT`, `CQT`). +* Improved the general layout of the application. +* Changed the internal file formats (`.stn`, `.ins`). +* Switched to `uv` as the package manager. +* Detect the NVIDIA driver at setup and install the matching _CuPy_ build automatically, on Linux and Windows. + +## v0.2.3 [2026-01-09] + +* Improved the application's graphical interface. +* Added the main page with filesystem explorer. +* Added editing and saving reconstructions. +* Added audio settings panel. +* Implemented instructions library autogeneration. +* Simplified instructions library tree. + +## v0.2.2 [2025-11-21] + +* Added GPU support via _CuPy_. +* Fixed generation bugs. +* Made minor visual improvements. +* Improved code quality. +* Released the application. + +## v0.2.1 [2025-11-17] + +* Optimized the output file structure. +* Improved application error handling. +* Enhanced task processing communication with the GUI. +* Fixed installer bugs. +* Created a Python package and an application installer via _PyInstaller_. + +## v0.2.0 [2025-11-08] + +* Created a graphical interface for the application. +* Added application content: + * Instruction instruction data explorer + * Audio reconstruction viewer +* Included instruction data creation and converter windows. +* Added audio graphs and spectrum plots. +* Implemented audio playback. + +## v0.1.0 [2025-10-23] + +* Added spectral features and FFT windows for the sample approximator. +* Included mixer levels for adjusting the general amplitude of sound. +* Created an instruction instruction data for reconstruction optimization. +* Added instruction data and generation configurations. + +## v0.0.1 [2025-09-24] + +First version of _SampleToNES_, containing basic reconstruction scripts and all 2A03 generators. diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index 97d40b4ef..f9d5a7d11 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -11,19 +11,31 @@ A stems reconstruction converts several audio stems at once. The stems are mixed and the mix is matched against the instruction library; within each frame, the channels are handed to the stems one pick at a time, following a precedence hierarchy. The result is one reconstruction whose `stems_data` records, per -channel and frame, which stem's stream plays — the record multisample playback -will read in the future. +channel and frame, which stem's stream plays. ## Principles -### 1. The mix is the target +### 1. Every conversion is a stems conversion + +Below the conversion job there is one pipeline and one entry point. A job names +the recordings it mixes, the stems setup that hands their channels out, and the +file it writes; a conversion from a single file is the job whose setup holds one +stem over every enabled channel. What the reader chose stays above that line: +the application decides how many jobs a request makes and what setup each +carries, and a batch is many single-source jobs rather than a mode of its own. + +This is what lets a channel cap, a hierarchy and a per-source channel set reach +every conversion alike, and what keeps the classic run from being a second path +that has to be kept in step. + +### 2. The mix is the target Every stem is loaded and normalized on its own, padded to the longest stem's length, and summed. Frames and residuals come from the mix; a stem's own audio takes no separate part in matching. The working-level coefficient is computed from the mix, exactly as for a single file. -### 2. One greedy pick at a time +### 3. One greedy pick at a time A pick scores each eligible stem's candidates against the current residual with the same two-stage criterion the single-sample pipeline uses (`FrameMatcher`), @@ -33,7 +45,32 @@ channel is assigned, or caps and free channels are exhausted. Matching against the residual is what keeps later picks from re-approximating content earlier picks already cover. -### 3. Precedence orders, mode alternates +### 4. A frame is answered whole + +Every channel the setup covers leaves a frame either picked or **resting**. A +resting channel holds its channel's null instruction over a silent frame and +records the resting stem id, so instruction streams, rendered approximations and +the per-frame stem record all run parallel to the frames they describe: frame +*i* of a channel is frame *i* of the recording. A channel that rests through +every frame stands by instead, carrying no stream at all. + +This is what makes a channel cap and a hierarchy usable. Without it, a frame a +cap left unclaimed would shorten that channel's streams and carry its later +frames early, so what the channel plays would drift out of step with the +recording it was matched against. + +### 5. Ownership and decoding compose + +The assignment answers *which stem owns which channel this frame*; the decoder +answers *what that channel plays across frames*. Each pick leaves the channel it +won a column of candidates, as wide as the configured decoder reads, and the +decoder chooses one candidate per frame from those columns — greedily, or along +the lowest-cost path through the whole lattice. A resting frame reaches the +decoder as a column of one, so a channel a cap left free sits in the path as the +off state it is. See [Reconstruction §5](reconstruction.md) for the decoders +themselves. + +### 6. Precedence orders, mode alternates The hierarchy groups stem ids into levels that pick in the listed order. In `strict` mode a level exhausts its stems' channel caps before the next level @@ -41,41 +78,75 @@ picks; in `round_robin` mode the levels take turns, granting every level's stems one channel per round. Both modes let every stem hold at most `channel_cap` channels per frame. -### 4. Ties resolve deterministically +### 7. Ties resolve deterministically Equal-cost choices go to the stem earlier in level order. Channels of one kind resolve to the lowest free channel, so successive picks over one kind land on the lowest free channel and a rerun assigns the same way every time. -### 5. The single-sample case stays exact +### 8. The single-sample case stays exact One stem covering every enabled channel, with a cap at the channel count, -reproduces the greedy baseline pick for pick. Property tests hold the two paths -to identical choices, instructions, and approximations, so the stems assignment -generalizes the existing pipeline without changing it. +reproduces the classic greedy reconstruction pick for pick. Property tests hold +the assignment against an independent restatement of that reconstruction — +identical choices, instructions, and approximations — so the one pipeline serves +the single-sample case exactly as it stands. -## Mechanics +### 9. The working level follows the frame budget + +A frame reaches as loud as the channels that may sound in it, so the level the +mix is scaled to is measured against the mixer weights of the loudest covered +channels, as many of them as one frame holds: -The assignment lives in `sampletones_core.reconstructions.reconstructor.stems`: +``` +budget = min(covered channels, stems x channel cap) +``` + +A capped run therefore targets a level its channels can actually render, and a +setup whose budget covers every channel measures against the same total the +single-sample pipeline always did. + +## Mechanics -- `Stem` names a competing source and the channels it may occupy; -- `StemHierarchy` carries the precedence levels and the mode; -- `assign_frame` runs one frame's picks against a residual, using the shared - `FrameMatcher` and `FeatureExtractor` of the pipeline; -- `Reconstructor.reconstruct_stems` loads the stems, mixes them, runs - `assign_frame` per frame, and records the outcome. +A request becomes jobs through `reconstructions.converter`: a `ConversionPlan` +answers with the `ConversionJob`s it divides into, resolved against the +configuration the run uses. `GroupConversion` mixes the recordings it is given +into one job, and `DirectoryConversion` scans a folder into one single-source job +per audio file. `ReconstructionConverter` runs those jobs across its worker pool +and reports the reconstructions written. + +`StemsConfig` (`reconstructor/stems/configs/`) is the setup: the entries with +their ids and channels, the precedence hierarchy and its mode, and the channel +cap. It validates its own consistency — unique ids, a hierarchy naming every +entry exactly once, a cap of at least one — so an inconsistent setup can be +neither built nor stored, and it derives the views the run reads (`entries_by_id`, +`covered_channels`, `frame_budget`). + +The assignment lives in `reconstructor/stems/assignment/`: + +- `assign_frame` validates the setup against the run's channels and answers one + frame whole: the picks in the order they were made, each with its candidate + column, together with the channels left resting; +- `AssignmentSession` carries one frame's progress — the residual, the free + channels, the per-stem counts — and runs the hierarchy's mode; +- `TrackAssignment` gathers the frames into what the rest of the run reads: the + lattice each channel offers the decoder, and the stem owning each of its + frames. + +`Reconstructor.reconstruct` loads the sources, mixes them, assigns every frame, +releases the channels that rested throughout, decodes the remaining lattices, +and folds the decoded streams into the state in frame order — the order each +generator's oscillator phase is carried in. The record stored in a reconstruction (`stems_data`) holds the stems setup the -assignment was made under — the entries, the precedence hierarchy, and the -channel cap — and, per channel, the stem id holding each frame, parallel to the -instruction streams. The record is an optional field, so files written before it -existed load without one. - -The stems setup is built per process from the inputs and the user's choices, and -handed to `Reconstructor.reconstruct_stems` together with the stem paths; it is -part of the process rather than of the standard configuration. Per-frame -assignment is greedy for now; Viterbi continuity and playback that decides per -frame on the recorded streams are future work. +assignment was made under and, per channel, the stem id holding each frame, +parallel to the instruction streams. Every reconstruction carries one. + +The stems setup is built per conversion from the sources and the reader's +choices and travels with the job; it is part of the request rather than of the +standard configuration. The assignment is greedy per frame: continuity of *who* +owns a channel across frames, and playback that decides per frame on the +recorded streams, are future work. ## The recorded stems in the application diff --git a/docs/development/compatibility.md b/docs/development/compatibility.md index 1f9671be1..c16c1dde7 100644 --- a/docs/development/compatibility.md +++ b/docs/development/compatibility.md @@ -33,6 +33,13 @@ the transform itself. The steps of one format form a chain, registered in after the version it writes — `compatibility/reconstruction/v2_2.py` carries the step that writes reconstruction data version 2.2. +That step shows the shape a whole step takes: it names each stored stream and +approximation by its channel, names the embedded config's channel selection the +same way and stamps that config with the target version, lists the source audio +as one path per stem, and synthesizes the stems record every 2.2 file carries — +one stem covering every enabled channel and holding every frame the file plays, +which is what the conversion that wrote the file did. + ### A chain applies whole or not at all An upgrade runs only when the registered steps form a complete path from the diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 9f557ba75..c0d463ad7 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -13,9 +13,10 @@ A `.stn` file holds: * **metadata** — the application name and version, and the reconstruction data-version used to check compatibility on load (see [Versioning](#versioning)); * **id** — a unique identifier for the reconstruction; -* **source audio** — the path to the original recording, the stem paths when the - reconstruction was built from several stems, or empty when the reconstruction - is [detached](#detached-reconstructions); +* **source audio** — one path per source recording, in the order the stems setup + lists them: a single path for a conversion from one file, several for a stems + mix, and empty where the reconstruction is + [detached](#detached-reconstructions); * **configuration** — a frozen snapshot of the [generation configuration](../guide/configuration.md) used, so the file records exactly how it was made: sample rate, NES frequency, enabled channels, spectrum @@ -46,10 +47,11 @@ A `.stn` file holds: channel's, and the player keeps the value it already holds for them. A channel in play writes them all as it is built, and clearing an envelope in the instruments panel adds that dimension here; -* **stems assignment** — present when the reconstruction was built from several - stems: the stems setup the assignment was made under and, per channel, the - stem holding each frame (`stems_data`). A reconstruction from a single file - carries none. +* **stems assignment** — the stems setup the reconstruction was built under and, + per channel, the source holding each frame (`stems_data`). Every reconstruction + carries one: a conversion from a single file records one stem covering every + channel it plays. A frame no source took records the resting stem id, `-1`, + which is what a channel sounds where a channel cap or a hierarchy left it free. A channel standing by rests at a reference pitch of its own, so the first envelope written into it sounds on a mid-range note, and it leaves every dimension it offers @@ -78,9 +80,10 @@ is stored alongside the data version, for reference. The current data version is 2.2. Version 2.2 renamed the per-channel stream and approximation keys from `generator_name` to `channel_name` and the channel selection under the embedded config from `generators` to `channels`; the enum -values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. A -reconstruction built from several stems also carries the optional `stems_data` -record; a file written before the record existed reads without one. +values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. It +also records the source audio as one path per stem and carries the `stems_data` +record on every reconstruction; a file written before either existed is read with +its single path listed and a one-stem record synthesized from what it plays. ## Storage and export diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9377a9a91..8c3dd5c28 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -21,9 +21,21 @@ were last working in, and **Collapse all** folds them away again. The [instruction library](../concepts/instruction-library.md) for your settings is built automatically the first time it is needed, so you can convert straight away. While it runs, the panel names the file going in and where the result is going, and -clicking either path shows it in your file manager. When a single file finishes, -**Load** opens the result on the **Reconstructions** tab; **Cancel** stops a run, and -only one runs at a time. +clicking either path shows it in your file manager. When a run writes one +reconstruction, **Load** opens it on the **Reconstructions** tab; a whole folder of +them offers **Open** instead. **Cancel** stops a run, and only one runs at a time. + +**Stems mode** turns the card into a list: tick it, then click each recording you +want mixed into one reconstruction, and Ctrl-click a folder to offer everything in +it at once. Each row names its recording, the channels that recording may use, and +a **Level** — the sources on level 1 choose their channels before those on level 2, +so a lead can take what it needs before a pad does. **Order** decides how the levels +take turns, and **x** takes a row out. Untick **Stems mode** and the first recording +stays as your single selection. + +**Channels per source** caps how many channels one recording may hold in a single +frame, and it applies to every conversion — one file, a whole folder, or a stems +mix. Leaving it at one channel per source gives each recording a single voice. A few settings are worth knowing before you convert. Under **Reconstructor settings**, the **Channels** toggles choose which channels take part — at least @@ -61,6 +73,12 @@ and unticking folds those rows back. click. Whatever you leave open is remembered, so the tree comes back the way you left it the next time you start the application. +A reconstruction mixed from several recordings carries a **Stems** card listing +each of them with the channels it took. Untick one and its frames fall silent +everywhere at once — in the waveform, in playback, in the original audio, and in a +WAV export — so you can hear what each recording contributed. The ticks are yours +for the session; saving records the assignment, never the selection. + To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase presets...** writes the same as `.json`, **NSF program...** writes a single `.nsf` From 01b52a1d4889d427dd1160c13d71beaa144fabc2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 02:23:05 +0200 Subject: [PATCH 044/142] Modelled: the stems setup as an ordered list of levels --- .../categories/elements/main.py | 1 - .../constants/conversion.py | 1 - .../coordinators/tabs/main.py | 1 - .../logic/main/converter.py | 126 +++++---- .../logic/main/stems.py | 252 +++++++++++++++--- .../ui/panels/main/converter.py | 21 +- .../view_model/main/converter.py | 44 ++- src/sampletones_config/lang/en.yaml | 1 - .../logic/main/test_converter.py | 41 ++- .../logic/main/test_stems.py | 215 +++++++++++---- .../sampletones_application/test_startup.py | 10 +- .../view_model/main/test_converter.py | 57 +++- 12 files changed, 586 insertions(+), 184 deletions(-) diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index a4bfacc88..f107e2fb9 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -78,7 +78,6 @@ class ConverterElements(AbstractElement): HIERARCHY_MODE_TOOLTIP = "hierarchy_mode_tooltip" HIERARCHY_ROUND_ROBIN = "hierarchy_round_robin" HIERARCHY_STRICT = "hierarchy_strict" - STEM_LEVEL = "stem_level" STEM_REMOVE = "stem_remove" STEMS_EMPTY_HINT = "stems_empty_hint" CONVERT_STEMS_BUTTON = "convert_stems_button" diff --git a/src/sampletones_application/constants/conversion.py b/src/sampletones_application/constants/conversion.py index 471eaab9f..4f33df8d0 100644 --- a/src/sampletones_application/constants/conversion.py +++ b/src/sampletones_application/constants/conversion.py @@ -1,5 +1,4 @@ from typing import Final MAX_STEM_SOURCES: Final[int] = 8 -DEFAULT_STEM_LEVEL: Final[int] = 1 MIN_CHANNEL_CAP: Final[int] = 1 diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 64f564789..d1f1739cd 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -264,7 +264,6 @@ def __init__( self._converter_panel.on_channel_cap_changed = self._converter_logic.set_channel_cap self._converter_panel.on_hierarchy_mode_changed = self._converter_logic.set_hierarchy_mode self._converter_panel.on_source_channels_changed = self._converter_logic.set_source_channels - self._converter_panel.on_source_level_changed = self._converter_logic.set_source_level self._converter_panel.on_source_removed = self._converter_logic.remove_source self._stem_selection_window.on_add = self._converter_logic.add_sources diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 12dfa0e37..e58a91164 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -1,16 +1,18 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, FrozenSet, List, Optional, Protocol, Sequence, Tuple +from typing import Callable, FrozenSet, Optional, Protocol, Sequence, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.constants.conversion import ( - DEFAULT_STEM_LEVEL, - MAX_STEM_SOURCES, - MIN_CHANNEL_CAP, -) +from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior -from sampletones_application.logic.main.stems import StemSource, derive_stems_config, effective_channels +from sampletones_application.logic.main.stems import ( + ConversionSetup, + StemLevels, + StemSource, + derive_conversion_setup, + effective_channels, +) from sampletones_application.services.result import ( ConversionResult, ServiceCancelled, @@ -107,7 +109,7 @@ def __init__( self._written: Tuple[Path, ...] = () self._is_file: bool = True self._stems_mode: bool = False - self._sources: List[StemSource] = [] + self._levels: StemLevels = StemLevels() self._channel_cap: int = len(ChannelName) self._hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE self._system_progress = SystemProgress() @@ -134,7 +136,7 @@ def stems_mode(self) -> bool: @property def source_count(self) -> int: """How many recordings the stems list holds.""" - return len(self._sources) + return self._levels.count @property def room_for_sources(self) -> int: @@ -187,28 +189,42 @@ def add_sources(self, paths: Sequence[Path]) -> None: A path already listed keeps the row it has, so adding it again leaves the setup as it is. """ enabled = frozenset(self._config_manager.config.generation.channels) - listed = {source.path for source in self._sources} for path in paths: - if path in listed or len(self._sources) >= MAX_STEM_SOURCES: - continue + if self._levels.count >= MAX_STEM_SOURCES: + break - self._sources.append(StemSource(path=path, channels=enabled, level=DEFAULT_STEM_LEVEL)) - listed.add(path) + self._levels = self._levels.add(StemSource(path=path, channels=enabled)) self._refresh_setup() def remove_source(self, path: Path) -> None: """Takes a recording out of the stems list.""" - self._sources = [source for source in self._sources if source.path != path] + self._levels = self._levels.remove(path) self._refresh_setup() def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: """Names the channels one recording may take.""" - self._replace_source(path, lambda source: source.with_channels(channels)) + self._apply(self._levels.replace_source(path, lambda source: source.with_channels(channels))) + + def move_source_within_level(self, path: Path, offset: int) -> None: + """Moves a recording past the neighbour it shares a level with.""" + self._apply(self._levels.move_within_level(path, offset)) + + def join_source_level(self, path: Path, offset: int) -> None: + """Sends a recording to the level above or below the one it picks on.""" + self._apply(self._levels.join_level(path, offset)) + + def isolate_source(self, path: Path) -> None: + """Gives a recording a level of its own, picking after the one it shared.""" + self._apply(self._levels.isolate(path)) + + def move_source_onto(self, path: Path, target_path: Path) -> None: + """Moves a recording to the level and the place another one holds.""" + self._apply(self._levels.move_onto(path, target_path)) - def set_source_level(self, path: Path, level: int) -> None: - """Names the level one recording picks on.""" - self._replace_source(path, lambda source: source.with_level(max(level, DEFAULT_STEM_LEVEL))) + def move_source_to_new_level(self, path: Path, position: int) -> None: + """Gives a recording a level of its own, in the slot the levels are broken at.""" + self._apply(self._levels.move_to_new_level(path, position)) def set_stems_mode(self, stems_mode: bool) -> None: """Switches between converting one selection and mixing several recordings into one. @@ -374,32 +390,39 @@ def _start_conversion(self) -> None: def _conversion_plan(self, config: Config, input_path: Path) -> ConversionPlan: """What the request amounts to: one reconstruction from the recordings listed or the file selected, or one per audio file the selected directory holds.""" - stems = self._stems_setup(config) + setup = self._stems_setup(config) if self._stems_mode: - return GroupConversion(sources=self._source_paths, stems=stems) + return GroupConversion(sources=setup.sources, stems=setup.stems) if self._is_file: - return GroupConversion(sources=(input_path,), stems=stems) + return GroupConversion(sources=(input_path,), stems=setup.stems) - return DirectoryConversion(directory=input_path, stems=stems) + return DirectoryConversion(directory=input_path, stems=setup.stems) - def _stems_setup(self, config: Config) -> StemsConfig: - """The setup the conversion runs under: the rows a reader listed, or one stem over every - enabled channel where none were listed. Either way it carries the channel cap.""" + def _stems_setup(self, config: Config) -> ConversionSetup: + """The recordings and the setup the conversion runs with, carrying the channel cap. + + In stems mode this is what the gathered levels amount to; otherwise it is one stem over + every enabled channel, which is the classic run's shape. + """ enabled = list(config.generation.channels) - if self._stems_mode and self._sources: - return derive_stems_config( - self._sources, + if self._stems_mode: + return derive_conversion_setup( + self._levels, enabled, channel_cap=self._effective_channel_cap, hierarchy_mode=self._hierarchy_mode, ) - return StemsConfig.single_entry(enabled, channel_cap=self._effective_channel_cap) + return ConversionSetup( + sources=(), + stems=StemsConfig.single_entry(enabled, channel_cap=self._effective_channel_cap), + ) @property def _source_paths(self) -> Tuple[Path, ...]: - return tuple(source.path for source in self._sources) + """The recordings that take part, in the order the conversion mixes them.""" + return self._stems_setup(self._config_manager.config).sources @property def _effective_channel_cap(self) -> int: @@ -409,20 +432,20 @@ def _effective_channel_cap(self) -> int: def _max_channel_cap(self) -> int: return max(len(self._config_manager.config.generation.channels), MIN_CHANNEL_CAP) - def _replace_source(self, path: Path, change: Callable[[StemSource], StemSource]) -> None: - """Rewrites one row of the stems list, leaving the others where they are.""" - self._sources = [change(source) if source.path == path else source for source in self._sources] + def _apply(self, levels: StemLevels) -> None: + """Takes up a rewritten stems list and follows it wherever the setup changed.""" + self._levels = levels self._refresh_setup() def _enter_stems_mode(self) -> None: enabled = frozenset(self._config_manager.config.generation.channels) - if not self._sources and self._input_path is not None and self._is_file: - self._sources = [StemSource(path=self._input_path, channels=enabled, level=DEFAULT_STEM_LEVEL)] + if self._levels.count == 0 and self._input_path is not None and self._is_file: + self._levels = self._levels.add(StemSource(path=self._input_path, channels=enabled)) def _leave_stems_mode(self) -> None: - if self._sources: - self._sources = self._sources[:1] - self._assign_paths(self._sources[0].path, self._config_manager.config) + if self._levels.count: + self._levels = self._levels.keep_first() + self._assign_paths(self._levels.paths[0], self._config_manager.config) def _refresh_setup(self) -> None: """Follows the setup wherever it changed: the destination it now names, and the view.""" @@ -432,20 +455,27 @@ def _refresh_setup(self) -> None: self._emit_view_model(self._msg_idle, 0.0) def _update_stems_output_path(self) -> None: - if not self._stems_mode or not self._sources: + if not self._stems_mode: return - self._output_path = group_output_path(self._config_manager.config, self._source_paths) + sources = self._source_paths + if sources: + self._output_path = group_output_path(self._config_manager.config, sources) def _stem_rows(self, config: Config) -> Tuple[StemSourceRow, ...]: + """The gathered recordings as the panel reads them, each stating where it stands.""" enabled = list(config.generation.channels) return tuple( StemSourceRow( path=source.path, channels=frozenset(effective_channels(source, enabled)), - level=source.level, + level=level_index, + position=position, + level_size=len(level), + level_count=self._levels.level_count, ) - for source in self._sources + for level_index, level in enumerate(self._levels.levels) + for position, source in enumerate(level) ) def _on_conversion_complete(self, written: Tuple[Path, ...]) -> None: @@ -500,15 +530,13 @@ def _compose_action_label(self, input_path: Optional[Path]) -> str: return self._language_manager["main.converter.template.convert_label_template"].format(base, input_path.name) def _compose_stems_action_label(self) -> str: - """The stems label, named after how many recordings are gathered.""" + """The stems label, named after how many recordings take part.""" base = self._language_manager["main.converter.label.convert_stems_button"] - if not self._sources: + playing = len(self._source_paths) + if not playing: return base - return self._language_manager["main.converter.template.convert_label_template"].format( - base, - len(self._sources), - ) + return self._language_manager["main.converter.template.convert_label_template"].format(base, playing) def _emit_view_model( self, diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py index 8e05cc6aa..3839987f0 100644 --- a/src/sampletones_application/logic/main/stems.py +++ b/src/sampletones_application/logic/main/stems.py @@ -1,32 +1,204 @@ -from collections import defaultdict from dataclasses import dataclass, replace from pathlib import Path -from typing import Dict, FrozenSet, List, Self, Sequence +from typing import Callable, FrozenSet, List, Optional, Self, Sequence, Tuple -from sampletones_application.constants.conversion import DEFAULT_STEM_LEVEL from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +Level = Tuple["StemSource", ...] + @dataclass(frozen=True) class StemSource: - """One row of a stems conversion: a recording, the channels it may take, and when it picks. - - The level orders the picking: every source on the lowest level chooses before any source on - the next, which is how a reader puts a lead ahead of a pad. - """ + """One recording in a stems conversion, together with the channels it may take.""" path: Path channels: FrozenSet[ChannelName] - level: int = DEFAULT_STEM_LEVEL def with_channels(self, channels: FrozenSet[ChannelName]) -> Self: return replace(self, channels=channels) - def with_level(self, level: int) -> Self: - return replace(self, level=level) + +@dataclass(frozen=True) +class ConversionSetup: + """What a stems conversion runs with: the recordings it mixes and the setup handing out channels. + + Both sides are built from one pass over the levels, so the entry ids the assignment records + name the recordings in the order the job mixes them. + """ + + sources: Tuple[Path, ...] + stems: StemsConfig + + +@dataclass(frozen=True) +class StemLevels: + """The recordings a stems conversion gathers, in the precedence levels they pick on. + + A level holds the recordings that compete on cost; the levels pick in the order they are + listed. Position within a level is the order the entries are numbered in, which is what + settles a tie between two equal-cost choices. Every gesture answers with a new value whose + levels are all occupied, so the bands a reader sees stay consecutive. + """ + + levels: Tuple[Level, ...] = () + + @classmethod + def of(cls, levels: Sequence[Sequence[StemSource]]) -> Self: + """Builds a value from levels given in any shape, leaving out the ones holding nothing.""" + return cls(levels=tuple(tuple(level) for level in levels if level)) + + @property + def sources(self) -> Tuple[StemSource, ...]: + """Every recording, in the order it is mixed and numbered.""" + return tuple(source for level in self.levels for source in level) + + @property + def paths(self) -> Tuple[Path, ...]: + return tuple(source.path for source in self.sources) + + @property + def count(self) -> int: + return len(self.sources) + + @property + def level_count(self) -> int: + return len(self.levels) + + def holds(self, path: Path) -> bool: + return any(source.path == path for source in self.sources) + + def level_of(self, path: Path) -> int: + """The level the recording picks on, counted from the first.""" + for level_index, level in enumerate(self.levels): + if any(source.path == path for source in level): + return level_index + + raise KeyError(f"{path} is not gathered in this conversion") + + def position_of(self, path: Path) -> int: + """The place the recording takes among the ones sharing its level.""" + level = self.levels[self.level_of(path)] + return next(index for index, source in enumerate(level) if source.path == path) + + def add(self, source: StemSource) -> Self: + """Gathers another recording, on the level the first recordings picked on.""" + if self.holds(source.path): + return self + + levels = self._mutable() + if not levels: + return self.of([[source]]) + + levels[0].append(source) + return self.of(levels) + + def remove(self, path: Path) -> Self: + """Lets a recording go, together with the level it emptied.""" + return self.of(self._without(path)) + + def replace_source(self, path: Path, change: Callable[[StemSource], StemSource]) -> Self: + """Rewrites one recording where it stands, leaving the rest of the setup as it is.""" + return self.of( + [[change(source) if source.path == path else source for source in level] for level in self.levels] + ) + + def keep_first(self) -> Self: + """Keeps the recording that picks first, which is the one a classic conversion carries.""" + sources = self.sources + return self.of([[sources[0]]]) if sources else self.of([]) + + def move_within_level(self, path: Path, offset: int) -> Self: + """Moves a recording past the neighbour it shares a level with, changing which of them ties first.""" + source = self._source(path) + if source is None: + return self + + level_index = self.level_of(path) + position = self.position_of(path) + offset + level = list(self.levels[level_index]) + if not 0 <= position < len(level): + return self + + level.remove(source) + level.insert(position, source) + levels = self._mutable() + levels[level_index] = level + return self.of(levels) + + def join_level(self, path: Path, offset: int) -> Self: + """Sends a recording to the neighbouring level, where it picks with that level's recordings.""" + source = self._source(path) + if source is None: + return self + + target = self.level_of(path) + offset + if not 0 <= target < self.level_count: + return self + + levels = self._without(path) + levels[target].append(source) + return self.of(levels) + + def isolate(self, path: Path) -> Self: + """Gives a recording a level of its own, picking directly after the one it shared.""" + source = self._source(path) + if source is None or len(self.levels[self.level_of(path)]) == 1: + return self + + levels = self._without(path) + levels.insert(self.level_of(path) + 1, [source]) + return self.of(levels) + + def move_onto(self, path: Path, target_path: Path) -> Self: + """Moves a recording to the level and the place another one holds.""" + source = self._source(path) + if source is None or path == target_path or not self.holds(target_path): + return self + + levels = self._without(path) + for level in levels: + for position, candidate in enumerate(level): + if candidate.path == target_path: + level.insert(position, source) + return self.of(levels) + + return self + + def move_to_new_level(self, path: Path, position: int) -> Self: + """Gives a recording a level of its own, in the slot the levels are broken at. + + ``position`` counts the gaps a reader sees: zero is above the first level and the level + count is below the last, so the slot names itself the same way whichever level the + recording is leaving. + """ + source = self._source(path) + if source is None: + return self + + level_index = self.level_of(path) + levels = self._without(path) + target = position + if not levels[level_index]: + del levels[level_index] + target = position - 1 if position > level_index else position + if target == level_index: + return self + + levels.insert(target, [source]) + return self.of(levels) + + def _source(self, path: Path) -> Optional[StemSource]: + return next((source for source in self.sources if source.path == path), None) + + def _mutable(self) -> List[List[StemSource]]: + return [list(level) for level in self.levels] + + def _without(self, path: Path) -> List[List[StemSource]]: + """The levels with one recording taken out, keeping a level it emptied for the callers that count on it.""" + return [[source for source in level if source.path != path] for level in self.levels] def effective_channels( @@ -36,44 +208,52 @@ def effective_channels( """The channels a source may take in the run being set up, in the order the run enables them. A source keeps whichever of its channels the configuration still enables. One left holding - none takes every enabled channel, so a source always has somewhere to sound. + none takes no part in the conversion, which is what unticking every channel of a row says. """ - kept = [channel_name for channel_name in enabled_channels if channel_name in source.channels] - return kept if kept else list(enabled_channels) + return [channel_name for channel_name in enabled_channels if channel_name in source.channels] -def derive_stems_config( - sources: Sequence[StemSource], +def derive_conversion_setup( + levels: StemLevels, enabled_channels: Sequence[ChannelName], *, channel_cap: int, hierarchy_mode: HierarchyMode, -) -> StemsConfig: - """Turns the rows a reader set up into the stems setup a conversion runs under. +) -> ConversionSetup: + """Turns the levels a reader gathered into the recordings and the setup a conversion runs with. - A row's position in the list is its stem id, which is what the conversion records per frame - and what a stem selection later reads back. Rows sharing a level pick together, and the - levels follow their numbers upwards. + Recordings holding no enabled channel take no part, so they reach neither the mix nor the + entries. What remains is numbered in list order, which is the id the conversion records per + frame and a stem selection later reads back. """ - return StemsConfig( - entries=[ - StemEntry(id=stem_id, channels=effective_channels(source, enabled_channels)) - for stem_id, source in enumerate(sources) - ], - hierarchy=_hierarchy(sources, hierarchy_mode), - channel_cap=channel_cap, + taking_part = [ + [(source, effective_channels(source, enabled_channels)) for source in level] for level in levels.levels + ] + playing = [[pair for pair in level if pair[1]] for level in taking_part] + ordered = [pair for level in playing if level for pair in level] + + entries = [StemEntry(id=stem_id, channels=channels) for stem_id, (_source, channels) in enumerate(ordered)] + return ConversionSetup( + sources=tuple(source.path for source, _channels in ordered), + stems=StemsConfig( + entries=entries, + hierarchy=_hierarchy(playing, hierarchy_mode), + channel_cap=channel_cap, + ), ) def _hierarchy( - sources: Sequence[StemSource], + playing: Sequence[Sequence[Tuple[StemSource, List[ChannelName]]]], hierarchy_mode: HierarchyMode, ) -> StemsHierarchy: - grouped: Dict[int, List[int]] = defaultdict(list) - for stem_id, source in enumerate(sources): - grouped[source.level].append(stem_id) + levels: List[List[int]] = [] + stem_id = 0 + for level in playing: + if not level: + continue - return StemsHierarchy( - levels=[grouped[level] for level in sorted(grouped)], - mode=hierarchy_mode, - ) + levels.append([stem_id + offset for offset in range(len(level))]) + stem_id += len(level) + + return StemsHierarchy(levels=levels, mode=hierarchy_mode) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index c056a19ad..479f68b3e 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -5,7 +5,7 @@ from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager -from sampletones_application.constants.conversion import DEFAULT_STEM_LEVEL, MIN_CHANNEL_CAP +from sampletones_application.constants.conversion import MIN_CHANNEL_CAP from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.tabs.main.converter import ConverterLayout from sampletones_application.tags.compose import compose_tag @@ -14,7 +14,6 @@ SUF_CHANNELS, SUF_CHECKBOX, SUF_GROUP, - SUF_INPUT, SUF_TEXT, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_PANEL_EMPHASIS, @@ -94,7 +93,6 @@ def __init__( self.on_channel_cap_changed: Optional[Callable[[int], None]] = None self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None self.on_source_channels_changed: Optional[Callable[[Path, FrozenSet[ChannelName]], None]] = None - self.on_source_level_changed: Optional[Callable[[Path, int], None]] = None self.on_source_removed: Optional[PathCallback] = None self._layout = layout @@ -328,18 +326,6 @@ def _create_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) - callback=self._on_source_channels_changed, ) - dpg.add_input_int( - label=self._language_manager["main.converter.label.stem_level"], - tag=self._row_tag(row.path, SUF_INPUT), - width=self._layout.level_input_width, - min_value=DEFAULT_STEM_LEVEL, - min_clamped=True, - step=1, - step_fast=1, - default_value=row.level, - user_data=row.path, - callback=self._on_source_level_changed, - ) remove = dpg.add_button( label=self._language_manager["main.converter.label.stem_remove"], tag=self._row_tag(row.path, SUF_BUTTON), @@ -362,8 +348,6 @@ def _render_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) - ) dpg_set_value(tag, channel_name in row.channels) - dpg_set_value(self._row_tag(row.path, SUF_INPUT), row.level) - dpg_configure_item(self._row_tag(row.path, SUF_INPUT), enabled=not view_model.is_active) dpg_configure_item(self._row_tag(row.path, SUF_BUTTON), enabled=not view_model.is_active) def _on_stems_mode_toggled(self, _sender: Sender, value: bool) -> None: @@ -386,9 +370,6 @@ def _on_source_channels_changed(self, _sender: Sender, _value: bool, user_data: ) self.call(self.on_source_channels_changed, user_data, channels) - def _on_source_level_changed(self, _sender: Sender, value: int, user_data: Path) -> None: - self.call(self.on_source_level_changed, user_data, value) - def _on_source_removed(self, _sender: Sender, _app_data: Any, user_data: Path) -> None: self.call(self.on_source_removed, user_data) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 9b4119144..4311edbbe 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -39,16 +39,49 @@ class ConverterAction(StrEnum): class StemSourceRow(BaseModel, frozen=True): - """One recording in the converter's stems list, as the panel renders it.""" + """One recording in the converter's stems list, as the panel renders it. + + A row states where it stands — the level it picks on, the place it takes among the + recordings sharing that level, and how many of each the list holds — so the moves the panel + offers grey themselves out from the row alone. + """ path: Path channels: FrozenSet[ChannelName] level: int + position: int + level_size: int + level_count: int @property def name(self) -> str: return self.path.name + @property + def takes_part(self) -> bool: + """The recording holds a channel, so the conversion mixes it and gives it a stem.""" + return bool(self.channels) + + @property + def is_first_on_level(self) -> bool: + return self.position == 0 + + @property + def is_last_on_level(self) -> bool: + return self.position == self.level_size - 1 + + @property + def has_level_above(self) -> bool: + return self.level > 0 + + @property + def has_level_below(self) -> bool: + return self.level < self.level_count - 1 + + @property + def alone_on_level(self) -> bool: + return self.level_size == 1 + class ConverterViewModel(BaseModel, frozen=True): """ @@ -90,9 +123,9 @@ def subpanel_visible(self) -> bool: @property def has_input(self) -> bool: - """Something is selected to convert: a listed recording in stems mode, a path otherwise.""" + """Something is there to convert: a listed recording holding a channel, or a selected path.""" if self.stems_mode: - return bool(self.stem_sources) + return any(row.takes_part for row in self.stem_sources) return self.input_path is not None @@ -100,6 +133,11 @@ def has_input(self) -> bool: def source_count(self) -> int: return len(self.stem_sources) + @property + def playing_count(self) -> int: + """How many of the listed recordings take part in the conversion.""" + return sum(1 for row in self.stem_sources if row.takes_part) + @property def can_add_source(self) -> bool: """The list has room for another recording.""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 2def3a138..8e307ff17 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -358,7 +358,6 @@ main.converter.label.channel_cap: "Channels per source" main.converter.label.hierarchy_mode: "Order" main.converter.label.hierarchy_round_robin: "Round robin" main.converter.label.hierarchy_strict: "Strict" -main.converter.label.stem_level: "Level" main.converter.label.stem_remove: "x" main.converter.label.convert_stems_button: "Convert stems" main.converter.label.discard_stems_button: "Keep the first" diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 6f7625ae8..aca535537 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -413,13 +413,13 @@ def test_selecting_a_recording_in_stems_mode_adds_it(self, converter_logic: Conv def test_adding_a_listed_recording_leaves_the_list_as_it_is(self, converter_logic: ConverterLogic) -> None: self._with_config(converter_logic) converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/bass.wav")]) + converter_logic.add_sources([Path("/audio/bass.wav"), Path("/audio/lead.wav")]) + converter_logic.isolate_source(Path("/audio/lead.wav")) - converter_logic.set_source_level(Path("/audio/bass.wav"), 3) - converter_logic.add_sources([Path("/audio/bass.wav")]) + converter_logic.add_sources([Path("/audio/lead.wav")]) - assert converter_logic._source_paths == (Path("/audio/bass.wav"),) - assert converter_logic._sources[0].level == 3 + assert converter_logic._source_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav")) + assert converter_logic._levels.level_of(Path("/audio/lead.wav")) == 1 def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic) -> None: self._with_config(converter_logic) @@ -427,7 +427,7 @@ def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES + 3)]) - assert len(converter_logic._sources) == MAX_STEM_SOURCES + assert converter_logic.source_count == MAX_STEM_SOURCES def test_removing_a_recording_takes_it_out(self, converter_logic: ConverterLogic) -> None: self._with_config(converter_logic) @@ -454,7 +454,7 @@ def test_leaving_stems_mode_keeps_the_first_recording(self, converter_logic: Con converter_logic.set_stems_mode(False) - assert converter_logic._source_paths == (Path("/audio/a.wav"),) + assert converter_logic._levels.paths == (Path("/audio/a.wav"),) def test_a_stems_conversion_groups_every_listed_recording(self, converter_logic: ConverterLogic) -> None: config = self._with_config(converter_logic) @@ -472,13 +472,38 @@ def test_the_rows_channels_and_levels_reach_the_setup(self, converter_logic: Con converter_logic.set_stems_mode(True) converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.PULSE1})) - converter_logic.set_source_level(Path("/audio/b.wav"), 2) + converter_logic.isolate_source(Path("/audio/b.wav")) plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) assert plan.stems.entries[0].channels == [ChannelName.PULSE1] assert plan.stems.hierarchy.levels == [[0], [1]] + def test_a_recording_left_with_no_channel_takes_no_part(self, converter_logic: ConverterLogic) -> None: + config = self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) + plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) + + assert converter_logic.source_count == 2 + assert plan.sources == (Path("/audio/b.wav"),) + assert [entry.id for entry in plan.stems.entries] == [0] + + def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None: + config = self._with_config(converter_logic) + converter_logic.set_stems_mode(True) + converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) + + converter_logic.move_source_to_new_level(Path("/audio/b.wav"), 0) + rows = converter_logic._stem_rows(config) + + assert [(row.path.name, row.level, row.level_count) for row in rows] == [ + ("b.wav", 0, 2), + ("a.wav", 1, 2), + ] + def test_the_cap_holds_within_the_channels_enabled(self, converter_logic: ConverterLogic) -> None: config = self._with_config(converter_logic) channels = list(config.generation.channels) diff --git a/tests/unit/sampletones_application/logic/main/test_stems.py b/tests/unit/sampletones_application/logic/main/test_stems.py index 4fd7b48d7..3f2dee025 100644 --- a/tests/unit/sampletones_application/logic/main/test_stems.py +++ b/tests/unit/sampletones_application/logic/main/test_stems.py @@ -1,10 +1,12 @@ from pathlib import Path -from typing import FrozenSet, List +from typing import FrozenSet, List, Sequence + +import pytest -from sampletones_application.constants.conversion import DEFAULT_STEM_LEVEL from sampletones_application.logic.main.stems import ( + StemLevels, StemSource, - derive_stems_config, + derive_conversion_setup, effective_channels, ) from sampletones_core.constants.enums import ChannelName, HierarchyMode @@ -12,8 +14,20 @@ ENABLED: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] -def _source(name: str, channels: FrozenSet[ChannelName], level: int = DEFAULT_STEM_LEVEL) -> StemSource: - return StemSource(path=Path(f"/audio/{name}.wav"), channels=channels, level=level) +def _path(name: str) -> Path: + return Path(f"/audio/{name}.wav") + + +def _source(name: str, channels: FrozenSet[ChannelName] = frozenset(ENABLED)) -> StemSource: + return StemSource(path=_path(name), channels=channels) + + +def _levels(*names: Sequence[str]) -> StemLevels: + return StemLevels.of([[_source(name) for name in level] for level in names]) + + +def _shape(levels: StemLevels) -> List[List[str]]: + return [[source.path.stem for source in level] for level in levels.levels] class TestEffectiveChannels: @@ -21,93 +35,184 @@ def test_a_source_keeps_the_channels_still_enabled(self) -> None: source = _source("lead", frozenset({ChannelName.PULSE1, ChannelName.PULSE2})) assert effective_channels(source, ENABLED) == [ChannelName.PULSE1] - def test_a_source_left_with_none_takes_every_enabled_channel(self) -> None: + def test_a_source_holding_no_enabled_channel_takes_no_part(self) -> None: source = _source("lead", frozenset({ChannelName.PULSE2})) - assert effective_channels(source, ENABLED) == ENABLED + assert effective_channels(source, ENABLED) == [] def test_channels_follow_the_order_the_run_enables_them(self) -> None: source = _source("lead", frozenset({ChannelName.NOISE, ChannelName.PULSE1})) assert effective_channels(source, ENABLED) == [ChannelName.PULSE1, ChannelName.NOISE] -class TestDeriveStemsConfig: - def test_a_rows_position_is_its_stem_id(self) -> None: - sources = [_source("a", frozenset(ENABLED)), _source("b", frozenset(ENABLED))] +class TestGathering: + """The list a reader builds: recordings arrive on the first level and leave without a trace.""" - setup = derive_stems_config( - sources, - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) + def test_the_first_recording_opens_a_level(self) -> None: + assert _shape(StemLevels().add(_source("bass"))) == [["bass"]] + + def test_further_recordings_join_the_first_level(self) -> None: + levels = StemLevels().add(_source("bass")).add(_source("lead")) + assert _shape(levels) == [["bass", "lead"]] + + def test_a_recording_already_gathered_changes_nothing(self) -> None: + levels = _levels(["bass"]).add(_source("bass")) + assert _shape(levels) == [["bass"]] + + def test_removing_the_last_of_a_level_takes_the_level_with_it(self) -> None: + assert _shape(_levels(["bass"], ["lead"]).remove(_path("bass"))) == [["lead"]] + + def test_leaving_stems_mode_keeps_the_recording_that_picks_first(self) -> None: + assert _shape(_levels(["bass", "lead"], ["pad"]).keep_first()) == [["bass"]] + + def test_a_row_states_where_it_stands(self) -> None: + levels = _levels(["bass", "lead"], ["pad"]) + assert (levels.level_of(_path("lead")), levels.position_of(_path("lead"))) == (0, 1) + + def test_asking_after_a_recording_that_was_never_gathered_fails(self) -> None: + with pytest.raises(KeyError): + _levels(["bass"]).level_of(_path("lead")) + + +class TestMovesWithinALevel: + """Position among peers settles which of two equal-cost choices picks first.""" + + def test_a_recording_moves_past_its_neighbour(self) -> None: + assert _shape(_levels(["bass", "lead"]).move_within_level(_path("lead"), -1)) == [["lead", "bass"]] - assert [entry.id for entry in setup.entries] == [0, 1] + def test_a_move_off_the_end_of_a_level_changes_nothing(self) -> None: + levels = _levels(["bass", "lead"]) + assert _shape(levels.move_within_level(_path("bass"), -1)) == _shape(levels) - def test_rows_sharing_a_level_pick_together(self) -> None: - sources = [ - _source("a", frozenset(ENABLED), level=1), - _source("b", frozenset(ENABLED), level=2), - _source("c", frozenset(ENABLED), level=1), + +class TestMovesBetweenLevels: + def test_a_recording_joins_the_level_below(self) -> None: + assert _shape(_levels(["bass"], ["lead"]).join_level(_path("bass"), 1)) == [["lead", "bass"]] + + def test_a_recording_joins_the_level_above(self) -> None: + assert _shape(_levels(["bass"], ["lead"]).join_level(_path("lead"), -1)) == [["bass", "lead"]] + + def test_joining_past_the_last_level_changes_nothing(self) -> None: + levels = _levels(["bass"], ["lead"]) + assert _shape(levels.join_level(_path("lead"), 1)) == _shape(levels) + + def test_a_recording_takes_a_level_of_its_own_after_the_one_it_shared(self) -> None: + assert _shape(_levels(["bass", "lead"], ["pad"]).isolate(_path("bass"))) == [["lead"], ["bass"], ["pad"]] + + def test_a_recording_already_alone_stays_where_it_is(self) -> None: + levels = _levels(["bass"], ["lead"]) + assert _shape(levels.isolate(_path("bass"))) == _shape(levels) + + +class TestDropOntoARow: + def test_the_dragged_recording_takes_the_place_it_was_dropped_on(self) -> None: + assert _shape(_levels(["bass"], ["lead", "pad"]).move_onto(_path("bass"), _path("pad"))) == [ + ["lead", "bass", "pad"] ] - setup = derive_stems_config( - sources, + def test_dropping_a_recording_on_itself_changes_nothing(self) -> None: + levels = _levels(["bass", "lead"]) + assert _shape(levels.move_onto(_path("bass"), _path("bass"))) == _shape(levels) + + +class TestDropOntoAStrip: + """A strip is the gap between two bands, counted from the one above the first level.""" + + @pytest.mark.parametrize( + ("position", "expected"), + [ + (0, [["bass"], ["lead"], ["pad"]]), + (1, [["bass"], ["lead"], ["pad"]]), + (2, [["lead"], ["bass"], ["pad"]]), + (3, [["lead"], ["pad"], ["bass"]]), + ], + ) + def test_a_lone_recording_lands_in_the_slot_it_was_dropped_in( + self, + position: int, + expected: List[List[str]], + ) -> None: + levels = _levels(["bass"], ["lead"], ["pad"]) + assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected + + @pytest.mark.parametrize( + ("position", "expected"), + [ + (0, [["bass"], ["lead"], ["pad"]]), + (1, [["lead"], ["bass"], ["pad"]]), + (2, [["lead"], ["pad"], ["bass"]]), + ], + ) + def test_a_recording_leaving_its_peers_opens_a_level( + self, + position: int, + expected: List[List[str]], + ) -> None: + levels = _levels(["bass", "lead"], ["pad"]) + assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected + + +class TestDeriveConversionSetup: + def test_a_recordings_position_is_its_stem_id(self) -> None: + setup = derive_conversion_setup( + _levels(["a", "b"]), ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) - assert setup.hierarchy.levels == [[0, 2], [1]] + assert [entry.id for entry in setup.stems.entries] == [0, 1] - def test_levels_follow_their_numbers_upwards_with_the_gaps_closed(self) -> None: - """Levels the reader left unused hold no rows, so the hierarchy names the ones in use.""" - sources = [ - _source("a", frozenset(ENABLED), level=5), - _source("b", frozenset(ENABLED), level=2), - ] - - setup = derive_stems_config( - sources, + def test_recordings_sharing_a_level_pick_together(self) -> None: + setup = derive_conversion_setup( + _levels(["a", "c"], ["b"]), ENABLED, channel_cap=1, - hierarchy_mode=HierarchyMode.ROUND_ROBIN, + hierarchy_mode=HierarchyMode.STRICT, ) - assert setup.hierarchy.levels == [[1], [0]] + assert setup.stems.hierarchy.levels == [[0, 1], [2]] - def test_the_cap_and_the_mode_reach_the_setup(self) -> None: - setup = derive_stems_config( - [_source("a", frozenset(ENABLED))], + def test_the_mix_lists_the_recordings_in_entry_order(self) -> None: + setup = derive_conversion_setup( + _levels(["a"], ["b"]), ENABLED, - channel_cap=2, + channel_cap=1, hierarchy_mode=HierarchyMode.ROUND_ROBIN, ) - assert setup.channel_cap == 2 - assert setup.hierarchy.mode == HierarchyMode.ROUND_ROBIN + assert setup.sources == (_path("a"), _path("b")) - def test_a_disabled_channel_leaves_the_setup(self) -> None: - setup = derive_stems_config( - [_source("a", frozenset({ChannelName.PULSE1, ChannelName.PULSE2}))], + def test_a_recording_holding_no_enabled_channel_reaches_neither_the_mix_nor_the_entries(self) -> None: + levels = StemLevels.of([[_source("a"), _source("silent", frozenset())], [_source("b")]]) + + setup = derive_conversion_setup( + levels, ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) - assert setup.entries[0].channels == [ChannelName.PULSE1] - assert setup.covered_channels == frozenset({ChannelName.PULSE1}) + assert setup.sources == (_path("a"), _path("b")) + assert setup.stems.hierarchy.levels == [[0], [1]] - def test_the_hierarchy_names_every_row(self) -> None: - """The setup validates that its hierarchy names each stem once, so a derivation that - dropped one would be refused rather than stored.""" - sources = [_source(name, frozenset(ENABLED), level=level) for name, level in (("a", 1), ("b", 3), ("c", 3))] + def test_a_level_left_with_nobody_taking_part_drops_out(self) -> None: + levels = StemLevels.of([[_source("silent", frozenset())], [_source("b")]]) - setup = derive_stems_config( - sources, + setup = derive_conversion_setup( + levels, ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) - named = [stem_id for level in setup.hierarchy.levels for stem_id in level] - assert sorted(named) == [entry.id for entry in setup.entries] + assert setup.stems.hierarchy.levels == [[0]] + + def test_the_cap_and_the_mode_travel_with_the_setup(self) -> None: + setup = derive_conversion_setup( + _levels(["a"]), + ENABLED, + channel_cap=2, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert (setup.stems.channel_cap, setup.stems.hierarchy.mode) == (2, HierarchyMode.ROUND_ROBIN) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 992c2abfa..342c76609 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -12,7 +12,7 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.logic.history.action import HistoryAction -from sampletones_application.tags.general import SUF_BUTTON, SUF_GROUP, SUF_INPUT +from sampletones_application.tags.general import SUF_BUTTON, SUF_GROUP from sampletones_application.tags.main import TAG_MAIN_CONVERTER_WINDOW_STEMS from sampletones_application.ui.panels.main.converter import GUIConverterPanel from sampletones_application.utils.gui.keyboard.event import KeyEvent @@ -424,17 +424,13 @@ def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Pa for path in paths: assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_GROUP)) - assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_INPUT)) assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_BUTTON)) - def test_a_rows_level_and_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None: + def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None: path = self._gather(app, tmp_path, ["a.wav"])[0] - converter_logic = app._main_tab._converter_logic - converter_logic.set_source_level(path, 3) - converter_logic.set_source_channels(path, frozenset({ChannelName.NOISE})) + app._main_tab._converter_logic.set_source_channels(path, frozenset({ChannelName.NOISE})) - assert dpg.get_value(GUIConverterPanel._row_tag(path, SUF_INPUT)) == 3 assert dpg.get_value(GUIConverterPanel._channel_tag(path, ChannelName.NOISE)) is True assert dpg.get_value(GUIConverterPanel._channel_tag(path, ChannelName.PULSE1)) is False diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 29ef23201..6e16b5e00 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -17,8 +17,23 @@ ) -def _row(name: str, level: int = 1) -> StemSourceRow: - return StemSourceRow(path=Path(f"/audio/{name}.wav"), channels=ENABLED_CHANNELS, level=level) +def _row( + name: str, + *, + channels: FrozenSet[ChannelName] = ENABLED_CHANNELS, + level: int = 0, + position: int = 0, + level_size: int = 1, + level_count: int = 1, +) -> StemSourceRow: + return StemSourceRow( + path=Path(f"/audio/{name}.wav"), + channels=channels, + level=level, + position=position, + level_size=level_size, + level_count=level_count, + ) def _view_model( @@ -170,3 +185,41 @@ def test_a_list_with_room_takes_another(self) -> None: def test_a_row_names_itself_by_its_file(self) -> None: assert _row("bass").name == "bass.wav" + + def test_a_row_holding_no_channel_offers_nothing_to_convert(self) -> None: + view_model = _view_model( + phase=ConversionPhase.IDLE, + input_path=None, + stems_mode=True, + stem_sources=(_row("bass", channels=frozenset()),), + ) + + assert view_model.has_input is False + assert view_model.playing_count == 0 + assert view_model.convert_button_enabled is False + + +class TestRowStanding: + """A row states where it stands, so the moves it offers grey themselves out from the row alone.""" + + def test_the_only_row_of_the_only_level_can_go_nowhere(self) -> None: + row = _row("bass") + + assert row.is_first_on_level is True + assert row.is_last_on_level is True + assert row.alone_on_level is True + assert row.has_level_above is False + assert row.has_level_below is False + + def test_a_row_between_levels_can_join_either(self) -> None: + row = _row("lead", level=1, level_count=3) + + assert row.has_level_above is True + assert row.has_level_below is True + + def test_a_row_sharing_a_level_names_its_place_among_its_peers(self) -> None: + row = _row("lead", position=1, level_size=3) + + assert row.is_first_on_level is False + assert row.is_last_on_level is False + assert row.alone_on_level is False From 440b9bd9599363f6e1cafc1498f32e44338a0bf6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 04:34:15 +0200 Subject: [PATCH 045/142] Reworked: the converter's stems card --- src/sampletones_application/application.py | 2 +- .../categories/elements/main.py | 15 + .../coordinators/tabs/main.py | 6 + .../layout/tabs/main/converter.py | 9 +- .../logic/main/converter.py | 14 +- src/sampletones_application/tags/general.py | 2 + src/sampletones_application/tags/main.py | 11 +- .../ui/panels/dialogs/stem_selection.py | 11 +- .../ui/panels/main/converter.py | 476 ++++++++++++------ .../utils/gui/tooltip.py | 11 + .../view_model/main/converter.py | 14 + src/sampletones_config/lang/en.yaml | 10 + .../layout/tabs/main/converter.yaml | 9 +- .../sampletones_application/test_startup.py | 100 +++- 14 files changed, 519 insertions(+), 171 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 368f88c39..a64f5f585 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -289,7 +289,7 @@ def __init__( shortcut_source=self._shortcut_source, ) self.stem_selection_window: GUIStemSelectionWindow = GUIStemSelectionWindow( - layout=self.layout.tabs.main.converter.stem_selection, + layout=self.layout.tabs.main.converter, title=self.language_manager["main.converter.title.stem_selection_dialog"], message=self.language_manager["main.converter.message.stem_selection_prompt"], limit_template=self.language_manager["main.converter.template.stem_selection_limit"], diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index f107e2fb9..9366e02ef 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -91,6 +91,21 @@ class ConverterElements(AbstractElement): ADD_STEMS_BUTTON = "add_stems_button" STATUS_STEM_REMOVE = "status_stem_remove" STATUS_STEMS_MODE = "status_stems_mode" + STEM_LEVEL_CAPTION = "stem_level_caption" + STEM_HANDLE = "stem_handle" + STEM_HANDLE_TOOLTIP = "stem_handle_tooltip" + STEM_INERT_TOOLTIP = "stem_inert_tooltip" + + +class ConverterStemMoveElements(AbstractElement): + """The moves a gathered recording can make, as the row's menu names them.""" + + CONTEXT_MOVE_UP = "context_move_up" + CONTEXT_MOVE_DOWN = "context_move_down" + CONTEXT_JOIN_ABOVE = "context_join_above" + CONTEXT_JOIN_BELOW = "context_join_below" + CONTEXT_ISOLATE = "context_isolate" + CONTEXT_REMOVE_STEM = "context_remove_stem" class AdvancedElements(AbstractElement): diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index d1f1739cd..b6457d82c 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -205,6 +205,7 @@ def __init__( ) self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, + inputs=layout.inputs, path_colors=layout.path_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_CONVERTER_PANEL), language_manager=language_manager, @@ -265,6 +266,11 @@ def __init__( self._converter_panel.on_hierarchy_mode_changed = self._converter_logic.set_hierarchy_mode self._converter_panel.on_source_channels_changed = self._converter_logic.set_source_channels self._converter_panel.on_source_removed = self._converter_logic.remove_source + self._converter_panel.on_source_moved = self._converter_logic.move_source_within_level + self._converter_panel.on_source_level_joined = self._converter_logic.join_source_level + self._converter_panel.on_source_isolated = self._converter_logic.isolate_source + self._converter_panel.on_source_dropped_on_source = self._converter_logic.move_source_onto + self._converter_panel.on_source_dropped_on_level = self._converter_logic.move_source_to_new_level self._stem_selection_window.on_add = self._converter_logic.add_sources def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index 89334ca2a..ee0e74bdd 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -5,11 +5,10 @@ class ConverterLayout(BaseModel, extra="forbid", frozen=True): width: int - height: int button_height: int - stems_list_height: int - cap_input_width: int - hierarchy_combo_width: int - level_input_width: int + handle_width: int + channel_column_width: int remove_button_width: int + level_strip_height: int stem_selection: Dimensions + stem_selection_footer: int diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index e58a91164..8f5e027e2 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -203,8 +203,18 @@ def remove_source(self, path: Path) -> None: self._refresh_setup() def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: - """Names the channels one recording may take.""" - self._apply(self._levels.replace_source(path, lambda source: source.with_channels(channels))) + """Names the channels one recording may take, among the ones the reader was offered. + + A channel the configuration leaves out reaches no checkbox, so the recording keeps + whatever it was given for it and gets that choice back when the channel returns. + """ + enabled = frozenset(self._config_manager.config.generation.channels) + self._apply( + self._levels.replace_source( + path, + lambda source: source.with_channels((source.channels - enabled) | channels), + ) + ) def move_source_within_level(self, path: Path, offset: int) -> None: """Moves a recording past the neighbour it shares a level with.""" diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 0fc2d507b..1535236d4 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -716,6 +716,8 @@ SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search") SUF_CHECKBOX = "checkbox" SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites") +SUF_STRIP = "strip" +SUF_HANDLE = "handle" SUF_TABLE = "table" SUF_TOOLTIP = "tooltip" SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail") diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index fc848cf8b..3eaf3ac9c 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -230,12 +230,6 @@ Widget.TOOLTIP, "convert", ) -TAG_MAIN_CONVERTER_WINDOW_SUMMARY = TagName( - Page.MAIN, - Panel.CONVERTER, - Widget.WINDOW, - "summary", -) TAG_MAIN_CONVERTER_GROUP_SUMMARY = TagName( Page.MAIN, Panel.CONVERTER, @@ -356,3 +350,8 @@ Panel.CONVERTER, "candidate", ) +PRE_MAIN_CONVERTER_LEVEL = compose_tag( + Page.MAIN, + Panel.CONVERTER, + "level", +) diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index effe092eb..b8751d5a6 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.tabs.main.converter import ConverterLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.main import ( PRE_MAIN_CONVERTER_CANDIDATE, @@ -34,7 +34,7 @@ class GUIStemSelectionWindow(GUIDialogWindow): def __init__( self, *, - layout: Dimensions, + layout: ConverterLayout, title: str, message: str, limit_template: str, @@ -50,13 +50,14 @@ def __init__( self._cancel_label = cancel_label self._candidates: Tuple[Path, ...] = () self._room = 0 + self._footer_height = layout.stem_selection_footer self.on_add: Optional[Callable[[List[Path]], None]] = None super().__init__( tag=TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, - width=layout.width, - height=layout.height, + width=layout.stem_selection.width, + height=layout.stem_selection.height, key_router=key_router, shortcut_source=shortcut_source, ) @@ -78,7 +79,7 @@ def create_window(self) -> None: with dpg.child_window( tag=TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION, width=-1, - height=-self.height // 4, + height=-self._footer_height, border=False, ): self._create_candidate_rows() diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 479f68b3e..2e56e34f9 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -1,12 +1,15 @@ from pathlib import Path -from typing import Any, Callable, Dict, FrozenSet, List, Optional +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple import dearpygui.dearpygui as dpg from sampletones_application.categories.context import channel_label +from sampletones_application.categories.elements.main import ConverterStemMoveElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.conversion import MIN_CHANNEL_CAP from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.inputs import InputsLayout from sampletones_application.layout.tabs.main.converter import ConverterLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( @@ -14,12 +17,17 @@ SUF_CHANNELS, SUF_CHECKBOX, SUF_GROUP, + SUF_HANDLE, + SUF_HANDLER_REGISTRY, + SUF_STRIP, + SUF_TABLE, SUF_TEXT, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_PANEL_EMPHASIS, TAG_GLOBAL_THEME_PRIMARY_BUTTON, ) from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_LEVEL, PRE_MAIN_CONVERTER_STEM, TAG_MAIN_CONVERTER_BUTTON_ACTION, TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, @@ -42,23 +50,29 @@ TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, - TAG_MAIN_CONVERTER_WINDOW_SUMMARY, ) from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.context_menu import context_menu +from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( dpg_configure_item, - dpg_delete_item, dpg_set_item_callback, dpg_set_value, ) -from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip +from sampletones_application.utils.gui.tooltip import ( + attach_disabled_tooltip, + set_tooltip_visible, + show_tooltip, +) +from sampletones_application.utils.gui.widgets import clamp_widget_value from sampletones_application.view_model.main.converter import ( ConverterAction, ConverterViewModel, @@ -68,12 +82,29 @@ from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import PathCallback, VoidCallback +RowShape = Tuple[Tuple[str, ...], Tuple[Tuple[str, int, bool], ...]] +PathOffsetCallback = Callable[[Path, int], None] + +STEM_PAYLOAD: str = compose_tag(PRE_MAIN_CONVERTER_STEM, "payload") +LEVEL_ABOVE: int = -1 +LEVEL_BELOW: int = 1 +POSITION_EARLIER: int = -1 +POSITION_LATER: int = 1 + class GUIConverterPanel(GUIPanel): + """The card a conversion is set up on: what it converts, how, and what it is doing. + + In stems mode the card lists the recordings being gathered under the levels they pick on. + A row is dragged by its handle onto another row to share that row's level, or onto the gap + between two levels to open one of its own; the row's menu names the same moves in words. + """ + def __init__( self, *, layout: ConverterLayout, + inputs: InputsLayout, path_colors: PathColors, initial_collapsed: bool = False, language_manager: LanguageManager, @@ -94,32 +125,42 @@ def __init__( self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None self.on_source_channels_changed: Optional[Callable[[Path, FrozenSet[ChannelName]], None]] = None self.on_source_removed: Optional[PathCallback] = None + self.on_source_moved: Optional[PathOffsetCallback] = None + self.on_source_level_joined: Optional[PathOffsetCallback] = None + self.on_source_isolated: Optional[PathCallback] = None + self.on_source_dropped_on_source: Optional[Callable[[Path, Path], None]] = None + self.on_source_dropped_on_level: Optional[Callable[[Path, int], None]] = None self._layout = layout + self._input_width = inputs.default_width + self._label_width = inputs.label_width self._path_colors = path_colors self._msg_path = language_manager["global.status.message.path"] self._msg_destination = language_manager["global.status.message.destination"] self._msg_status_convert = language_manager["main.converter.message.status_convert"] self._status_action_message = self._msg_status_convert + self._level_template = language_manager["main.converter.template.stem_level_caption"] self._hierarchy_labels: Dict[HierarchyMode, str] = { HierarchyMode.ROUND_ROBIN: language_manager["main.converter.label.hierarchy_round_robin"], HierarchyMode.STRICT: language_manager["main.converter.label.hierarchy_strict"], } - self._hierarchy_modes: List[HierarchyMode] = list(self._hierarchy_labels) - self._stem_rows: List[Path] = [] + self._settings_handler_tag = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_HANDLER_REGISTRY) + self._row_handler_tag = compose_tag(PRE_MAIN_CONVERTER_STEM, SUF_HANDLER_REGISTRY) + self._rows: Tuple[StemSourceRow, ...] = () + self._channels_in_play: Tuple[ChannelName, ...] = () + self._shape: RowShape = ((), ()) - super().__init__( - tag=TAG_MAIN_CONVERTER_PANEL, - height=layout.height, - ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed) + super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) + self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) def create_panel(self, parent: str) -> None: + self._create_handlers() with self._collapsible_card( parent, self._language_manager["main.converter.label.section"], glyph=self._glyphs.headers.converter, width=self.width, + no_scrollbar=True, card_theme=TAG_GLOBAL_THEME_PANEL_EMPHASIS, ): self._create_action_button() @@ -133,15 +174,21 @@ def is_visible(self) -> bool: return bool(dpg.get_item_configuration(self.tag)["show"]) def update_view(self, view_model: ConverterViewModel) -> None: - self._update_visibility(view_model) self._update_status(view_model) self._update_paths(view_model) self._update_controls(view_model) self._update_setup(view_model) + self._update_visibility(view_model) + + def _create_handlers(self) -> None: + with dpg.item_handler_registry(tag=self._settings_handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._on_channel_cap_edited) + + with dpg.item_handler_registry(tag=self._row_handler_tag): + dpg.add_item_clicked_handler(callback=self._on_row_clicked) def _update_visibility(self, view_model: ConverterViewModel) -> None: - dpg.configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) - dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_SUMMARY, show=not view_model.subpanel_visible) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not view_model.has_input) dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=view_model.has_input) @@ -182,34 +229,34 @@ def _update_controls(self, view_model: ConverterViewModel) -> None: def _create_controls(self) -> None: """The row of choices every conversion carries: stems mode, the cap, and the picking order.""" - with dpg.group(horizontal=True, tag=TAG_MAIN_CONVERTER_GROUP_CONTROLS): + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONTROLS): dpg.add_checkbox( label=self._language_manager["main.converter.label.stems_mode"], tag=TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, callback=self._on_stems_mode_toggled, ) - dpg.add_input_int( - label=self._language_manager["main.converter.label.channel_cap"], - tag=TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, - width=self._layout.cap_input_width, - min_value=MIN_CHANNEL_CAP, - min_clamped=True, - max_clamped=True, - step=1, - step_fast=1, - callback=self._on_channel_cap_changed, - ) - - with dpg.group(horizontal=True): - dpg.add_combo( - items=list(self._hierarchy_labels.values()), - label=self._language_manager["main.converter.label.hierarchy_mode"], - tag=TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, - width=self._layout.hierarchy_combo_width, - default_value=self._hierarchy_labels[HierarchyMode.ROUND_ROBIN], - callback=self._on_hierarchy_mode_changed, - ) + with labeled_field(self._language_manager["main.converter.label.channel_cap"], self._label_width): + cap_input = dpg.add_input_int( + tag=TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + width=self._input_width, + min_value=MIN_CHANNEL_CAP, + min_clamped=True, + max_clamped=True, + default_value=len(ChannelName), + callback=self._on_channel_cap_edited, + ) + FontRegistry.bind_to_item(cap_input, Font.MONO) + + with labeled_field(self._language_manager["main.converter.label.hierarchy_mode"], self._label_width): + dpg.add_combo( + items=list(self._hierarchy_labels.values()), + tag=TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + width=self._input_width, + default_value=self._hierarchy_labels[HierarchyMode.ROUND_ROBIN], + callback=self._on_hierarchy_mode_changed, + ) + dpg.bind_item_handler_registry(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, self._settings_handler_tag) self._attach_control_tooltips() self._status_bar.bind_to_item( TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, @@ -234,18 +281,11 @@ def _attach_control_tooltips(self) -> None: TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, ), ): - with dpg.tooltip(tag, tag=tooltip_tag): - dpg.add_text(message) + show_tooltip(tag, message, tag=tooltip_tag) def _create_stems_list(self) -> None: - """The recordings gathered so far, one row each, scrolling when the list outgrows its space.""" - with dpg.child_window( - tag=TAG_MAIN_CONVERTER_WINDOW_STEMS, - width=-1, - height=self._layout.stems_list_height, - border=False, - show=False, - ): + """The recordings gathered so far, under the levels they pick on.""" + with dpg.group(tag=TAG_MAIN_CONVERTER_WINDOW_STEMS, show=False): hint = dpg.add_text( self._language_manager["main.converter.message.stems_empty_hint"], tag=TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, @@ -267,94 +307,168 @@ def _update_setup(self, view_model: ConverterViewModel) -> None: self._hierarchy_labels[view_model.hierarchy_mode], ) dpg_configure_item(TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, show=view_model.stems_mode) + set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.stems_mode) dpg_configure_item(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, enabled=not view_model.is_active) self._update_stems_list(view_model) def _update_stems_list(self, view_model: ConverterViewModel) -> None: - dpg_configure_item( - TAG_MAIN_CONVERTER_WINDOW_STEMS, - show=view_model.stems_mode and not view_model.subpanel_visible, - ) - dpg_configure_item( - TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, - show=view_model.source_count == 0, - ) + dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_STEMS, show=view_model.stems_mode) + dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=view_model.source_count == 0) + self._rows = view_model.stem_sources + self._channels_in_play = view_model.channels_in_play self._sync_stem_rows(view_model) + for level_index in range(view_model.level_count + 1): + dpg_set_value(self._level_tag(level_index, SUF_STRIP), False) + for row in view_model.stem_sources: self._render_stem_row(row, view_model) - self.set_expanded_height(self._card_height(view_model)) + def _sync_stem_rows(self, view_model: ConverterViewModel) -> None: + """Rebuilds the bands when the recordings or the levels change, keeps them otherwise.""" + shape = self._row_shape(view_model) + if shape == self._shape: + return - def _card_height(self, view_model: ConverterViewModel) -> int: - """The room the card takes: its own, plus the list's where the list is on show.""" - if view_model.stems_mode and not view_model.subpanel_visible: - return self._layout.height + self._layout.stems_list_height + self._shape = shape + dpg.delete_item(TAG_MAIN_CONVERTER_GROUP_STEMS, children_only=True) + for level_index in range(view_model.level_count): + self._create_level_strip(level_index) + self._create_level_caption(level_index) + self._create_level_table(level_index, view_model) - return self._layout.height + if view_model.level_count: + self._create_level_strip(view_model.level_count) - def _sync_stem_rows(self, view_model: ConverterViewModel) -> None: - """Rebuilds the rows when the recordings change, keeps them otherwise.""" - listed = [row.path for row in view_model.stem_sources] - if listed == self._stem_rows: - return + @staticmethod + def _row_shape(view_model: ConverterViewModel) -> RowShape: + """What the bands are built from: the channels in play, and where each row stands.""" + return ( + tuple(str(channel_name) for channel_name in view_model.channels_in_play), + tuple((str(row.path), row.level, row.takes_part) for row in view_model.stem_sources), + ) - for path in self._stem_rows: - dpg_delete_item(self._row_tag(path, SUF_GROUP)) + def _create_level_strip(self, position: int) -> None: + """The gap a level is broken at: a recording dropped here takes a level of its own.""" + dpg.add_selectable( + label="", + tag=self._level_tag(position, SUF_STRIP), + parent=TAG_MAIN_CONVERTER_GROUP_STEMS, + height=self._layout.level_strip_height, + user_data=position, + payload_type=STEM_PAYLOAD, + drop_callback=self._on_dropped_on_level, + ) - self._stem_rows = listed - for row in view_model.stem_sources: - self._create_stem_row(row, view_model) + def _create_level_caption(self, level_index: int) -> None: + caption = dpg.add_text( + self._level_template.format(level_index + 1).upper(), + tag=self._level_tag(level_index, SUF_TEXT), + parent=TAG_MAIN_CONVERTER_GROUP_STEMS, + ) + FontRegistry.bind_to_item(caption, Font.MONO_SMALL) - def _create_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: - with dpg.group( - horizontal=True, - tag=self._row_tag(row.path, SUF_GROUP), + def _create_level_table(self, level_index: int, view_model: ConverterViewModel) -> None: + with dpg.table( + tag=self._level_tag(level_index, SUF_TABLE), parent=TAG_MAIN_CONVERTER_GROUP_STEMS, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, ): - name = dpg.add_text(row.name, tag=self._row_tag(row.path, SUF_TEXT)) - FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) - with dpg.tooltip(name): - dpg.add_text(str(row.path)) - - for channel_name in ChannelName.items(): - dpg.add_checkbox( - label=channel_label(self._language_manager, channel_name), - tag=self._channel_tag(row.path, channel_name), - show=channel_name in view_model.enabled_channels, - default_value=channel_name in row.channels, - user_data=row.path, - callback=self._on_source_channels_changed, - ) + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.handle_width) + dpg.add_table_column(width_stretch=True) + for _channel_name in view_model.channels_in_play: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width) - remove = dpg.add_button( - label=self._language_manager["main.converter.label.stem_remove"], - tag=self._row_tag(row.path, SUF_BUTTON), - width=self._layout.remove_button_width, - user_data=row.path, - callback=self._on_source_removed, - ) - self._status_bar.bind_to_item( - remove, - self._language_manager["main.converter.message.status_stem_remove"], - ) + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) + for row in view_model.stem_sources: + if row.level == level_index: + self._create_stem_row(row, view_model) + + def _create_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: + with dpg.table_row(tag=self._row_tag(row.path, SUF_GROUP)): + self._create_row_handle(row) + self._create_row_name(row) + for channel_name in view_model.channels_in_play: + self._create_row_channel(row, channel_name) + + self._create_row_remove(row) + + def _create_row_handle(self, row: StemSourceRow) -> None: + handle = dpg.add_button( + label=self._language_manager["main.converter.label.stem_handle"], + tag=self._row_tag(row.path, SUF_HANDLE), + width=self._layout.handle_width, + user_data=row.path, + payload_type=STEM_PAYLOAD, + drop_callback=self._on_dropped_on_source, + ) + with dpg.drag_payload(parent=handle, drag_data=str(row.path), payload_type=STEM_PAYLOAD): + dpg.add_text(row.name) + + show_tooltip(handle, self._language_manager["main.converter.message.stem_handle_tooltip"]) + + def _create_row_name(self, row: StemSourceRow) -> None: + name = dpg.add_selectable( + label=row.name, + tag=self._row_tag(row.path, SUF_TEXT), + user_data=row.path, + payload_type=STEM_PAYLOAD, + drop_callback=self._on_dropped_on_source, + ) + FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) + dpg.bind_item_handler_registry(name, self._row_handler_tag) + show_tooltip(name, self._row_explanation(row)) + + def _create_row_channel(self, row: StemSourceRow, channel_name: ChannelName) -> None: + checkbox_tag = self._channel_tag(row.path, channel_name) + dpg.add_checkbox( + label=channel_label(self._language_manager, channel_name), + tag=checkbox_tag, + default_value=channel_name in row.channels, + user_data=row.path, + callback=self._on_source_channels_changed, + ) + ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(checkbox_tag) + + def _create_row_remove(self, row: StemSourceRow) -> None: + remove = dpg.add_button( + label=self._language_manager["main.converter.label.stem_remove"], + tag=self._row_tag(row.path, SUF_BUTTON), + width=self._layout.remove_button_width, + user_data=row.path, + callback=self._on_source_removed, + ) + self._status_bar.bind_to_item( + remove, + self._language_manager["main.converter.message.status_stem_remove"], + ) + + def _row_explanation(self, row: StemSourceRow) -> str: + """What the row's hover states: where the recording is, and where it holds no channel, why + it is greyed out.""" + if row.takes_part: + return str(row.path) + + return f"{row.path}\n{self._language_manager['main.converter.message.stem_inert_tooltip']}" def _render_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: - for channel_name in ChannelName.items(): + live = not view_model.is_active + for channel_name in view_model.channels_in_play: tag = self._channel_tag(row.path, channel_name) - dpg_configure_item( - tag, - show=channel_name in view_model.enabled_channels, - enabled=not view_model.is_active, - ) + dpg_configure_item(tag, enabled=live) dpg_set_value(tag, channel_name in row.channels) - dpg_configure_item(self._row_tag(row.path, SUF_BUTTON), enabled=not view_model.is_active) + dpg_set_value(self._row_tag(row.path, SUF_TEXT), False) + dpg_configure_item(self._row_tag(row.path, SUF_TEXT), enabled=row.takes_part) + dpg_configure_item(self._row_tag(row.path, SUF_HANDLE), enabled=live) + dpg_configure_item(self._row_tag(row.path, SUF_BUTTON), enabled=live) def _on_stems_mode_toggled(self, _sender: Sender, value: bool) -> None: self.call(self.on_stems_mode_changed, value) - def _on_channel_cap_changed(self, _sender: Sender, value: int) -> None: - self.call(self.on_channel_cap_changed, value) + def _on_channel_cap_edited(self, _sender: Sender, _app_data: Any) -> None: + self.call(self.on_channel_cap_changed, int(clamp_widget_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP))) def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: for hierarchy_mode, label in self._hierarchy_labels.items(): @@ -365,7 +479,7 @@ def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: def _on_source_channels_changed(self, _sender: Sender, _value: bool, user_data: Path) -> None: channels = frozenset( channel_name - for channel_name in ChannelName.items() + for channel_name in self._channels_in_play if dpg.get_value(self._channel_tag(user_data, channel_name)) ) self.call(self.on_source_channels_changed, user_data, channels) @@ -373,10 +487,93 @@ def _on_source_channels_changed(self, _sender: Sender, _value: bool, user_data: def _on_source_removed(self, _sender: Sender, _app_data: Any, user_data: Path) -> None: self.call(self.on_source_removed, user_data) + def _on_dropped_on_source(self, sender: Sender, app_data: str) -> None: + """A recording was dropped on a row, so it joins that row's level at its place.""" + target = dpg.get_item_user_data(sender) + if isinstance(target, Path): + self.call(self.on_source_dropped_on_source, Path(app_data), target) + + def _on_dropped_on_level(self, sender: Sender, app_data: str) -> None: + """A recording was dropped in a gap, so it takes a level of its own there.""" + position = dpg.get_item_user_data(sender) + if isinstance(position, int): + self.call(self.on_source_dropped_on_level, Path(app_data), position) + + def _on_row_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + path = dpg.get_item_user_data(clicked_item) + if isinstance(path, Path): + self._show_row_menu(path) + + def _row_for(self, path: Path) -> Optional[StemSourceRow]: + return next((row for row in self._rows if row.path == path), None) + + def _show_row_menu(self, path: Path) -> None: + """Names the moves the row can make, greying out the ones that would change nothing.""" + row = self._row_for(path) + if row is None: + return + + with context_menu(): + header = dpg.add_text(row.name) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + for element, enabled, callback in self._row_moves(row): + dpg.add_menu_item( + label=self._label(element), + enabled=enabled, + callback=callback, + ) + + def _row_moves(self, row: StemSourceRow) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: + path = row.path + return [ + ( + ConverterStemMoveElements.CONTEXT_MOVE_UP, + not row.is_first_on_level, + lambda: self.call(self.on_source_moved, path, POSITION_EARLIER), + ), + ( + ConverterStemMoveElements.CONTEXT_MOVE_DOWN, + not row.is_last_on_level, + lambda: self.call(self.on_source_moved, path, POSITION_LATER), + ), + ( + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE, + row.has_level_above, + lambda: self.call(self.on_source_level_joined, path, LEVEL_ABOVE), + ), + ( + ConverterStemMoveElements.CONTEXT_JOIN_BELOW, + row.has_level_below, + lambda: self.call(self.on_source_level_joined, path, LEVEL_BELOW), + ), + ( + ConverterStemMoveElements.CONTEXT_ISOLATE, + not row.alone_on_level, + lambda: self.call(self.on_source_isolated, path), + ), + ( + ConverterStemMoveElements.CONTEXT_REMOVE_STEM, + True, + lambda: self.call(self.on_source_removed, path), + ), + ] + + def _label(self, element: ConverterStemMoveElements) -> str: + return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] + @staticmethod def _row_tag(path: Path, suffix: str) -> str: return compose_tag(PRE_MAIN_CONVERTER_STEM, str(path), suffix) + @staticmethod + def _level_tag(level_index: int, suffix: str) -> str: + return compose_tag(PRE_MAIN_CONVERTER_LEVEL, str(level_index), suffix) + @classmethod def _channel_tag(cls, path: Path, channel_name: ChannelName) -> str: return compose_tag( @@ -414,40 +611,35 @@ def _action_status_message(self, *_args: Any, **_kwargs: Any) -> str: return self._status_action_message def _create_summary(self) -> None: - with dpg.child_window( - tag=TAG_MAIN_CONVERTER_WINDOW_SUMMARY, - width=-1, - height=-1, - border=False, - ): - hint = dpg.add_text( - self._language_manager["main.converter.message.status_empty_hint"], - tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, + dpg.add_separator() + hint = dpg.add_text( + self._language_manager["main.converter.message.status_empty_hint"], + tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, + ) + FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=False): + self.input_path_text = GUIPathText( + path=None, + prefix=self._language_manager["main.converter.message.status_input_label"], + tag=TAG_MAIN_CONVERTER_PATH_INPUT_PATH, + parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_path, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + self.output_path_text = GUIDestinationPathText( + path=None, + prefix=self._language_manager["main.converter.message.status_output_label"], + tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, + parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_destination, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, ) - FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) - with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=False): - self.input_path_text = GUIPathText( - path=None, - prefix=self._language_manager["main.converter.message.status_input_label"], - tag=TAG_MAIN_CONVERTER_PATH_INPUT_PATH, - parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, - color=self._path_colors.default, - hover_color=self._path_colors.hover, - status_message=self._msg_path, - font=Font.REGULAR_SMALL, - status_bar=self._status_bar, - ) - self.output_path_text = GUIDestinationPathText( - path=None, - prefix=self._language_manager["main.converter.message.status_output_label"], - tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, - parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, - color=self._path_colors.default, - hover_color=self._path_colors.hover, - status_message=self._msg_destination, - font=Font.REGULAR_SMALL, - status_bar=self._status_bar, - ) def _create_conversion_status(self) -> None: with dpg.group( diff --git a/src/sampletones_application/utils/gui/tooltip.py b/src/sampletones_application/utils/gui/tooltip.py index 63e3abbde..97170df2b 100644 --- a/src/sampletones_application/utils/gui/tooltip.py +++ b/src/sampletones_application/utils/gui/tooltip.py @@ -32,6 +32,17 @@ def show_tooltip( return tooltip_text +def set_tooltip_visible(tag: str, visible: bool) -> None: + """Shows or hides a tooltip along with the widget it explains. + + DearPyGui keeps a tooltip live over the rectangle its parent last drew at, so a tooltip left + showing while its widget is hidden explains whatever moved into that place. Toggling the two + together keeps an explanation on the control it belongs to. + """ + if dpg.does_item_exist(tag): + dpg.configure_item(tag, show=visible) + + def attach_disabled_tooltip( parent: str, message: str, diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 4311edbbe..6ed4f8afc 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -133,6 +133,20 @@ def has_input(self) -> bool: def source_count(self) -> int: return len(self.stem_sources) + @property + def channels_in_play(self) -> Tuple[ChannelName, ...]: + """The channels a conversion may reach, in the order the application names them. + + A stems row offers a checkbox per channel in play, so a channel the configuration leaves + out costs the row no column at all. + """ + return tuple(channel_name for channel_name in ChannelName.items() if channel_name in self.enabled_channels) + + @property + def level_count(self) -> int: + """How many levels the gathered recordings are spread over.""" + return max((row.level + 1 for row in self.stem_sources), default=0) + @property def playing_count(self) -> int: """How many of the listed recordings take part in the conversion.""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8e307ff17..3ce1e23b1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -374,6 +374,16 @@ main.converter.message.status_stems_mode: "Mix several recordings into one recon main.converter.title.discard_stems_dialog: "Leave stems mode?" main.converter.title.stem_selection_dialog: "Add recordings" main.converter.template.stem_selection_limit: "Room for {} more of the {} recordings found." +main.converter.template.stem_level_caption: "Level {}" +main.converter.label.stem_handle: "::" +main.converter.label.context_move_up: "Move up" +main.converter.label.context_move_down: "Move down" +main.converter.label.context_join_above: "Join the level above" +main.converter.label.context_join_below: "Join the level below" +main.converter.label.context_isolate: "Put on its own level" +main.converter.label.context_remove_stem: "Remove from the conversion" +main.converter.message.stem_handle_tooltip: "Drag onto another recording to share its level, or onto a gap to open a new one." +main.converter.message.stem_inert_tooltip: "This recording holds no channel, so it takes no part in the conversion." # ============================================================================= # Main tab — Advanced diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index 7479bf958..50723d5ee 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -1,11 +1,10 @@ width: -1 -height: 180 button_height: 45 -stems_list_height: 150 -cap_input_width: 60 -hierarchy_combo_width: 120 -level_input_width: 60 +handle_width: 24 +channel_column_width: 90 remove_button_width: 24 +level_strip_height: 6 stem_selection: width: 420 height: 360 +stem_selection_footer: 44 diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 342c76609..149936171 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -12,8 +12,18 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.logic.history.action import HistoryAction -from sampletones_application.tags.general import SUF_BUTTON, SUF_GROUP -from sampletones_application.tags.main import TAG_MAIN_CONVERTER_WINDOW_STEMS +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_GROUP, + SUF_HANDLE, + SUF_STRIP, + SUF_TABLE, + SUF_TEXT, +) +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_WINDOW_STEMS, +) from sampletones_application.ui.panels.main.converter import GUIConverterPanel from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( @@ -25,6 +35,7 @@ stop_background_workers, ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_application.view_model.main.converter import ConversionPhase from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @@ -427,12 +438,16 @@ def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Pa assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_BUTTON)) def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None: + """The row offers a checkbox per channel the configuration enables, ticked as the row holds it.""" path = self._gather(app, tmp_path, ["a.wav"])[0] + converter_logic = app._main_tab._converter_logic + enabled = list(converter_logic._config_manager.config.generation.channels) + kept, cleared = enabled[-1], enabled[0] - app._main_tab._converter_logic.set_source_channels(path, frozenset({ChannelName.NOISE})) + converter_logic.set_source_channels(path, frozenset({kept})) - assert dpg.get_value(GUIConverterPanel._channel_tag(path, ChannelName.NOISE)) is True - assert dpg.get_value(GUIConverterPanel._channel_tag(path, ChannelName.PULSE1)) is False + assert dpg.get_value(GUIConverterPanel._channel_tag(path, kept)) is True + assert dpg.get_value(GUIConverterPanel._channel_tag(path, cleared)) is False def test_removing_a_recording_takes_its_row_with_it(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) @@ -449,3 +464,78 @@ def test_leaving_stems_mode_hides_the_list(self, app: Application, tmp_path: Pat app._main_tab._converter_logic.set_stems_mode(False) assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is False + + def test_the_list_stays_on_screen_while_a_conversion_runs(self, app: Application, tmp_path: Path) -> None: + """The setup is what a running conversion is making, so it keeps saying what that is.""" + path = self._gather(app, tmp_path, ["a.wav"])[0] + converter_logic = app._main_tab._converter_logic + + converter_logic._phase = ConversionPhase.RUNNING + converter_logic.refresh_view() + converter_logic._emit_view_model("running", 0.5) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True + assert dpg.get_item_configuration(GUIConverterPanel._row_tag(path, SUF_BUTTON))["enabled"] is False + + def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> None: + first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + converter_logic = app._main_tab._converter_logic + + converter_logic.isolate_source(second) + + assert dpg.does_item_exist(GUIConverterPanel._level_tag(0, SUF_TABLE)) + assert dpg.does_item_exist(GUIConverterPanel._level_tag(1, SUF_TABLE)) + assert dpg.does_item_exist(GUIConverterPanel._level_tag(2, SUF_STRIP)) + assert dpg.get_item_parent(GUIConverterPanel._row_tag(first, SUF_GROUP)) == GUIConverterPanel._level_tag( + 0, SUF_TABLE + ) + assert dpg.get_item_parent(GUIConverterPanel._row_tag(second, SUF_GROUP)) == GUIConverterPanel._level_tag( + 1, SUF_TABLE + ) + + def test_a_row_carries_a_handle_to_drag_it_by(self, app: Application, tmp_path: Path) -> None: + path = self._gather(app, tmp_path, ["a.wav"])[0] + + assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_HANDLE)) + + def test_dropping_a_recording_on_a_row_joins_that_rows_level(self, app: Application, tmp_path: Path) -> None: + first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + converter_logic = app._main_tab._converter_logic + converter_logic.isolate_source(second) + + panel = app._main_tab._converter_panel + panel._on_dropped_on_source(dpg.get_alias_id(GUIConverterPanel._row_tag(second, SUF_TEXT)), str(first)) + + assert converter_logic._levels.level_count == 1 + + def test_dropping_a_recording_in_a_gap_opens_a_level(self, app: Application, tmp_path: Path) -> None: + first, _second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + converter_logic = app._main_tab._converter_logic + + panel = app._main_tab._converter_panel + panel._on_dropped_on_level(dpg.get_alias_id(GUIConverterPanel._level_tag(1, SUF_STRIP)), str(first)) + + assert converter_logic._levels.level_count == 2 + assert converter_logic._levels.level_of(first) == 1 + + def test_the_order_explanation_leaves_with_the_control_it_belongs_to(self, app: Application) -> None: + """A tooltip left live over a hidden widget's rectangle explains whatever moved into it.""" + converter_logic = app._main_tab._converter_logic + + converter_logic.set_stems_mode(True) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is True + + converter_logic.set_stems_mode(False) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is False + + def test_a_recording_holding_no_channel_greys_out_but_stays_listed( + self, + app: Application, + tmp_path: Path, + ) -> None: + path = self._gather(app, tmp_path, ["a.wav"])[0] + + app._main_tab._converter_logic.set_source_channels(path, frozenset()) + + assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_GROUP)) + assert dpg.get_item_configuration(GUIConverterPanel._row_tag(path, SUF_TEXT))["enabled"] is False From e42e275d0c95de5f5b98fe8d31084355faec7c12 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 04:42:28 +0200 Subject: [PATCH 046/142] Extended: the browser with stems gestures --- .../categories/elements/main.py | 2 + .../coordinators/tabs/main.py | 59 ++++++++--- .../ui/panels/main/explorer.py | 20 +++- src/sampletones_config/lang/en.yaml | 2 + .../coordinators/tabs/test_main.py | 99 ++++++++++++++++++- 5 files changed, 165 insertions(+), 17 deletions(-) diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 9366e02ef..5624a505a 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -8,6 +8,8 @@ class ExplorerElements(AbstractElement): CONTEXT_LOAD_LIBRARY = "context_load_library" CONTEXT_RECONSTRUCT_FILE = "context_reconstruct_file" CONTEXT_RECONSTRUCT_DIRECTORY = "context_reconstruct_directory" + CONTEXT_ADD_STEM = "context_add_stem" + CONTEXT_ADD_FOLDER_STEMS = "context_add_folder_stems" CONTEXT_SET_LIBRARY_DIRECTORY = "context_set_library_directory" CONTEXT_SET_OUTPUT_DIRECTORY = "context_set_output_directory" STATUS_REFRESH = "status_refresh" diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index b6457d82c..8bf80a5a0 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -229,6 +229,8 @@ def __init__( on_wave_file_clicked=self._on_wave_file_clicked, on_directory_clicked=self._on_directory_clicked, on_directory_add_requested=self._on_directory_add_requested, + on_file_add_requested=self._on_file_add_requested, + can_add_stems=self._can_add_stems, on_reconstruct_file=self._request_reconstruct_file, on_reconstruct_directory=self._request_reconstruct_directory, on_load_reconstruction=on_load_reconstruction, @@ -296,13 +298,41 @@ def _request_reconstruct_file(self, filepath: Path) -> None: if self._notify_converter_running(): return - self._on_reconstruct_file(filepath) + self._leaving_stems_mode(lambda: self._on_reconstruct_file(filepath)) def _request_reconstruct_directory(self, directory_path: Path) -> None: if self._notify_converter_running(): return - self._on_reconstruct_directory(directory_path) + self._leaving_stems_mode(lambda: self._on_reconstruct_directory(directory_path)) + + def _leaving_stems_mode(self, reconstruct: VoidCallback) -> None: + """Runs a conversion the browser asked for, asking first where it would drop a stems list. + + A Reconstruct names one file or one folder, which is what a classic conversion converts, so + the gathered recordings are what the reader is being asked about. Declining leaves the + setup as it stands and starts nothing. + """ + if not self._converter_logic.stems_mode: + reconstruct() + return + + self._confirm_discarding_stems(lambda: self._reconstruct_without_stems(reconstruct)) + + def _reconstruct_without_stems(self, reconstruct: VoidCallback) -> None: + self._converter_logic.set_stems_mode(False) + reconstruct() + + def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None: + self._dialogs.show_confirmation( + TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, + self._language_manager["main.converter.message.discard_stems_prompt"], + self._language_manager["main.converter.title.discard_stems_dialog"], + on_confirm, + ok_label=self._language_manager["main.converter.label.discard_stems_button"], + cancel_label=self._language_manager["main.converter.label.keep_stems_button"], + on_cancel=self._converter_logic.refresh_view, + ) def _notify_converter_running(self) -> bool: if not self._is_operation_active(): @@ -349,15 +379,19 @@ def _request_stems_mode(self, stems_mode: bool) -> None: self._converter_logic.set_stems_mode(stems_mode) return - self._dialogs.show_confirmation( - TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, - self._language_manager["main.converter.message.discard_stems_prompt"], - self._language_manager["main.converter.title.discard_stems_dialog"], - lambda: self._converter_logic.set_stems_mode(False), - ok_label=self._language_manager["main.converter.label.discard_stems_button"], - cancel_label=self._language_manager["main.converter.label.keep_stems_button"], - on_cancel=self._converter_logic.refresh_view, - ) + self._confirm_discarding_stems(lambda: self._converter_logic.set_stems_mode(False)) + + def _can_add_stems(self) -> bool: + """A stems list is being gathered and is free to take another recording.""" + return self._converter_logic.stems_mode and not self._is_operation_active() + + def _on_file_add_requested(self, filepath: Path) -> None: + """Gathers one recording into a stems conversion, opening one where none is being built.""" + if self._is_operation_active(): + return + + self._converter_logic.set_stems_mode(True) + self._converter_logic.add_sources([filepath]) def _on_directory_add_requested(self, directory_path: Path) -> None: """Offers a folder's recordings to a stems conversion, asking which ones where they overflow. @@ -365,13 +399,14 @@ def _on_directory_add_requested(self, directory_path: Path) -> None: Where the folder holds no more than the list has room for, every recording joins at once. A fuller folder raises the selection window, which shows what fits already ticked. """ - if self._is_operation_active() or not self._converter_logic.stems_mode: + if self._is_operation_active(): return candidates = top_level_audio_files(directory_path) if not candidates: return + self._converter_logic.set_stems_mode(True) room = self._converter_logic.room_for_sources if len(candidates) <= room: self._converter_logic.add_sources(candidates) diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 41339e46f..81251d3c2 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, List, Optional, Protocol, Tuple +from typing import Any, Callable, List, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -95,6 +95,8 @@ def __init__( self.on_wave_file_clicked: Optional[PathCallback] = None self.on_directory_clicked: Optional[PathCallback] = None self.on_directory_add_requested: Optional[PathCallback] = None + self.on_file_add_requested: Optional[PathCallback] = None + self.can_add_stems: Optional[Callable[[], bool]] = None self.on_reconstruct_directory: Optional[PathCallback] = None self.on_reconstruct_file: Optional[PathCallback] = None self.on_load_reconstruction: Optional[PathCallback] = None @@ -333,12 +335,16 @@ def _directory_node_clicked( node: FileSystemNode, node_tag: str, ) -> None: - """Answers a click on a folder: Ctrl offers its recordings, a plain click opens it.""" + """Answers a click on a folder: Ctrl offers its recordings to a stems list, else it opens. + + The modifier reaches the stems list only while one is being gathered, so a Ctrl-click with + nothing to gather into opens the folder the way a plain click does. + """ has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: return - if Modifier.CTRL in capture_modifiers(): + if Modifier.CTRL in capture_modifiers() and self.query(self.can_add_stems, default=False): self.call(self.on_directory_add_requested, node.filepath) return @@ -410,6 +416,10 @@ def _add_context_menu_file_actions(self, node: FileSystemNode) -> None: label=self._language_manager["main.explorer.label.context_reconstruct_file"], callback=lambda: self._context_reconstruct_file(node), ) + dpg.add_menu_item( + label=self._language_manager["main.explorer.label.context_add_stem"], + callback=lambda: self.call(self.on_file_add_requested, node.filepath), + ) def _show_file_context_menu(self, node: FileSystemNode) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: @@ -431,6 +441,10 @@ def _add_context_menu_reconstruction_directory( label=self._language_manager["main.explorer.label.context_reconstruct_directory"], callback=lambda: self._context_reconstruct_directory(node), ) + dpg.add_menu_item( + label=self._language_manager["main.explorer.label.context_add_folder_stems"], + callback=lambda: self.call(self.on_directory_add_requested, node.filepath), + ) def _add_context_menu_set_directory_items(self, node: FileSystemNode) -> None: dpg.add_separator() diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 3ce1e23b1..538611a7f 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -289,6 +289,8 @@ main.explorer.label.context_load_reconstruction: "Load reconstruction" main.explorer.label.context_load_library: "Load instructions library" main.explorer.label.context_reconstruct_file: "Reconstruct file" main.explorer.label.context_reconstruct_directory: "Reconstruct directory" +main.explorer.label.context_add_stem: "Add as stem" +main.explorer.label.context_add_folder_stems: "Add folder as stems" main.explorer.label.context_set_library_directory: "Set as instructions library directory" main.explorer.label.context_set_output_directory: "Set as output directory" main.explorer.message.status_node_audio_no_autoplay: "Double-click to reconstruct audio. Right-click to open context menu." diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 8eddaaa47..d9f5a71a2 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -2,6 +2,8 @@ from typing import Final from unittest.mock import MagicMock +import pytest + from sampletones_application.constants.conversion import MAX_STEM_SOURCES from sampletones_application.coordinators.tabs.main import MainTabCoordinator from sampletones_application.logic.main.converter import ConversionSuccess @@ -31,6 +33,8 @@ def _coordinator(*, operation_active: bool) -> MainTabCoordinator: coordinator._language_manager = FakeLanguageManager() coordinator._on_reconstruct_file = MagicMock() coordinator._on_reconstruct_directory = MagicMock() + coordinator._converter_logic = MagicMock() + coordinator._converter_logic.stems_mode = False return coordinator @@ -173,6 +177,7 @@ def _stems_coordinator( ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) coordinator._is_operation_active = lambda: operation_active + coordinator._notify_converter_running = lambda: operation_active coordinator._dialogs = MagicMock() coordinator._language_manager = FakeLanguageManager() coordinator._converter_logic = MagicMock() @@ -269,13 +274,15 @@ def test_a_folder_holding_no_recordings_is_left_alone(self, tmp_path: Path) -> N coordinator._converter_logic.add_sources.assert_not_called() coordinator._stem_selection_window.open.assert_not_called() - def test_a_classic_conversion_ignores_the_gesture(self, tmp_path: Path) -> None: + def test_a_classic_conversion_starts_gathering(self, tmp_path: Path) -> None: + """The gesture is what starts a stems conversion, so it turns the mode on to answer.""" (tmp_path / "a.wav").touch() coordinator = _stems_coordinator(stems_mode=False) coordinator._on_directory_add_requested(tmp_path) - coordinator._converter_logic.add_sources.assert_not_called() + coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) + assert coordinator._converter_logic.add_sources.call_args.args[0][0].name == "a.wav" def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: (tmp_path / "a.wav").touch() @@ -284,3 +291,91 @@ def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: coordinator._on_directory_add_requested(tmp_path) coordinator._converter_logic.add_sources.assert_not_called() + + +class TestFileAdd: + """A recording added from the browser's menu joins a stems list, opening one where none stands.""" + + def test_a_recording_joins_the_list(self, tmp_path: Path) -> None: + recording = tmp_path / "bass.wav" + recording.touch() + coordinator = _stems_coordinator() + + coordinator._on_file_add_requested(recording) + + coordinator._converter_logic.add_sources.assert_called_once_with([recording]) + + def test_adding_from_a_classic_conversion_turns_stems_mode_on(self, tmp_path: Path) -> None: + recording = tmp_path / "bass.wav" + recording.touch() + coordinator = _stems_coordinator(stems_mode=False) + + coordinator._on_file_add_requested(recording) + + coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) + + def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(operation_active=True) + + coordinator._on_file_add_requested(tmp_path / "bass.wav") + + coordinator._converter_logic.add_sources.assert_not_called() + + +class TestModifierAddAvailability: + """The modifier click reaches a stems list only while one stands ready to take a recording.""" + + def test_a_gathered_list_takes_the_click(self) -> None: + assert _stems_coordinator()._can_add_stems() is True + + def test_a_classic_conversion_leaves_the_click_alone(self) -> None: + assert _stems_coordinator(stems_mode=False)._can_add_stems() is False + + def test_a_busy_application_leaves_the_click_alone(self) -> None: + assert _stems_coordinator(operation_active=True)._can_add_stems() is False + + +class TestReconstructLeavesStemsMode: + """A Reconstruct names what a classic conversion converts, so a gathered list is asked about.""" + + def _coordinator(self, *, stems_mode: bool) -> MainTabCoordinator: + coordinator = _stems_coordinator(stems_mode=stems_mode) + coordinator._on_reconstruct_file = MagicMock() + coordinator._on_reconstruct_directory = MagicMock() + return coordinator + + def test_a_classic_conversion_reconstructs_straight_away(self, tmp_path: Path) -> None: + coordinator = self._coordinator(stems_mode=False) + + coordinator._request_reconstruct_file(tmp_path / "a.wav") + + coordinator._on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") + coordinator._dialogs.show_confirmation.assert_not_called() + + @pytest.mark.parametrize("gesture", ["_request_reconstruct_file", "_request_reconstruct_directory"]) + def test_a_gathered_list_is_asked_about_first(self, tmp_path: Path, gesture: str) -> None: + coordinator = self._coordinator(stems_mode=True) + + getattr(coordinator, gesture)(tmp_path) + + coordinator._on_reconstruct_file.assert_not_called() + coordinator._on_reconstruct_directory.assert_not_called() + assert coordinator._dialogs.show_confirmation.call_args.args[1] == DISCARD_STEMS_PROMPT_KEY + + def test_confirming_leaves_stems_mode_and_converts(self, tmp_path: Path) -> None: + coordinator = self._coordinator(stems_mode=True) + + coordinator._request_reconstruct_directory(tmp_path) + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) + coordinator._on_reconstruct_directory.assert_called_once_with(tmp_path) + + def test_declining_converts_nothing(self, tmp_path: Path) -> None: + coordinator = self._coordinator(stems_mode=True) + + coordinator._request_reconstruct_directory(tmp_path) + coordinator._dialogs.show_confirmation.call_args.kwargs["on_cancel"]() + + coordinator._converter_logic.set_stems_mode.assert_not_called() + coordinator._on_reconstruct_directory.assert_not_called() From 312c5abf90c5ca29d79710e712b736f01aa8f17c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 04:58:48 +0200 Subject: [PATCH 047/142] Fixed: conversion progress and dialog sizing --- .../categories/elements/main.py | 1 + .../logic/main/converter.py | 31 +++++++++++++--- .../ui/elements/window.py | 30 ++++++++++++---- .../utils/gui/dialogs/windows/confirmation.py | 2 ++ .../gui/dialogs/windows/save_confirmation.py | 2 ++ src/sampletones_config/lang/en.yaml | 1 + .../logic/main/test_converter.py | 35 +++++++++++++++++++ 7 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 5624a505a..ead4bb350 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -71,6 +71,7 @@ class ConverterElements(AbstractElement): CANCEL_DIALOG = "cancel_dialog" CANCEL_PROMPT = "cancel_prompt" PROGRESS_TEMPLATE = "progress_template" + SINGLE_PROGRESS_TEMPLATE = "single_progress_template" CONVERT_LABEL_TEMPLATE = "convert_label_template" STEMS_MODE = "stems_mode" STEMS_MODE_TOOLTIP = "stems_mode_tooltip" diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 8f5e027e2..8b1ae38ea 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, FrozenSet, Optional, Protocol, Sequence, Tuple +from typing import Callable, Final, FrozenSet, Optional, Protocol, Sequence, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -48,6 +48,8 @@ from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.paths import to_path +SINGLE_JOB: Final[int] = 1 + @dataclass(frozen=True) class ConversionSuccess: @@ -336,10 +338,7 @@ def _handle_progress_result(self, progress: ServiceProgress[Path]) -> None: self._system_progress.set(progress.completed, progress.total) eta_string = ETAEstimator.format_duration(progress.eta_seconds) total = max(progress.total, 1) - status_text = self._language_manager["main.converter.template.progress_template"].format( - progress.completed, progress.total - ) - + status_text = self._compose_progress_text(progress) if eta_string: status_text += self._language_manager["global.dialog.template.time_estimation"].format( eta_string=eta_string @@ -354,6 +353,28 @@ def _handle_progress_result(self, progress: ServiceProgress[Path]) -> None: input_path=display_input_path, ) + def _compose_progress_text(self, progress: ServiceProgress[Path]) -> str: + """What the run is doing: the reconstruction being built, or how far a batch has come. + + A batch is many reconstructions and a count says where it stands; a single job counts to + one, so it names the document it is writing instead. + """ + if progress.total > SINGLE_JOB: + return self._language_manager["main.converter.template.progress_template"].format( + progress.completed, progress.total + ) + + return self._language_manager["main.converter.template.single_progress_template"].format( + self._reconstruction_name() + ) + + def _reconstruction_name(self) -> str: + """The document a single job writes, which is what a run of one is making.""" + if self._output_path is not None: + return self._output_path.stem + + return self._input_path.stem if self._input_path is not None else "" + def _handle_library_progress(self, progress: TaskProgress) -> None: if self._phase != ConversionPhase.WAITING: return diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index 4a832b97e..7016fcb53 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -7,10 +7,11 @@ from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG_WINDOW from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.align import center_item +from sampletones_application.utils.gui.align import center_item, center_when_settled from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.types.data import SerializedData class GUIWindow(GUIPanel, ABC): @@ -26,8 +27,13 @@ class GUIWindow(GUIPanel, ABC): A dialog that raises another modal — a prompt, a countdown — hands the screen over with ``yield_to`` and takes it back with ``resume``, which is what keeps the two from competing for the one modal DearPyGui carries at a time. + + A window holding prose of a length it learns at the moment it opens sets + ``_fits_content``, which lets it grow past the height it states. """ + _fits_content: bool = False + def center(self) -> None: center_item(self.tag) @@ -60,25 +66,31 @@ def dialog_window( ) -> Iterator[None]: """Open this window's modal frame, with the block's widgets building inside it. - The window holds the width it states and fits its height to the content it is given, which - is what lets a field, a combo or a button stretch across it: a stretched item measures one - pixel inside the region it is offered, so a window sized from its own content would take - that pixel back on every frame. A stated width settles the geometry in one pass and gives - every dialog the same reading width whatever it holds. + The window holds the width it states, which is what lets a field, a combo or a button + stretch across it: a stretched item measures one pixel inside the region it is offered, so + a window sized from its own content would take that pixel back on every frame. A stated + width settles the geometry in one pass and gives every dialog the same reading width + whatever it holds. + + A window that sets ``_fits_content`` reads its stated height as a floor and grows to hold + what it is given, so a prompt whose text wraps over several lines shows all of it. A dialog offers the title bar's close button when ``on_close`` names what closing means, and omits it otherwise, so the only way out of a window is one the window answers for. """ + geometry: SerializedData = ( + {"min_size": (self.width, self.height), "autosize": True} if self._fits_content else {"height": self.height} + ) with dpg.window( tag=self.tag, label=label, width=self.width, - height=self.height, no_resize=True, no_collapse=True, no_close=on_close is None, on_close=on_close, modal=True, + **geometry, ): yield @@ -87,6 +99,10 @@ def show(self, *args: Any, **kwargs: Any) -> None: self.prepare(*args, **kwargs) self.create_window() ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG_WINDOW).bind_to_item(self.tag) + if self._fits_content: + center_when_settled(self.tag) + return + dpg.split_frame() self.center() diff --git a/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py index d72449cb9..cbdb37d6b 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py @@ -35,6 +35,8 @@ class GUIConfirmationWindow(GUIDialogWindow): keyboard alone. """ + _fits_content = True + def __init__( self, tag: str, diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py index ed5f03e8b..d3b2d8bf9 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -30,6 +30,8 @@ class GUISaveConfirmationWindow(GUIDialogWindow): the prompt. """ + _fits_content = True + def __init__( self, tag: str, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 538611a7f..18b610694 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -354,6 +354,7 @@ main.converter.message.load_file_prompt: "The reconstruction is ready. Load it n main.converter.message.load_directory_prompt: "The reconstructions are ready. Open the Reconstruction tab?" main.converter.message.cancel_prompt: "Stop the current reconstruction?" main.converter.template.progress_template: "Progress: {}/{} files" +main.converter.template.single_progress_template: "Reconstructing {}..." main.converter.template.convert_label_template: "{}: {}" main.converter.label.stems_mode: "Stems mode" main.converter.label.channel_cap: "Channels per source" diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index aca535537..e82a8aec5 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -9,6 +9,7 @@ ConversionSuccess, ConverterLogic, ) +from sampletones_application.services.result import ServiceProgress from sampletones_application.view_model.main.converter import ( ACTIVE_PHASES, ConversionPhase, @@ -25,6 +26,9 @@ "main.converter.label.convert_directory_button": "Convert directory", "main.converter.label.cancel_button": "Cancel", "main.converter.template.convert_label_template": "{}: {}", + "main.converter.template.progress_template": "Progress: {}/{} files", + "main.converter.template.single_progress_template": "Reconstructing {}...", + "global.dialog.template.time_estimation": "", } @@ -584,3 +588,34 @@ def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: Co assert view_model.has_input is False assert view_model.convert_button_enabled is False + + +class TestProgressText: + """A batch counts the files it has written; a single job names the reconstruction it is making.""" + + def _progress(self, completed: int, total: int) -> ServiceProgress[Path]: + return ServiceProgress(completed=completed, total=total, eta_seconds=None, current_item=None) + + def _status(self, converter_logic: ConverterLogic) -> str: + view_model = converter_logic.on_view_changed.call_args.args[0] + return str(view_model.status_text) + + def test_a_batch_counts_its_files(self, converter_logic: ConverterLogic) -> None: + converter_logic._handle_progress_result(self._progress(2, 5)) + + assert self._status(converter_logic) == "Progress: 2/5 files" + + def test_a_single_job_names_the_reconstruction_it_writes(self, converter_logic: ConverterLogic) -> None: + converter_logic._output_path = Path("/reconstructions/track.stn") + + converter_logic._handle_progress_result(self._progress(0, 1)) + + assert self._status(converter_logic) == "Reconstructing track..." + + def test_a_single_job_falls_back_to_the_selected_input(self, converter_logic: ConverterLogic) -> None: + converter_logic._output_path = None + converter_logic._input_path = Path("/audio/kick.wav") + + converter_logic._handle_progress_result(self._progress(0, 1)) + + assert self._status(converter_logic) == "Reconstructing kick..." From 94513f110b6253fd4bb47d9280971f1579c38d3f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 05:06:02 +0200 Subject: [PATCH 048/142] Documented: the converter's stems gestures --- docs/concepts/stems.md | 8 +++++--- docs/guide/interface.md | 29 ++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index f9d5a7d11..b40b64e19 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -144,9 +144,11 @@ parallel to the instruction streams. Every reconstruction carries one. The stems setup is built per conversion from the sources and the reader's choices and travels with the job; it is part of the request rather than of the -standard configuration. The assignment is greedy per frame: continuity of *who* -owns a channel across frames, and playback that decides per frame on the -recorded streams, are future work. +standard configuration. A source the reader left holding no channel takes no part: +the recordings and the entries are derived in one pass, so such a source reaches +neither, and the target stays what the covered channels can render. The assignment +is greedy per frame: continuity of *who* owns a channel across frames, and playback +that decides per frame on the recorded streams, are future work. ## The recorded stems in the application diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 8c3dd5c28..0639ca960 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -25,18 +25,33 @@ clicking either path shows it in your file manager. When a run writes one reconstruction, **Load** opens it on the **Reconstructions** tab; a whole folder of them offers **Open** instead. **Cancel** stops a run, and only one runs at a time. -**Stems mode** turns the card into a list: tick it, then click each recording you -want mixed into one reconstruction, and Ctrl-click a folder to offer everything in -it at once. Each row names its recording, the channels that recording may use, and -a **Level** — the sources on level 1 choose their channels before those on level 2, -so a lead can take what it needs before a pad does. **Order** decides how the levels -take turns, and **x** takes a row out. Untick **Stems mode** and the first recording -stays as your single selection. +**Stems mode** turns the card into a list of the recordings mixed into one +reconstruction. Tick it and click each recording in the browser, or right-click one +and choose **Add as stem** — that starts a stems conversion from a classic one in a +single step. **Add folder as stems** offers everything in a folder, as does +Ctrl-clicking it while you are gathering; where a folder holds more recordings than +the list has room for, you pick which ones. + +Each row names its recording and carries a checkbox per channel that recording may +use. Untick them all and the row greys out: that recording takes no part in the +conversion, and its row stays listed so you can bring it back. + +The rows sit under **level** bands, and a level is a turn to choose: every recording +on level 1 picks its channels before any on level 2, so a lead can take what it needs +before a pad does. Drag a row by its handle onto another row to share that row's +level, or onto the gap between two levels to give it a level of its own. +Right-clicking a row names the same moves in words. **Order** decides how the levels +take turns — round by round, or one level filled before the next picks — and **x** +takes a row out. Untick **Stems mode** and the first recording stays as your single +selection. **Channels per source** caps how many channels one recording may hold in a single frame, and it applies to every conversion — one file, a whole folder, or a stems mix. Leaving it at one channel per source gives each recording a single voice. +Reconstructing a file or a folder from the browser converts that one thing, so while +you are gathering stems it asks before dropping the list. + A few settings are worth knowing before you convert. Under **Reconstructor settings**, the **Channels** toggles choose which channels take part — at least one must be on — and **Drive** sets how hard they are pushed. **General settings** From 125e81d7b41f2849f1c7cb9625e1fd3c99ee235b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 11:04:28 +0200 Subject: [PATCH 049/142] Flattened: a project into the player's channel streams --- docs/development/packages.md | 10 +- .../constants/playback.py | 14 -- .../playback/synthesizer/__init__.py | 6 - .../sequencer/playback/synthesizer/length.py | 2 +- .../sequencer/playback/synthesizer/state.py | 41 +--- .../playback/synthesizer/synthesizer.py | 76 ++------ src/sampletones_core/performance/__init__.py | 16 ++ .../performance}/modifiers.py | 0 src/sampletones_core/performance/rows.py | 79 ++++++++ src/sampletones_core/performance/song.py | 98 ++++++++++ src/sampletones_core/performance/state.py | 46 +++++ src/sampletones_core/performance/ticks.py | 47 +++++ .../performance}/voice.py | 0 src/sampletones_core/timing/__init__.py | 5 + src/sampletones_core/timing/bounds.py | 15 ++ .../timing/song.py} | 13 +- src/sampletones_player/builder.py | 37 ++++ tests/integration/nsf/test_song_export.py | 95 ++++++++++ tests/suite/performance.py | 132 +++++++++++++ .../logic/sequencer/playback/conftest.py | 86 +-------- .../sequencer/playback/test_song_length.py | 2 +- .../sequencer/playback/test_synthesizer.py | 77 ++++---- .../sequencer/playback/test_tick_clock.py | 5 +- .../sampletones_core/performance/__init__.py | 0 .../performance/test_modifiers.py} | 4 +- .../sampletones_core/performance/test_rows.py | 177 ++++++++++++++++++ .../sampletones_core/performance/test_song.py | 81 ++++++++ .../performance/test_ticks.py | 104 ++++++++++ .../performance}/test_voice.py | 2 +- tests/unit/sampletones_player/test_builder.py | 68 +++++++ 30 files changed, 1088 insertions(+), 250 deletions(-) create mode 100644 src/sampletones_core/performance/__init__.py rename src/{sampletones_application/logic/sequencer/playback/synthesizer => sampletones_core/performance}/modifiers.py (100%) create mode 100644 src/sampletones_core/performance/rows.py create mode 100644 src/sampletones_core/performance/song.py create mode 100644 src/sampletones_core/performance/state.py create mode 100644 src/sampletones_core/performance/ticks.py rename src/{sampletones_application/logic/sequencer/playback/synthesizer => sampletones_core/performance}/voice.py (100%) create mode 100644 src/sampletones_core/timing/bounds.py rename src/{sampletones_application/logic/sequencer/playback/synthesizer/timing.py => sampletones_core/timing/song.py} (77%) create mode 100644 tests/integration/nsf/test_song_export.py create mode 100644 tests/suite/performance.py create mode 100644 tests/unit/sampletones_core/performance/__init__.py rename tests/unit/{sampletones_application/logic/sequencer/playback/test_apply_modifiers.py => sampletones_core/performance/test_modifiers.py} (98%) create mode 100644 tests/unit/sampletones_core/performance/test_rows.py create mode 100644 tests/unit/sampletones_core/performance/test_song.py create mode 100644 tests/unit/sampletones_core/performance/test_ticks.py rename tests/unit/{sampletones_application/logic/sequencer/playback => sampletones_core/performance}/test_voice.py (99%) diff --git a/docs/development/packages.md b/docs/development/packages.md index 3fbfa745b..30fc56817 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -44,7 +44,7 @@ graph TD | `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, calibration, and these boundaries themselves — reached as package data rather than by import | — | | `sampletones_assets` | The application mark and the bundled fonts, with the code that draws the mark | `sampletones_shared` | | `sampletones_synthesis` | Analytic waveform synthesis: oscillators, envelopes, layers and voices | `sampletones_shared` | -| `sampletones_core` | The reconstruction engine, the project model, and the tracker export formats | `sampletones_shared`, `sampletones_synthesis` | +| `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats | `sampletones_shared`, `sampletones_synthesis` | | `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file | `sampletones_shared`, `sampletones_core` | | `sampletones_application` | The DearPyGui front end | `sampletones_shared`, `sampletones_core`, `sampletones_player` | | `sampletones` | The command-line entry point and the startup self-check | `sampletones_shared`, `sampletones_core`, `sampletones_application` | @@ -57,6 +57,14 @@ player's format move while the engine holds still. The consequence is that an ex reaching the console — the seam `sampletones_core/exports/backend.py` describes — is registered from above rather than from the engine's own registry. +**A song is played out once, for every reader of it.** Turning an arrangement into the +instruction each channel sounds on each engine tick — the order walked frame by frame, a row's note +column starting a sample, its transpose and volume bending what the sample carries, a looping sample +wrapping where a one-shot falls silent — is `sampletones_core/performance/`. The sequencer renders +those instructions to audio and the player encodes them into register values, so what a listener +hears and what the console plays are the same walk read two ways rather than two implementations of +one rule. + **Equal temperament sits at the bottom.** The MIDI pitch limits and the A4 reference are `sampletones_shared/constants/music.py`, and the pitch-to-frequency conversion they govern is `sampletones_shared/utils/frequencies.py` — so the synthesis package reads them without reaching up diff --git a/src/sampletones_application/constants/playback.py b/src/sampletones_application/constants/playback.py index 31324fedc..307d89fda 100644 --- a/src/sampletones_application/constants/playback.py +++ b/src/sampletones_application/constants/playback.py @@ -1,11 +1,6 @@ from enum import StrEnum -from math import ceil from typing import Final -from sampletones_core.timing import RowRate -from sampletones_shared.constants.nes import MAX_NES_FREQUENCY -from sampletones_shared.constants.project import MAX_SPEED, MIN_TEMPO - class FollowMode(StrEnum): """How far the sequencer view chases the playhead during song playback. @@ -30,12 +25,3 @@ def follows_row(self) -> bool: DEFAULT_FOLLOW_MODE: Final[FollowMode] = FollowMode.ROWS - -MIN_TICKS_PER_ROW: Final[int] = 1 -MAX_TICKS_PER_ROW: Final[int] = ceil( - RowRate.from_parameters( - tempo=MIN_TEMPO, - speed=MAX_SPEED, - nes_frequency=MAX_NES_FREQUENCY, - ).ticks_per_row -) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py index a60f7ba81..e88fdc0a7 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -1,12 +1,9 @@ from .bank import ChannelBank from .frames import RowFrames from .length import SongLength -from .modifiers import apply_modifiers from .rates import EngineRates from .state import ChannelState from .synthesizer import RowSynthesizer -from .timing import SongTiming -from .voice import SampleVoice __all__ = [ "ChannelBank", @@ -14,8 +11,5 @@ "EngineRates", "RowFrames", "RowSynthesizer", - "SampleVoice", "SongLength", - "SongTiming", - "apply_modifiers", ] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py index 718e9ff5c..e75fac730 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py @@ -2,9 +2,9 @@ from typing import Self from sampletones_core.project import Project +from sampletones_core.timing import SongTiming from .rates import EngineRates -from .timing import SongTiming @dataclass(frozen=True) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py index 5af877fe1..9f757221d 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py @@ -1,53 +1,32 @@ from dataclasses import dataclass, field -from typing import Dict, Optional -from sampletones_core.constants.enums import FeatureKey -from sampletones_core.constants.general import MAX_VOLUME -from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.performance import ChannelPerformance from ..protocol import ChannelGeneratorProtocol @dataclass class ChannelState: - """What one channel carries from row to row. + """One channel of the synthesiser: the voice filling its ticks, beside what it carries. - A pattern states a channel's instrument, transpose, and volume only where it changes them, so - the channel keeps the last of each until another row states otherwise. The tick index is how - far into the sounding sample's instructions the channel has played, which is what lets a note - sustain across rows. - - The channel carries a value per envelope dimension too, which is what an instrument leaving a - dimension to the channel sounds at. A frame the instrument writes hands its value over, so the - channel keeps the last one written for as long as the song runs. + The pattern state is the engine's own (:class:`~sampletones_core.performance.state.ChannelPerformance`), + so what a channel is sounding, how far into it, and at what transpose and volume are read + the same here as anywhere else a song is played out. The generator is what makes this the + audible reading of it: it holds the timer phase across ticks and rows, so a note sustained + over several rows keeps one continuous waveform. Attributes: generator: The synthesiser filling the channel's ticks. - sample_id: The sample the channel is sounding, or ``None`` while it is silent. - tick_index: How many ticks of that sample's instructions the channel has played. - transpose: The semitone offset a row last set. - volume: The level a row last set. - feature_values: The value the channel holds for each envelope dimension. + performance: What the channel carries from row to row. """ generator: ChannelGeneratorProtocol - sample_id: Optional[str] = field(default=None) - tick_index: int = field(default=0) - transpose: int = field(default=0) - volume: int = field(default=MAX_VOLUME) - feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy) + performance: ChannelPerformance = field(default_factory=ChannelPerformance) def reset(self) -> None: """Returns the channel to silence at full volume, as a song starts it. - The envelope dimensions return to the values a channel holds from the start of a song, - so a pass through the song sounds the same however the previous one left them. - The generator is kept, since it is built from the rates in force rather than from anything a song reaches. """ - self.sample_id = None - self.tick_index = 0 - self.transpose = 0 - self.volume = MAX_VOLUME - self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy() + self.performance.reset() diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 411164afe..5fd054bb2 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -7,23 +7,17 @@ from sampletones_core.audio import clip_audio_inplace, silence from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.instructions import InstructionUnion +from sampletones_core.performance import SampleVoice, apply_row, resolve_row, sound_tick from sampletones_core.project import Project -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition -from sampletones_core.timing import Groove +from sampletones_core.timing import Groove, SongTiming from .bank import ChannelBank from .frames import RowFrames -from .modifiers import apply_modifiers from .rates import EngineRates from .state import ChannelState -from .timing import SongTiming -from .voice import SampleVoice class RowSynthesizer: @@ -198,11 +192,11 @@ def _render_channel( ) -> np.ndarray: state = channels.state(channel_name) - row = self._resolve_row(channel_name, song) - if row is not None: - self._apply_row_to_state(state, row) + row = resolve_row(song, self._position, channel_name) + if row is not None and apply_row(state.performance, row): + state.generator.reset() - sample_id = state.sample_id + sample_id = state.performance.sample_id if sample_id is None or channel_name not in self._active_channels(): return silence(frames.total) @@ -214,42 +208,6 @@ def _render_channel( frames, ) - def _resolve_row( - self, - channel_name: ChannelName, - song: Song, - ) -> Optional[Row]: - if self._position.order_position >= song.order_length(): - return None - - order_entry = song.order[self._position.order_position].get(channel_name) - if order_entry is None: - return None - - pattern = song.pattern(channel_name, order_entry) - if pattern is None or self._position.row_index >= len(pattern.rows): - return None - - return pattern.rows[self._position.row_index] - - def _apply_row_to_state(self, state: ChannelState, row: Row) -> None: - match row.command: - case Instrument() as instrument: - state.generator.reset() - state.sample_id = instrument.sample_id - state.tick_index = 0 - state.transpose = row.transpose if row.transpose is not None else 0 - state.volume = row.volume if row.volume is not None else MAX_VOLUME - case NoteOff(): - state.generator.reset() - state.sample_id = None - state.tick_index = 0 - case None: - if row.transpose is not None: - state.transpose = row.transpose - if row.volume is not None: - state.volume = row.volume - def _synthesize_ticks( self, state: ChannelState, @@ -280,7 +238,6 @@ def _synthesize_ticks( voice, ) output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame - state.tick_index += 1 return output @@ -293,22 +250,17 @@ def _synthesize_tick( frame_length: int, voice: SampleVoice, ) -> np.ndarray: - if loop: - instruction = instructions[state.tick_index % len(instructions)] - elif state.tick_index < len(instructions): - instruction = instructions[state.tick_index] - else: + instruction = sound_tick( + state.performance, + instructions, + loop=loop, + voice=voice, + ) + if instruction is None: return silence_frame state.generator.frame_length = frame_length - return state.generator( - apply_modifiers( - voice.sound(instruction, state.feature_values), - state.transpose, - state.volume, - ), - save=True, - ) + return state.generator(instruction, save=True) def _advance_position(self, song: Song) -> None: self._position.advance(song.rows_per_pattern, song.order_length()) diff --git a/src/sampletones_core/performance/__init__.py b/src/sampletones_core/performance/__init__.py new file mode 100644 index 000000000..48259f614 --- /dev/null +++ b/src/sampletones_core/performance/__init__.py @@ -0,0 +1,16 @@ +from .modifiers import apply_modifiers +from .rows import apply_row, resolve_row +from .song import song_instructions +from .state import ChannelPerformance +from .ticks import sound_tick +from .voice import SampleVoice + +__all__ = [ + "ChannelPerformance", + "SampleVoice", + "apply_modifiers", + "apply_row", + "resolve_row", + "song_instructions", + "sound_tick", +] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py b/src/sampletones_core/performance/modifiers.py similarity index 100% rename from src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py rename to src/sampletones_core/performance/modifiers.py diff --git a/src/sampletones_core/performance/rows.py b/src/sampletones_core/performance/rows.py new file mode 100644 index 000000000..a35e56553 --- /dev/null +++ b/src/sampletones_core/performance/rows.py @@ -0,0 +1,79 @@ +from typing import Optional + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.performance.state import ChannelPerformance +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.song_position import SongPosition + + +def resolve_row( + song: Song, + position: SongPosition, + channel_name: ChannelName, +) -> Optional[Row]: + """The row one channel reaches at a position in the order. + + A position answers with a row where the order plays a pattern on that channel and the + pattern is long enough to hold the row; anywhere else the channel plays on with whatever + it already carries. + + Args: + song: The arrangement being played. + position: The order frame and the row within it. + channel_name: The channel whose pattern is read. + + Returns: + Optional[Row]: The row the channel reaches, or ``None`` where it reaches none. + """ + if position.order_position >= song.order_length(): + return None + + order_entry = song.order[position.order_position].get(channel_name) + if order_entry is None: + return None + + pattern = song.pattern(channel_name, order_entry) + if pattern is None or position.row_index >= len(pattern.rows): + return None + + return pattern.rows[position.row_index] + + +def apply_row(performance: ChannelPerformance, row: Row) -> bool: + """Moves a channel onto the row it has reached, and reports whether the note starts over. + + A note column names the sample to sound and begins it, taking the transpose and volume the + row states or the defaults where it states neither. A row naming no note leaves the sample + playing and changes only the columns it fills in, which is how a transpose or a volume bends + a note already sounding. + + Args: + performance: What the channel carries; updated in place. + row: The row the channel reached. + + Returns: + bool: Whether the channel starts over, which is where a phase-continuous voice resets. + """ + match row.command: + case Instrument() as instrument: + performance.sample_id = instrument.sample_id + performance.tick_index = 0 + performance.transpose = row.transpose if row.transpose is not None else 0 + performance.volume = row.volume if row.volume is not None else MAX_VOLUME + return True + case NoteOff(): + performance.sample_id = None + performance.tick_index = 0 + return True + case None: + if row.transpose is not None: + performance.transpose = row.transpose + + if row.volume is not None: + performance.volume = row.volume + + return False diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py new file mode 100644 index 000000000..75b6b2083 --- /dev/null +++ b/src/sampletones_core/performance/song.py @@ -0,0 +1,98 @@ +from typing import Dict, List, Optional + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.instructions import InstructionUnion +from sampletones_core.performance.rows import apply_row, resolve_row +from sampletones_core.performance.state import ChannelPerformance +from sampletones_core.performance.ticks import sound_tick +from sampletones_core.performance.voice import SampleVoice +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project +from sampletones_core.project.song_position import SongPosition +from sampletones_core.timing.song import SongTiming + + +def song_instructions(project: Project) -> Dict[ChannelName, List[InstructionUnion]]: + """Plays a whole song out as the instructions each channel sounds, one per engine tick. + + The order is walked frame by frame and row by row, each row lasting the ticks the project's + groove gives its position within the pattern. Every channel answers for each of those ticks, + so the four streams share one length and a tick's index into them is the same moment of the + song — which is what an engine consuming one instruction per tick plays from. + + Args: + project: The project whose song is played. + + Returns: + Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, tick by tick. + """ + song = project.song + groove = SongTiming.from_project(project).groove() + performances = {channel_name: ChannelPerformance() for channel_name in ChannelName.items()} + streams: Dict[ChannelName, List[InstructionUnion]] = {channel_name: [] for channel_name in ChannelName.items()} + + position = SongPosition() + while position.order_position < song.order_length(): + ticks = groove.ticks[position.row_index] + for channel_name in ChannelName.items(): + performance = performances[channel_name] + row = resolve_row(song, position, channel_name) + if row is not None: + apply_row(performance, row) + + streams[channel_name].extend( + _channel_ticks( + project.sample(performance.sample_id) if performance.sample_id is not None else None, + channel_name, + performance, + ticks, + ) + ) + + position.advance(song.rows_per_pattern, song.order_length()) + + return streams + + +def _channel_ticks( + sample: Optional[Sample], + channel_name: ChannelName, + performance: ChannelPerformance, + ticks: int, +) -> List[InstructionUnion]: + """One channel's instructions across a single row. + + A channel with nothing to sound rests for the whole row and keeps the tick it had reached, + so a sample removed from the project leaves the rows that named it silent while the rows + around them play on. + + Args: + sample: The sample the channel is sounding, or ``None`` while it rests. + channel_name: The channel being sounded. + performance: What the channel carries; its tick index moves on per sounded tick. + ticks: The engine ticks the row lasts. + + Returns: + List[InstructionUnion]: One instruction per tick of the row. + """ + resting: InstructionUnion = CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction() + if sample is None: + return [resting] * ticks + + instructions = sample.reconstruction.instructions[channel_name] + if not instructions: + return [resting] * ticks + + voice = SampleVoice.read(sample.reconstruction, channel_name) + sounded: List[InstructionUnion] = [] + for _ in range(ticks): + instruction = sound_tick( + performance, + instructions, + loop=sample.loop, + voice=voice, + ) + sounded.append(resting if instruction is None else instruction) + + return sounded diff --git a/src/sampletones_core/performance/state.py b/src/sampletones_core/performance/state.py new file mode 100644 index 000000000..ae7374858 --- /dev/null +++ b/src/sampletones_core/performance/state.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS + + +@dataclass +class ChannelPerformance: + """What one channel carries from row to row while a song plays. + + A pattern states a channel's instrument, transpose, and volume only where it changes them, so + the channel keeps the last of each until another row states otherwise. The tick index is how + far into the sounding sample's instructions the channel has played, which is what lets a note + sustain across rows. + + The channel carries a value per envelope dimension too, which is what an instrument leaving a + dimension to the channel sounds at. A frame the instrument writes hands its value over, so the + channel keeps the last one written for as long as the song runs. + + Attributes: + sample_id: The sample the channel is sounding, or ``None`` while it is silent. + tick_index: How many ticks of that sample's instructions the channel has played. + transpose: The semitone offset a row last set. + volume: The level a row last set. + feature_values: The value the channel holds for each envelope dimension. + """ + + sample_id: Optional[str] = field(default=None) + tick_index: int = field(default=0) + transpose: int = field(default=0) + volume: int = field(default=MAX_VOLUME) + feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy) + + def reset(self) -> None: + """Returns the channel to silence at full volume, as a song starts it. + + The envelope dimensions return to the values a channel holds from the start of a song, + so a pass through the song sounds the same however the previous one left them. + """ + self.sample_id = None + self.tick_index = 0 + self.transpose = 0 + self.volume = MAX_VOLUME + self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy() diff --git a/src/sampletones_core/performance/ticks.py b/src/sampletones_core/performance/ticks.py new file mode 100644 index 000000000..44732540b --- /dev/null +++ b/src/sampletones_core/performance/ticks.py @@ -0,0 +1,47 @@ +from typing import Optional, Sequence + +from sampletones_core.instructions import InstructionUnion +from sampletones_core.performance.modifiers import apply_modifiers +from sampletones_core.performance.state import ChannelPerformance +from sampletones_core.performance.voice import SampleVoice + + +def sound_tick( + performance: ChannelPerformance, + instructions: Sequence[InstructionUnion], + *, + loop: bool, + voice: SampleVoice, +) -> Optional[InstructionUnion]: + """The instruction a channel sounds this tick, and the step onto the next one. + + A looping sample wraps around its instructions, so it sustains for as long as rows keep it + sounding; a one-shot plays each of its instructions once and falls silent past the last. + Either way the channel moves on a tick, so a sample that has run out keeps counting and a + row starting a note lands it back at the beginning. + + Args: + performance: What the channel carries; its tick index moves on. + instructions: The sounding sample's stream for this channel, holding at least one frame. + loop: Whether the sample repeats its instructions. + voice: The reading that fills in the dimensions the instrument leaves to the channel. + + Returns: + Optional[InstructionUnion]: The instruction to sound, or ``None`` where the sample has + played out and the channel rests. + """ + index = performance.tick_index + performance.tick_index += 1 + + if loop: + instruction = instructions[index % len(instructions)] + elif index < len(instructions): + instruction = instructions[index] + else: + return None + + return apply_modifiers( + voice.sound(instruction, performance.feature_values), + performance.transpose, + performance.volume, + ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py b/src/sampletones_core/performance/voice.py similarity index 100% rename from src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py rename to src/sampletones_core/performance/voice.py diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py index 11b4d4cab..f705007c9 100644 --- a/src/sampletones_core/timing/__init__.py +++ b/src/sampletones_core/timing/__init__.py @@ -1,13 +1,18 @@ +from .bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW from .clock import TickClock from .distribution import distribute_by_halving, distribute_proportionally from .groove import Groove, calculate_groove from .metre import Metre from .rate import RowRate +from .song import SongTiming __all__ = [ + "MAX_TICKS_PER_ROW", + "MIN_TICKS_PER_ROW", "Groove", "Metre", "RowRate", + "SongTiming", "TickClock", "calculate_groove", "distribute_by_halving", diff --git a/src/sampletones_core/timing/bounds.py b/src/sampletones_core/timing/bounds.py new file mode 100644 index 000000000..8c3da006f --- /dev/null +++ b/src/sampletones_core/timing/bounds.py @@ -0,0 +1,15 @@ +from math import ceil +from typing import Final + +from sampletones_core.timing.rate import RowRate +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY +from sampletones_shared.constants.project import MAX_SPEED, MIN_TEMPO + +MIN_TICKS_PER_ROW: Final[int] = 1 +MAX_TICKS_PER_ROW: Final[int] = ceil( + RowRate.from_parameters( + tempo=MIN_TEMPO, + speed=MAX_SPEED, + nes_frequency=MAX_NES_FREQUENCY, + ).ticks_per_row +) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py b/src/sampletones_core/timing/song.py similarity index 77% rename from src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py rename to src/sampletones_core/timing/song.py index 96c72dd78..8579b52e2 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py +++ b/src/sampletones_core/timing/song.py @@ -1,12 +1,11 @@ from dataclasses import dataclass from typing import Self -from sampletones_application.constants.playback import ( - MAX_TICKS_PER_ROW, - MIN_TICKS_PER_ROW, -) from sampletones_core.project import Project -from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove +from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW +from sampletones_core.timing.groove import Groove, calculate_groove +from sampletones_core.timing.metre import Metre +from sampletones_core.timing.rate import RowRate @dataclass(frozen=True) @@ -35,8 +34,8 @@ def from_project(cls, project: Project) -> Self: def groove(self) -> Groove: """Spreads the row rate across a pattern's rows. - Playback follows whatever tempo the project states, so the one bound it sets is that - every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the + A song runs at whatever tempo the project states, so the one bound that applies is that + every row lasts at least a tick and keeps sounding; the ceiling is the slowest row the settings can ask for, which leaves the groove free to realize the rate exactly. """ return calculate_groove( diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 8c7cc3021..1255a5d19 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -10,6 +10,8 @@ PulseInstruction, TriangleInstruction, ) +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule @@ -18,6 +20,7 @@ from sampletones_player.registers.streams import ChannelStreams from sampletones_player.registers.triangle import TriangleRegisters from sampletones_player.song import Song +from sampletones_shared.music import Tuning SONG_START: Final[int] = 0 @@ -206,3 +209,37 @@ def song_from_sample(request: SampleExport) -> Song: schedule=PlaySchedule.from_parameters(request.nes_frequency), loop_tick=loop_tick_from_instruments(request.instruments), ) + + +def song_from_project( + project: Project, + tuning: Tuning, + loop_tick: Optional[int], +) -> Song: + """Builds the song the console plays a whole project as. + + The project's song is played out row by row into the instructions each channel sounds, so + what reaches the console is the arrangement itself rather than one reconstruction: the same + walk the sequencer sounds a song through, read as register values instead of audio. The + project states the rate the driver re-clocks those ticks by. + + Args: + project: The project whose song is played. + tuning: Where concert pitch sits, which decides the timer each pitch sounds at. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + + Returns: + Song: The streams, the clock and the loop point as the player holds them. + + Raises: + TypeError: If a channel's stream holds an instruction another channel sounds. + ValueError: If ``loop_tick`` lies outside the song's ticks. + """ + return Song( + streams=streams_from_instructions( + song_instructions(project), + get_timer_table(tuning), + ), + schedule=PlaySchedule.from_parameters(project.settings.nes_frequency), + loop_tick=loop_tick, + ) diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py new file mode 100644 index 000000000..6025d4a3d --- /dev/null +++ b/tests/integration/nsf/test_song_export.py @@ -0,0 +1,95 @@ +from typing import Final + +import pytest + +from sampletones_core.project.project import Project +from sampletones_core.timing import SongTiming +from sampletones_player.builder import song_from_project +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.nsf import PROGRAM_SIZE +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_shared.exceptions import SongTooLargeError +from sampletones_shared.music import Tuning + +RECORD_BYTES_PER_TICK: Final[int] = 11 + + +def available_bytes(driver_image: DriverImage) -> int: + """The program area the song block is written into, behind the driver.""" + return PROGRAM_SIZE - len(driver_image.code) + + +def lengthened(project: Project, frames: int) -> Project: + """``project`` with its order repeated to ``frames`` positions, over the same samples. + + The song is copied rather than edited so the session's own project keeps the arrangement + every other case reads. + """ + longer = Project.create( + rows_per_pattern=project.song.rows_per_pattern, + settings=project.settings, + ) + for sample in project.samples: + longer.samples.append(sample) + + longer.song = project.song.model_copy(deep=True) + while longer.song.order_length() < frames: + longer.song.duplicate_frame(longer.song.order_length() - 1) + + return longer + + +@pytest.fixture +def project_song(integration_project: Project) -> Song: + """The song the console plays the integration project's arrangement as.""" + return song_from_project(integration_project, Tuning(), loop_tick=None) + + +class TestTheProjectReachesTheConsole: + """A whole arrangement flattened into the streams the driver already plays.""" + + def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_order( + self, + integration_project: Project, + project_song: Song, + ) -> None: + groove = SongTiming.from_project(integration_project).groove() + assert project_song.ticks == integration_project.song.order_length() * groove.total_ticks + + def test_every_channel_the_order_plays_sounds(self, project_song: Song) -> None: + """The fixture's arrangement fills all four channels, so none of them rests throughout.""" + for stream in ( + project_song.streams.pulse1, + project_song.streams.pulse2, + project_song.streams.triangle, + project_song.streams.noise, + ): + assert len(set(stream)) > 1 + + def test_the_arrangement_writes_through_the_song_block( + self, + project_song: Song, + driver_image: DriverImage, + ) -> None: + block = song_to_bytes(project_song, available_bytes(driver_image)) + assert len(block) == SONG_HEADER_SIZE + RECORD_BYTES_PER_TICK * project_song.ticks + + +class TestTheProgramAreaBoundsTheSong: + """Where a record per tick stops fitting behind the driver.""" + + def test_a_song_outgrowing_the_program_area_is_refused( + self, + integration_project: Project, + driver_image: DriverImage, + ) -> None: + """The exporter names the overflow rather than writing a file the console truncates.""" + space = available_bytes(driver_image) + groove = SongTiming.from_project(integration_project).groove() + frames = space // (RECORD_BYTES_PER_TICK * groove.total_ticks) + 2 + song = song_from_project(lengthened(integration_project, frames), Tuning(), loop_tick=None) + + with pytest.raises(SongTooLargeError): + song_to_bytes(song, space) diff --git a/tests/suite/performance.py b/tests/suite/performance.py new file mode 100644 index 000000000..6729e98f8 --- /dev/null +++ b/tests/suite/performance.py @@ -0,0 +1,132 @@ +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.reconstructions import Reconstruction +from tests.suite.stems import single_entry_stems_data + +APPROXIMATION_LENGTH: int = 64 + + +def _reconstruction( + channel_name: ChannelName, + instructions: List[InstructionUnion], +) -> Reconstruction: + approximation = np.zeros(APPROXIMATION_LENGTH, dtype=np.float32) + channel_instructions: Dict[ChannelName, List[InstructionUnion]] = {channel_name: instructions} + return Reconstruction.create( + approximation=approximation, + approximations={channel_name: approximation}, + instructions=channel_instructions, + config=Config(), + coefficient=1.0, + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(Config().generation.channels), + channel_instructions, + ), + ) + + +def make_pulse_reconstruction( + *, + pitch: int = 60, + volume: int = 15, + count: int = 1, + held_features: Iterable[FeatureKey] = (), +) -> Reconstruction: + """Single-channel reconstruction with ``count`` identical PulseInstructions. + + ``held_features`` names the dimensions the instrument leaves to the channel, which is what + an envelope cleared in the instruments panel produces. + """ + instructions: List[InstructionUnion] = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count + reconstruction = _reconstruction(ChannelName.PULSE1, instructions) + if held_features: + reconstruction.update_channel_data( + ChannelName.PULSE1, + list(instructions), + np.zeros(APPROXIMATION_LENGTH, dtype=np.float32), + reconstruction.initial_pitches[ChannelName.PULSE1], + held_features, + ) + + return reconstruction + + +def make_triangle_reconstruction( + *, + pitch: int = 60, + count: int = 1, +) -> Reconstruction: + """Single-channel reconstruction with ``count`` identical TriangleInstructions.""" + instructions: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=pitch)] * count + return _reconstruction(ChannelName.TRIANGLE, instructions) + + +def make_noise_reconstruction( + *, + period: int = 3, + volume: int = 15, + count: int = 1, +) -> Reconstruction: + """Single-channel reconstruction with ``count`` identical NoiseInstructions.""" + instructions: List[InstructionUnion] = [ + NoiseInstruction(on=True, period=period, volume=volume, short=False) + ] * count + return _reconstruction(ChannelName.NOISE, instructions) + + +def project_with_sample( + reconstruction: Reconstruction, + *, + rows_per_pattern: int, + settings: Optional[ProjectSettings] = None, + loop: bool = False, + name: str = "test", +) -> Tuple[Project, Sample]: + """A one-sample project, built through the core models a document is made of. + + Answering with the sample beside the project is what lets a case place it on a row without + reaching back into the collection for an id it already knows. + """ + project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings) + sample = Sample(name=name, reconstruction=reconstruction, loop=loop) + project.samples.append(sample) + return project, sample + + +def place_instrument( + project: Project, + *, + channel_name: ChannelName, + row_index: int, + sample: Sample, + transpose: Optional[int] = None, + volume: Optional[int] = None, + pattern_index: int = 0, +) -> None: + """Writes a note column naming ``sample`` onto one row of one channel's pattern.""" + pattern = project.song.channels[channel_name].ensure_pattern( + pattern_index, + project.song.rows_per_pattern, + ) + pattern.rows[row_index] = Row( + command=Instrument(sample_id=sample.id, channel_name=channel_name), + transpose=transpose, + volume=volume, + ) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index e96c46dd6..94ef8a853 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -10,17 +10,11 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.instructions import ( - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction -from tests.suite.stems import single_entry_stems_data def make_controller() -> ProjectController: @@ -48,84 +42,6 @@ def make_synthesizer( ) -def make_pulse_reconstruction( - *, - pitch: int = 60, - volume: int = 15, - count: int = 1, - held_features: Iterable[FeatureKey] = (), -) -> Reconstruction: - """Single-channel reconstruction with ``count`` identical PulseInstructions. - - ``held_features`` names the dimensions the instrument leaves to the channel, which is what - an envelope cleared in the instruments panel produces. - """ - instructions = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count - reconstruction = Reconstruction.create( - approximation=np.zeros(64, dtype=np.float32), - approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)}, - instructions={ChannelName.PULSE1: instructions}, - config=Config(), - coefficient=1.0, - audio_filepath=(Path("/dev/null"),), - stems_data=single_entry_stems_data( - list(Config().generation.channels), - {ChannelName.PULSE1: instructions}, - ), - ) - if held_features: - reconstruction.update_channel_data( - ChannelName.PULSE1, - list(instructions), - np.zeros(64, dtype=np.float32), - reconstruction.initial_pitches[ChannelName.PULSE1], - held_features, - ) - - return reconstruction - - -def make_triangle_reconstruction( - *, - pitch: int = 60, - count: int = 1, -) -> Reconstruction: - instructions = [TriangleInstruction(on=True, pitch=pitch)] * count - return Reconstruction.create( - approximation=np.zeros(64, dtype=np.float32), - approximations={ChannelName.TRIANGLE: np.zeros(64, dtype=np.float32)}, - instructions={ChannelName.TRIANGLE: instructions}, - config=Config(), - coefficient=1.0, - audio_filepath=(Path("/dev/null"),), - stems_data=single_entry_stems_data( - list(Config().generation.channels), - {ChannelName.TRIANGLE: instructions}, - ), - ) - - -def make_noise_reconstruction( - *, - period: int = 3, - volume: int = 15, - count: int = 1, -) -> Reconstruction: - instructions = [NoiseInstruction(on=True, period=period, volume=volume, short=False)] * count - return Reconstruction.create( - approximation=np.zeros(64, dtype=np.float32), - approximations={ChannelName.NOISE: np.zeros(64, dtype=np.float32)}, - instructions={ChannelName.NOISE: instructions}, - config=Config(), - coefficient=1.0, - audio_filepath=(Path("/dev/null"),), - stems_data=single_entry_stems_data( - list(Config().generation.channels), - {ChannelName.NOISE: instructions}, - ), - ) - - def add_sample( controller: ProjectController, reconstruction: Reconstruction, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py index ab23991af..2cc9aaff0 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py @@ -2,7 +2,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.playback.synthesizer import SongLength -from sampletones_application.logic.sequencer.playback.synthesizer.timing import SongTiming +from sampletones_core.timing import SongTiming FRACTIONAL_RATE: Final[int] = 22050 EXACT_RATE: Final[int] = 44100 diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 4985c164b..a0fecb883 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -4,10 +4,6 @@ import numpy as np import pytest -from sampletones_application.constants.playback import ( - MAX_TICKS_PER_ROW, - MIN_TICKS_PER_ROW, -) from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer @@ -16,13 +12,24 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.performance import ChannelPerformance from sampletones_core.reconstructions import Reconstruction -from sampletones_core.timing import Metre, RowRate, calculate_groove +from sampletones_core.timing import ( + MAX_TICKS_PER_ROW, + MIN_TICKS_PER_ROW, + Metre, + RowRate, + calculate_groove, +) +from tests.suite.performance import ( + make_noise_reconstruction, + make_pulse_reconstruction, + make_triangle_reconstruction, +) from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( add_sample, make_controller, - make_pulse_reconstruction, make_synthesizer, place_modifier_row, place_note_off, @@ -73,11 +80,11 @@ def _controller(context: SynthesizerContext) -> ProjectController: return context.controller -def _state( +def _performance( context: SynthesizerContext, channel: ChannelName = ChannelName.PULSE1, -): - return context.synthesizer._channels.state(channel) +) -> ChannelPerformance: + return context.synthesizer._channels.state(channel).performance def _render(context: SynthesizerContext) -> np.ndarray: @@ -121,8 +128,8 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: def render_row_0_and_assert_defaults(context: SynthesizerContext) -> None: _render(context) - assert _state(context).transpose == 0 - assert _state(context).volume == MAX_VOLUME + assert _performance(context).transpose == 0 + assert _performance(context).volume == MAX_VOLUME BaseTestScenario( label="trigger sets default transpose and volume", @@ -156,8 +163,8 @@ def render_row_0_and_assert_explicit_values( context: SynthesizerContext, ) -> None: _render(context) - assert _state(context).transpose == 5 - assert _state(context).volume == 8 + assert _performance(context).transpose == 5 + assert _performance(context).volume == 8 BaseTestScenario( label="trigger with explicit modifiers", @@ -189,16 +196,16 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: def render_row_0_and_record_state(context: SynthesizerContext) -> None: _render(context) - context.tick_snapshots["after_row_0"] = _state(context).tick_index - context.sample_id_snapshots["triggered"] = _state(context).sample_id - assert _state(context).sample_id is not None + context.tick_snapshots["after_row_0"] = _performance(context).tick_index + context.sample_id_snapshots["triggered"] = _performance(context).sample_id + assert _performance(context).sample_id is not None def render_empty_row_1_and_assert_tick_advanced( context: SynthesizerContext, ) -> None: _render(context) - assert _state(context).tick_index > context.tick_snapshots["after_row_0"] - assert _state(context).sample_id == context.sample_id_snapshots["triggered"] + assert _performance(context).tick_index > context.tick_snapshots["after_row_0"] + assert _performance(context).sample_id == context.sample_id_snapshots["triggered"] BaseTestScenario( label="sustain — empty row continues previous note", @@ -241,17 +248,17 @@ def setup(context: SynthesizerContext) -> None: def render_row_0_and_record_state(context: SynthesizerContext) -> None: _render(context) - context.tick_snapshots["after_row_0"] = _state(context).tick_index - context.sample_id_snapshots["after_row_0"] = _state(context).sample_id - assert _state(context).volume == 15 + context.tick_snapshots["after_row_0"] = _performance(context).tick_index + context.sample_id_snapshots["after_row_0"] = _performance(context).sample_id + assert _performance(context).volume == 15 def render_modifier_row_and_assert_volume_changed( context: SynthesizerContext, ) -> None: _render(context) - assert _state(context).volume == 0 - assert _state(context).sample_id == context.sample_id_snapshots["after_row_0"] - assert _state(context).tick_index > context.tick_snapshots["after_row_0"] + assert _performance(context).volume == 0 + assert _performance(context).sample_id == context.sample_id_snapshots["after_row_0"] + assert _performance(context).tick_index > context.tick_snapshots["after_row_0"] BaseTestScenario( label="modifier-only row changes volume without retriggering", @@ -289,15 +296,15 @@ def setup(context: SynthesizerContext) -> None: def render_row_0_and_record_sample(context: SynthesizerContext) -> None: _render(context) - context.sample_id_snapshots["triggered"] = _state(context).sample_id - assert _state(context).transpose == 0 + context.sample_id_snapshots["triggered"] = _performance(context).sample_id + assert _performance(context).transpose == 0 def render_modifier_row_and_assert_transpose_changed( context: SynthesizerContext, ) -> None: _render(context) - assert _state(context).transpose == 7 - assert _state(context).sample_id == context.sample_id_snapshots["triggered"] + assert _performance(context).transpose == 7 + assert _performance(context).sample_id == context.sample_id_snapshots["triggered"] BaseTestScenario( label="modifier-only row changes transpose without retriggering", @@ -376,7 +383,7 @@ def place_looping_pulse_sample(context: SynthesizerContext) -> None: def mute_pulse1_and_render_row_0(context: SynthesizerContext) -> None: context.mask.mute(ChannelName.PULSE1) assert np.allclose(_render(context), 0.0) - assert _state(context).sample_id is not None + assert _performance(context).sample_id is not None def unmute_pulse1_and_render_row_1(context: SynthesizerContext) -> None: context.mask.active = ALL_CHANNELS @@ -503,7 +510,7 @@ def render_row_0_and_assert_audible(context: SynthesizerContext) -> None: def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None: audio = _render(context) assert np.all(audio == 0.0) - assert _state(context).sample_id is None + assert _performance(context).sample_id is None BaseTestScenario( label="note-off silences a looped voice and clears channel state", @@ -546,7 +553,7 @@ def render_rows_1_to_3_and_assert_tick_advanced( ) -> None: for _ in range(3): _render(context) - assert _state(context).tick_index > 2 + assert _performance(context).tick_index > 2 BaseTestScenario( label="loop=True wraps instruction index", @@ -628,7 +635,7 @@ def render_into_empty_second_frame_and_assert_sustained( audio, (order_position, _) = context.synthesizer.render_row() assert order_position == 1 assert not np.all(audio == 0.0) - assert _state(context).sample_id is not None + assert _performance(context).sample_id is not None BaseTestScenario( label="looped voice carries across an empty (None-slot) next frame", @@ -852,7 +859,7 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non synthesizer.render_row() assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30) - assert pulse_state.sample_id is not None + assert pulse_state.performance.sample_id is not None class TestChannelHeldValues: @@ -894,7 +901,7 @@ def test_the_channel_takes_up_the_level_its_instrument_writes(self) -> None: _render(context) - assert _state(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME + assert _performance(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None: context = _make_context() @@ -954,4 +961,4 @@ def test_a_reset_returns_every_channel_to_the_values_a_song_starts_on(self) -> N context.synthesizer.reset() - assert _state(context).feature_values == CHANNEL_FEATURE_DEFAULTS + assert _performance(context).feature_values == CHANNEL_FEATURE_DEFAULTS diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index 3d217f3d5..e74020610 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -4,18 +4,17 @@ import numpy as np import pytest -from sampletones_application.constants.playback import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.timing import Metre, RowRate, TickClock, calculate_groove +from sampletones_core.timing import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, Metre, RowRate, TickClock, calculate_groove from tests.suite.base import BaseTestSuite +from tests.suite.performance import make_pulse_reconstruction from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( add_sample, all_channels, make_controller, - make_pulse_reconstruction, make_synthesizer, place_row, ) diff --git a/tests/unit/sampletones_core/performance/__init__.py b/tests/unit/sampletones_core/performance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_core/performance/test_modifiers.py similarity index 98% rename from tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py rename to tests/unit/sampletones_core/performance/test_modifiers.py index 2b4dcaa08..80f2b5cac 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py +++ b/tests/unit/sampletones_core/performance/test_modifiers.py @@ -3,15 +3,13 @@ import pytest -from sampletones_application.logic.sequencer.playback.synthesizer import ( - apply_modifiers, -) from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH from sampletones_core.instructions import ( NoiseInstruction, PulseInstruction, TriangleInstruction, ) +from sampletones_core.performance import apply_modifiers from tests.suite.case import BaseRegularTestCase, BaseTestCase diff --git a/tests/unit/sampletones_core/performance/test_rows.py b/tests/unit/sampletones_core/performance/test_rows.py new file mode 100644 index 000000000..d5537974b --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_rows.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.performance import ChannelPerformance, apply_row, resolve_row +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.song_position import SongPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ROWS_PER_PATTERN: int = 4 +SOUNDING_ROW: int = 2 +SAMPLE_ID: str = "sample" +ANOTHER_SAMPLE_ID: str = "another" + + +def _song() -> Song: + """A one-frame song whose pulse pattern names a sample on ``SOUNDING_ROW``.""" + song = Song.empty(ROWS_PER_PATTERN) + pattern = song.channels[ChannelName.PULSE1].patterns[0] + pattern.rows[SOUNDING_ROW] = Row( + command=Instrument(sample_id=SAMPLE_ID, channel_name=ChannelName.PULSE1), + ) + return song + + +class TestResolveRow(BaseTestSuite): + """Which row a channel reaches, over every way an order position answers with none.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: bool + position: SongPosition + order_entry: Optional[int] + + test_cases: Tuple["TestResolveRow.TestCase", ...] = ( + TestCase( + label="the row the pattern states", + position=SongPosition(order_position=0, row_index=SOUNDING_ROW), + order_entry=0, + expected=True, + ), + TestCase( + label="a position past the order's end", + position=SongPosition(order_position=1, row_index=SOUNDING_ROW), + order_entry=0, + expected=False, + ), + TestCase( + label="a silent slot", + position=SongPosition(order_position=0, row_index=SOUNDING_ROW), + order_entry=None, + expected=False, + ), + TestCase( + label="a slot naming a pattern the pool has none of", + position=SongPosition(order_position=0, row_index=SOUNDING_ROW), + order_entry=7, + expected=False, + ), + TestCase( + label="a row past the pattern's length", + position=SongPosition(order_position=0, row_index=ROWS_PER_PATTERN), + order_entry=0, + expected=False, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_row_a_position_answers_with(self, test_case: TestCase) -> None: + song = _song() + song.set_order_entry(0, ChannelName.PULSE1, test_case.order_entry) + + row = resolve_row(song, test_case.position, ChannelName.PULSE1) + + assert (row is not None) is test_case.expected + + def test_an_empty_row_is_answered_with_rather_than_skipped(self) -> None: + """A row stating nothing is still a row, which is what keeps a channel's state its own.""" + song = _song() + + row = resolve_row(song, SongPosition(order_position=0, row_index=0), ChannelName.PULSE1) + + assert row == Row() + + +class TestApplyRow(BaseTestSuite): + """What a row leaves the channel carrying, and whether the note starts over.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: bool + row: Row + sample_id: Optional[str] + transpose: int + volume: int + + test_cases: Tuple["TestApplyRow.TestCase", ...] = ( + TestCase( + label="a note column with no modifiers takes the defaults", + row=Row(command=Instrument(sample_id=SAMPLE_ID, channel_name=ChannelName.PULSE1)), + sample_id=SAMPLE_ID, + transpose=0, + volume=MAX_VOLUME, + expected=True, + ), + TestCase( + label="a note column takes the modifiers the row states", + row=Row( + command=Instrument(sample_id=SAMPLE_ID, channel_name=ChannelName.PULSE1), + transpose=5, + volume=8, + ), + sample_id=SAMPLE_ID, + transpose=5, + volume=8, + expected=True, + ), + TestCase( + label="a note off silences the channel", + row=Row(command=NoteOff()), + sample_id=None, + transpose=3, + volume=8, + expected=True, + ), + TestCase( + label="an empty row leaves everything as it stands", + row=Row(), + sample_id=ANOTHER_SAMPLE_ID, + transpose=3, + volume=8, + expected=False, + ), + TestCase( + label="a modifier row bends the note already sounding", + row=Row(transpose=-2, volume=4), + sample_id=ANOTHER_SAMPLE_ID, + transpose=-2, + volume=4, + expected=False, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_what_the_channel_carries_after_the_row(self, test_case: TestCase) -> None: + performance = ChannelPerformance( + sample_id=ANOTHER_SAMPLE_ID, + tick_index=6, + transpose=3, + volume=8, + ) + + retriggered = apply_row(performance, test_case.row) + + assert retriggered is test_case.expected + assert performance.sample_id == test_case.sample_id + assert performance.transpose == test_case.transpose + assert performance.volume == test_case.volume + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_tick_index_returns_to_the_start_exactly_where_the_note_does( + self, + test_case: TestCase, + ) -> None: + """A row that starts the note over is a row that starts its envelopes over.""" + performance = ChannelPerformance(sample_id=ANOTHER_SAMPLE_ID, tick_index=6) + + retriggered = apply_row(performance, test_case.row) + + assert (performance.tick_index == 0) is retriggered diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py new file mode 100644 index 000000000..a8d8dc417 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -0,0 +1,81 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SongTiming +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_sample, +) + +ROWS_PER_PATTERN: Final[int] = 4 +ENVELOPE_TICKS: Final[int] = 2 +SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=150, speed=6, nes_frequency=60) + + +def _project() -> Project: + """A one-frame project sounding a two-tick pulse envelope from the first row.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=ENVELOPE_TICKS), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=sample, + ) + return project + + +def _resting(channel_name: ChannelName) -> object: + return CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction() + + +class TestSongInstructions: + """The four streams a song plays out as, one instruction per channel per engine tick.""" + + def test_the_song_lasts_the_ticks_its_groove_gives_every_row_it_plays(self) -> None: + project = _project() + groove = SongTiming.from_project(project).groove() + + streams = song_instructions(project) + + expected_ticks = project.song.order_length() * groove.total_ticks + assert len(streams[ChannelName.PULSE1]) == expected_ticks + + def test_every_channel_answers_for_every_tick(self) -> None: + """One length across the four streams is what makes a tick index one moment of the song.""" + streams = song_instructions(_project()) + + assert len({len(stream) for stream in streams.values()}) == 1 + + def test_a_channel_no_row_names_rests_throughout(self) -> None: + streams = song_instructions(_project()) + + assert streams[ChannelName.TRIANGLE] == [_resting(ChannelName.TRIANGLE)] * len(streams[ChannelName.TRIANGLE]) + + def test_a_sounding_channel_falls_silent_once_its_envelope_plays_out(self) -> None: + """A one-shot sounds for the ticks it carries and rests for the rest of the row.""" + stream = song_instructions(_project())[ChannelName.PULSE1] + resting = _resting(ChannelName.PULSE1) + + assert stream[:ENVELOPE_TICKS] != [resting] * ENVELOPE_TICKS + assert stream[ENVELOPE_TICKS:] == [resting] * (len(stream) - ENVELOPE_TICKS) + + def test_a_pattern_the_order_plays_twice_sounds_alike_both_times(self) -> None: + """The order is where reuse lives, so the ticks a repeated frame produces are the same.""" + project = _project() + project.song.append_frame() + project.song.set_order_entry(1, ChannelName.PULSE1, 0) + groove = SongTiming.from_project(project).groove() + + stream = song_instructions(project)[ChannelName.PULSE1] + + frame_ticks = groove.total_ticks + assert stream[:frame_ticks] == stream[frame_ticks : 2 * frame_ticks] diff --git a/tests/unit/sampletones_core/performance/test_ticks.py b/tests/unit/sampletones_core/performance/test_ticks.py new file mode 100644 index 000000000..3a6ee2c28 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_ticks.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.performance import ChannelPerformance, SampleVoice, sound_tick +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import make_pulse_reconstruction + +ENVELOPE_TICKS: int = 3 +SOUNDING_PITCH: int = 60 + + +def _voice() -> Tuple[SampleVoice, List[InstructionUnion]]: + """A pulse voice over a three-tick envelope, read the way a channel reads it.""" + reconstruction = make_pulse_reconstruction(pitch=SOUNDING_PITCH, count=ENVELOPE_TICKS) + return ( + SampleVoice.read(reconstruction, ChannelName.PULSE1), + reconstruction.instructions[ChannelName.PULSE1], + ) + + +class TestSoundTick(BaseTestSuite): + """Which of a sample's instructions a channel reaches, and where it runs out.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: bool + tick_index: int + loop: bool + + test_cases: Tuple["TestSoundTick.TestCase", ...] = ( + TestCase(label="a one-shot within its envelope", tick_index=0, loop=False, expected=True), + TestCase( + label="a one-shot on its final tick", + tick_index=ENVELOPE_TICKS - 1, + loop=False, + expected=True, + ), + TestCase( + label="a one-shot past its envelope", + tick_index=ENVELOPE_TICKS, + loop=False, + expected=False, + ), + TestCase( + label="a looping sample past its envelope", + tick_index=ENVELOPE_TICKS, + loop=True, + expected=True, + ), + TestCase( + label="a looping sample several passes on", + tick_index=ENVELOPE_TICKS * 4 + 1, + loop=True, + expected=True, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_whether_the_channel_still_sounds(self, test_case: TestCase) -> None: + voice, instructions = _voice() + performance = ChannelPerformance(tick_index=test_case.tick_index) + + instruction = sound_tick(performance, instructions, loop=test_case.loop, voice=voice) + + assert (instruction is not None) is test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_channel_moves_on_whether_or_not_it_sounds(self, test_case: TestCase) -> None: + """A sample that has played out keeps counting, so the tick index states the song's time.""" + voice, instructions = _voice() + performance = ChannelPerformance(tick_index=test_case.tick_index) + + sound_tick(performance, instructions, loop=test_case.loop, voice=voice) + + assert performance.tick_index == test_case.tick_index + 1 + + def test_a_looping_sample_wraps_onto_the_instruction_the_pass_reaches(self) -> None: + voice, instructions = _voice() + performance = ChannelPerformance() + + sounded = [sound_tick(performance, instructions, loop=True, voice=voice) for _ in range(ENVELOPE_TICKS * 2)] + + assert sounded[:ENVELOPE_TICKS] == sounded[ENVELOPE_TICKS:] + + def test_the_row_bends_the_instruction_the_sample_holds(self) -> None: + """The transpose and volume a row reached are applied to what the channel sounds.""" + voice, instructions = _voice() + transpose = 7 + volume = MAX_VOLUME // 3 + performance = ChannelPerformance(transpose=transpose, volume=volume) + + instruction = sound_tick(performance, instructions, loop=False, voice=voice) + + held: Optional[InstructionUnion] = instructions[0] + assert isinstance(instruction, PulseInstruction) + assert isinstance(held, PulseInstruction) + assert instruction.pitch == held.pitch + transpose + assert instruction.volume == round(held.volume * volume / MAX_VOLUME) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_core/performance/test_voice.py similarity index 99% rename from tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py rename to tests/unit/sampletones_core/performance/test_voice.py index 6075eab78..651687e0b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py +++ b/tests/unit/sampletones_core/performance/test_voice.py @@ -5,7 +5,6 @@ import numpy as np import pytest -from sampletones_application.logic.sequencer.playback.synthesizer import SampleVoice from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME @@ -16,6 +15,7 @@ PulseInstruction, TriangleInstruction, ) +from sampletones_core.performance import SampleVoice from sampletones_core.reconstructions import Reconstruction from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index bb3dd702a..0a50732dd 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -10,12 +10,16 @@ PulseInstruction, TriangleInstruction, ) +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings from sampletones_core.timers.utils import get_timer_table +from sampletones_core.timing import SongTiming from sampletones_player.builder import ( SONG_START, channel_instructions, instructions_from_instruments, loop_tick_from_instruments, + song_from_project, song_from_reconstruction, song_from_sample, streams_from_instructions, @@ -26,6 +30,11 @@ TRIANGLE_SOUNDING_RELOAD, ) from sampletones_shared.music import Tuning +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_sample, +) from tests.suite.player import ( PLAYER_FULL_VOLUME, PLAYER_REFERENCE_PITCH, @@ -242,3 +251,62 @@ def test_a_retuned_request_plays_retuned_timers(self) -> None: song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY, tuning=tuning)) timer = get_timer_table(tuning)[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + + +ROWS_PER_PATTERN: Final[int] = 4 +SONG_SETTINGS: Final[ProjectSettings] = ProjectSettings( + tempo=150, + speed=6, + nes_frequency=NTSC_FREQUENCY, +) + + +def drum_project() -> Project: + """A one-frame project sounding a pulse envelope from the first row.""" + project, sample = project_with_sample( + make_pulse_reconstruction(pitch=PLAYER_REFERENCE_PITCH, count=SOUNDING_TICKS), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SONG_SETTINGS, + ) + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=sample, + ) + return project + + +class TestSongFromProject: + """A whole project reaching the console as the four streams the driver plays.""" + + def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows(self) -> None: + project = drum_project() + song = song_from_project(project, Tuning(), None) + groove = SongTiming.from_project(project).groove() + assert song.ticks == project.song.order_length() * groove.total_ticks + + def test_the_schedule_follows_the_rate_the_project_states(self) -> None: + song = song_from_project(drum_project(), Tuning(), None) + assert song.schedule == PlaySchedule.from_parameters(SONG_SETTINGS.nes_frequency) + + def test_every_channel_carries_the_songs_whole_length(self) -> None: + song = song_from_project(drum_project(), Tuning(), None) + assert len(song.streams.noise) == song.ticks + assert len(song.streams.triangle) == song.ticks + + def test_a_pitch_reaches_the_timer_the_tuning_names(self) -> None: + song = song_from_project(drum_project(), Tuning(), None) + timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] + assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) + + def test_the_song_carries_the_loop_it_is_given(self) -> None: + song = song_from_project(drum_project(), Tuning(), SONG_START) + assert song.loop_tick == SONG_START + + def test_a_frame_the_order_plays_twice_lasts_twice_as_long(self) -> None: + project = drum_project() + one_frame = song_from_project(project, Tuning(), None).ticks + project.song.append_frame() + project.song.set_order_entry(1, ChannelName.PULSE1, 0) + assert song_from_project(project, Tuning(), None).ticks == 2 * one_frame From 852a41ef14e727e07fe15a40c8204bec459cac61 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 14:28:50 +0200 Subject: [PATCH 050/142] Added: a token codec for the player's channel streams --- Makefile | 7 +- docs/development/packages.md | 1 + src/sampletones_config/boundaries/graphs.yaml | 1 + src/sampletones_player/builder.py | 76 +--- .../compression/__init__.py | 0 .../compression/compressed.py | 34 ++ src/sampletones_player/compression/decode.py | 110 +++++ .../compression/dictionary/__init__.py | 0 .../compression/dictionary/phrase.py | 47 +++ .../compression/dictionary/prune.py | 44 ++ .../compression/dictionary/table.py | 65 +++ src/sampletones_player/compression/encode.py | 145 +++++++ .../compression/matches/__init__.py | 0 .../compression/matches/index.py | 54 +++ .../compression/matches/match.py | 15 + .../compression/matches/matcher.py | 84 ++++ .../compression/matches/played.py | 85 ++++ .../compression/matches/shift.py | 23 ++ src/sampletones_player/compression/options.py | 23 ++ .../compression/parse/__init__.py | 0 .../compression/parse/boundaries.py | 49 +++ .../compression/parse/literals.py | 42 ++ .../compression/parse/plane.py | 130 ++++++ .../compression/parse/result.py | 22 + .../compression/parse/shortest.py | 87 ++++ .../compression/parse/song.py | 29 ++ src/sampletones_player/compression/pitch.py | 74 ++++ .../compression/planes/__init__.py | 0 .../compression/planes/channel.py | 48 +++ .../compression/planes/order.py | 53 +++ .../compression/planes/rebuild.py | 73 ++++ .../compression/planes/separate.py | 76 ++++ .../compression/planes/song.py | 87 ++++ src/sampletones_player/compression/search.py | 180 ++++++++ src/sampletones_player/compression/seeds.py | 46 +++ .../compression/tokens/__init__.py | 0 .../compression/tokens/hold.py | 15 + .../compression/tokens/literal.py | 20 + .../compression/tokens/phrase.py | 21 + .../compression/tokens/sizes.py | 35 ++ .../compression/tokens/types.py | 7 + src/sampletones_player/registers/channel.py | 119 ++++++ .../specification/channels.py | 10 +- .../specification/compression.py | 52 +++ tests/integration/nsf/report.py | 115 ++++++ tests/integration/nsf/songs.py | 32 ++ .../nsf/test_compression_report.py | 387 ++++++++++++++++++ tests/integration/nsf/test_song_export.py | 35 +- tests/integration/paths.py | 1 + .../compression/__init__.py | 0 .../compression/dictionary/__init__.py | 0 .../compression/dictionary/phrases.py | 13 + .../compression/dictionary/test_phrase.py | 34 ++ .../compression/dictionary/test_prune.py | 21 + .../compression/dictionary/test_table.py | 32 ++ .../compression/matches/__init__.py | 0 .../compression/matches/test_index.py | 16 + .../compression/matches/test_matcher.py | 60 +++ .../compression/matches/test_shift.py | 17 + .../compression/parse/__init__.py | 0 .../compression/parse/test_boundaries.py | 29 ++ .../compression/parse/test_plane.py | 116 ++++++ .../compression/planes/__init__.py | 0 .../compression/planes/conftest.py | 43 ++ .../compression/planes/test_channel.py | 21 + .../compression/planes/test_order.py | 21 + .../compression/planes/test_rebuild.py | 18 + .../compression/planes/test_separate.py | 48 +++ .../compression/planes/test_song.py | 32 ++ .../compression/test_decode.py | 53 +++ .../compression/test_encode.py | 105 +++++ .../compression/test_pitch.py | 44 ++ .../compression/test_seeds.py | 43 ++ .../compression/tokens/__init__.py | 0 .../compression/tokens/test_hold.py | 9 + .../compression/tokens/test_literal.py | 16 + .../compression/tokens/test_phrase.py | 55 +++ .../registers/test_channel.py | 89 ++++ tests/unit/sampletones_player/test_builder.py | 19 - 79 files changed, 3493 insertions(+), 120 deletions(-) create mode 100644 src/sampletones_player/compression/__init__.py create mode 100644 src/sampletones_player/compression/compressed.py create mode 100644 src/sampletones_player/compression/decode.py create mode 100644 src/sampletones_player/compression/dictionary/__init__.py create mode 100644 src/sampletones_player/compression/dictionary/phrase.py create mode 100644 src/sampletones_player/compression/dictionary/prune.py create mode 100644 src/sampletones_player/compression/dictionary/table.py create mode 100644 src/sampletones_player/compression/encode.py create mode 100644 src/sampletones_player/compression/matches/__init__.py create mode 100644 src/sampletones_player/compression/matches/index.py create mode 100644 src/sampletones_player/compression/matches/match.py create mode 100644 src/sampletones_player/compression/matches/matcher.py create mode 100644 src/sampletones_player/compression/matches/played.py create mode 100644 src/sampletones_player/compression/matches/shift.py create mode 100644 src/sampletones_player/compression/options.py create mode 100644 src/sampletones_player/compression/parse/__init__.py create mode 100644 src/sampletones_player/compression/parse/boundaries.py create mode 100644 src/sampletones_player/compression/parse/literals.py create mode 100644 src/sampletones_player/compression/parse/plane.py create mode 100644 src/sampletones_player/compression/parse/result.py create mode 100644 src/sampletones_player/compression/parse/shortest.py create mode 100644 src/sampletones_player/compression/parse/song.py create mode 100644 src/sampletones_player/compression/pitch.py create mode 100644 src/sampletones_player/compression/planes/__init__.py create mode 100644 src/sampletones_player/compression/planes/channel.py create mode 100644 src/sampletones_player/compression/planes/order.py create mode 100644 src/sampletones_player/compression/planes/rebuild.py create mode 100644 src/sampletones_player/compression/planes/separate.py create mode 100644 src/sampletones_player/compression/planes/song.py create mode 100644 src/sampletones_player/compression/search.py create mode 100644 src/sampletones_player/compression/seeds.py create mode 100644 src/sampletones_player/compression/tokens/__init__.py create mode 100644 src/sampletones_player/compression/tokens/hold.py create mode 100644 src/sampletones_player/compression/tokens/literal.py create mode 100644 src/sampletones_player/compression/tokens/phrase.py create mode 100644 src/sampletones_player/compression/tokens/sizes.py create mode 100644 src/sampletones_player/compression/tokens/types.py create mode 100644 src/sampletones_player/registers/channel.py create mode 100644 src/sampletones_player/specification/compression.py create mode 100644 tests/integration/nsf/report.py create mode 100644 tests/integration/nsf/songs.py create mode 100644 tests/integration/nsf/test_compression_report.py create mode 100644 tests/unit/sampletones_player/compression/__init__.py create mode 100644 tests/unit/sampletones_player/compression/dictionary/__init__.py create mode 100644 tests/unit/sampletones_player/compression/dictionary/phrases.py create mode 100644 tests/unit/sampletones_player/compression/dictionary/test_phrase.py create mode 100644 tests/unit/sampletones_player/compression/dictionary/test_prune.py create mode 100644 tests/unit/sampletones_player/compression/dictionary/test_table.py create mode 100644 tests/unit/sampletones_player/compression/matches/__init__.py create mode 100644 tests/unit/sampletones_player/compression/matches/test_index.py create mode 100644 tests/unit/sampletones_player/compression/matches/test_matcher.py create mode 100644 tests/unit/sampletones_player/compression/matches/test_shift.py create mode 100644 tests/unit/sampletones_player/compression/parse/__init__.py create mode 100644 tests/unit/sampletones_player/compression/parse/test_boundaries.py create mode 100644 tests/unit/sampletones_player/compression/parse/test_plane.py create mode 100644 tests/unit/sampletones_player/compression/planes/__init__.py create mode 100644 tests/unit/sampletones_player/compression/planes/conftest.py create mode 100644 tests/unit/sampletones_player/compression/planes/test_channel.py create mode 100644 tests/unit/sampletones_player/compression/planes/test_order.py create mode 100644 tests/unit/sampletones_player/compression/planes/test_rebuild.py create mode 100644 tests/unit/sampletones_player/compression/planes/test_separate.py create mode 100644 tests/unit/sampletones_player/compression/planes/test_song.py create mode 100644 tests/unit/sampletones_player/compression/test_decode.py create mode 100644 tests/unit/sampletones_player/compression/test_encode.py create mode 100644 tests/unit/sampletones_player/compression/test_pitch.py create mode 100644 tests/unit/sampletones_player/compression/test_seeds.py create mode 100644 tests/unit/sampletones_player/compression/tokens/__init__.py create mode 100644 tests/unit/sampletones_player/compression/tokens/test_hold.py create mode 100644 tests/unit/sampletones_player/compression/tokens/test_literal.py create mode 100644 tests/unit/sampletones_player/compression/tokens/test_phrase.py create mode 100644 tests/unit/sampletones_player/registers/test_channel.py diff --git a/Makefile b/Makefile index a25081737..3a9a98d87 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples nsf-samples nsf-render icons player check-import-boundary check-tag-names check-unused-tags \ + ftm-samples nsf-samples nsf-render compression-report icons player check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -72,6 +72,7 @@ help: @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) @echo $(Q) make nsf-samples - Emit example .nsf files to build/nsf via the integration suite$(Q) @echo $(Q) make nsf-render - Render the .nsf files in build/nsf to waves with ffmpeg$(Q) + @echo $(Q) make compression-report - Measure the song codec into build/compression$(Q) @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make player - Assemble the NES player driver with cc65$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @@ -121,6 +122,10 @@ nsf-samples: nsf-render: nsf-samples uv run scripts/nsf_render.py +compression-report: export SAMPLETONES_COMPRESSION_OUTPUT_DIR := build/compression +compression-report: + uv run python -m pytest tests/integration/nsf/test_compression_report.py + icons: uv run --group assets python scripts/assets/icons.py diff --git a/docs/development/packages.md b/docs/development/packages.md index 30fc56817..c19f2d67b 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -84,6 +84,7 @@ them. | `specification/` | The register addresses, control bits, offsets and address constants the format is written by, one module per subject | — | | `clock/` | `PlaySchedule` and `FixedPointStep` — the engine ticks one play call advances a stream by | `specification/` | | `registers/` | The per-tick register values each channel plays, and the four streams together | `specification/` | +| `compression/` | The planes a song separates into, the dictionary its tokens name, and the codec that reads them both ways | `specification/`, `registers/` | | `song.py` | `Song` — the streams, the schedule and the loop point as one value | `clock/`, `registers/` | | `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded and its rate scheduled | `song.py`, `registers/`, `clock/` | | `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` | diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml index 8ee892b70..3bf214023 100644 --- a/src/sampletones_config/boundaries/graphs.yaml +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -17,6 +17,7 @@ player: specification: [] clock: [specification] registers: [specification] + compression: [specification, registers] song.py: [clock, registers] builder.py: [song.py, registers, clock] trace: [song.py, specification] diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 1255a5d19..8f40693f5 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -1,67 +1,22 @@ -from typing import Dict, Final, List, Mapping, Optional, Sequence, Type +from typing import Dict, Final, Mapping, Optional, Sequence from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP from sampletones_core.exports.request import InstrumentExport, SampleExport -from sampletones_core.instructions import ( - InstructionT, - InstructionUnion, - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) +from sampletones_core.instructions import InstructionUnion from sampletones_core.performance import song_instructions from sampletones_core.project.project import Project from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule -from sampletones_player.registers.noise import NoiseRegisters -from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.channel import channel_registers from sampletones_player.registers.streams import ChannelStreams -from sampletones_player.registers.triangle import TriangleRegisters from sampletones_player.song import Song from sampletones_shared.music import Tuning SONG_START: Final[int] = 0 -def channel_instructions( - instructions: Sequence[InstructionUnion], - instruction_type: Type[InstructionT], -) -> List[InstructionT]: - """One channel's stream, read as the instruction type that channel sounds. - - A reconstruction holds a stream for every channel, and a channel standing by holds one - describing no frame. Such a channel reaches the player resting for a single tick, which is - the shortest stream a song lays its records out from. - - Args: - instructions: The channel's stream, as the reconstruction holds it. - instruction_type: The instruction type the channel's encoder reads. - - Returns: - List[InstructionT]: The stream, covering at least one tick. - - Raises: - TypeError: If the stream holds an instruction another channel sounds. - """ - typed: List[InstructionT] = [] - for instruction in instructions: - if not isinstance(instruction, instruction_type): - raise TypeError( - f"a {instruction_type.__name__} stream holds {type(instruction).__name__} " - f"{instruction.name}, which another channel sounds" - ) - - typed.append(instruction) - - if typed: - return typed - - resting: InstructionT = instruction_type.null_instruction() - return [resting] - - def streams_from_instructions( instructions: Mapping[ChannelName, Sequence[InstructionUnion]], timer_table: Dict[int, int], @@ -81,28 +36,11 @@ def streams_from_instructions( Raises: TypeError: If a channel's stream holds an instruction another channel sounds. """ - pulse1 = channel_instructions( - instructions.get(ChannelName.PULSE1, ()), - PulseInstruction, - ) - pulse2 = channel_instructions( - instructions.get(ChannelName.PULSE2, ()), - PulseInstruction, - ) - triangle = channel_instructions( - instructions.get(ChannelName.TRIANGLE, ()), - TriangleInstruction, - ) - noise = channel_instructions( - instructions.get(ChannelName.NOISE, ()), - NoiseInstruction, - ) - return ChannelStreams( - pulse1=tuple(PulseRegisters.from_instructions(pulse1, timer_table)), - pulse2=tuple(PulseRegisters.from_instructions(pulse2, timer_table)), - triangle=tuple(TriangleRegisters.from_instructions(triangle, timer_table)), - noise=tuple(NoiseRegisters.from_instructions(noise)), + pulse1=channel_registers(ChannelName.PULSE1, instructions, timer_table), + pulse2=channel_registers(ChannelName.PULSE2, instructions, timer_table), + triangle=channel_registers(ChannelName.TRIANGLE, instructions, timer_table), + noise=channel_registers(ChannelName.NOISE, instructions, timer_table), ) diff --git a/src/sampletones_player/compression/__init__.py b/src/sampletones_player/compression/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py new file mode 100644 index 000000000..d4302cea0 --- /dev/null +++ b/src/sampletones_player/compression/compressed.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.planes.order import PlaneOrder + + +class CompressedPlanes(BaseModel): + """A song's planes as the driver reads them: one dictionary and eight token streams. + + Attributes: + phrases: The dictionary every stream's tokens name. + streams: The tokens each plane is written as, in the order the song block writes them. + ticks: The ticks the song lasts, which is where each stream stops being read. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + phrases: PhraseTable + streams: PlaneOrder + ticks: int + + @model_validator(mode="after") + def _validate_the_song_lasts(self) -> CompressedPlanes: + if self.ticks < 1: + raise ValueError(f"a song lasts at least one tick, and this one lasts {self.ticks}") + + return self + + @property + def size(self) -> int: + """The bytes the dictionary and the eight streams take together.""" + return self.phrases.size + sum(len(stream) for stream in self.streams) diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py new file mode 100644 index 000000000..75783dc6f --- /dev/null +++ b/src/sampletones_player/compression/decode.py @@ -0,0 +1,110 @@ +from typing import Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.compression import ( + BYTE_VALUES, + INITIAL_PLANE_VALUE, + PHRASE_ID_ESCAPE, + TOKEN_OPERAND_MASK, + TOKEN_TAG_MASK, + TokenTag, +) + + +def _phrase_values( + operand: int, + data: bytes, + position: int, + table: PhraseTable, + *, + transposed: bool, +) -> Tuple[bytes, int]: + phrase_id = operand + if operand == PHRASE_ID_ESCAPE: + phrase_id = data[position] + position += 1 + + ticks = data[position] + 1 + position += 1 + transpose = 0 + if transposed: + transpose = data[position] + position += 1 + + body = table[phrase_id].body + last = len(body) - 1 + played = bytes((body[min(offset, last)] + transpose) % BYTE_VALUES for offset in range(ticks)) + return played, position + + +def decode_plane(data: bytes, table: PhraseTable, ticks: int) -> bytes: + """Plays a plane's token stream back into the values it writes, tick by tick. + + This is the reading the driver performs, stated where it is testable: every encoding is held + against it, so what the console plays and what the encoder meant are the same values. + + Args: + data: The plane's token stream. + table: The dictionary the tokens name. + ticks: The ticks the song lasts. + + Returns: + bytes: The values the plane writes, one per tick. + """ + values = bytearray() + current = INITIAL_PLANE_VALUE + position = 0 + while len(values) < ticks: + opcode = data[position] + position += 1 + operand = opcode & TOKEN_OPERAND_MASK + match TokenTag(opcode & TOKEN_TAG_MASK): + case TokenTag.HOLD: + played = bytes([current]) * (operand + 1) + case TokenTag.LITERAL: + played = data[position : position + operand + 1] + position += operand + 1 + case TokenTag.PHRASE: + played, position = _phrase_values( + operand, + data, + position, + table, + transposed=False, + ) + case TokenTag.TRANSPOSED_PHRASE: + played, position = _phrase_values( + operand, + data, + position, + table, + transposed=True, + ) + + current = played[-1] + values.extend(played) + + return bytes(values[:ticks]) + + +def decode_planes(compressed: CompressedPlanes) -> SongPlanes: + """Plays a song's eight token streams back into the planes they were written from. + + Args: + compressed: The dictionary, the streams and the ticks the song lasts. + + Returns: + SongPlanes: The eight planes, two per channel. + """ + played = PlaneOrder.across( + decode_plane( + stream, + compressed.phrases, + compressed.ticks, + ) + for stream in compressed.streams + ) + return SongPlanes.from_order(played) diff --git a/src/sampletones_player/compression/dictionary/__init__.py b/src/sampletones_player/compression/dictionary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/dictionary/phrase.py b/src/sampletones_player/compression/dictionary/phrase.py new file mode 100644 index 000000000..8241b92a5 --- /dev/null +++ b/src/sampletones_player/compression/dictionary/phrase.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.specification.compression import ( + BYTE_VALUES, + MAX_PHRASE_LENGTH, + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_ENTRY_SIZE, +) + + +class Phrase(BaseModel): + """One entry of the dictionary: the values a plane plays when a token names it. + + A phrase is stored at the pitch it was found at, and a token states the shift it is played + at, so a note's shape is written once and every pitch it sounds at names that one entry. + + Attributes: + body: The values the phrase plays, one per tick. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + body: bytes + + @model_validator(mode="after") + def _validate_the_body_fits_a_table_entry(self) -> Phrase: + if not 1 <= len(self.body) <= MAX_PHRASE_LENGTH: + raise ValueError(f"a phrase runs from 1 to {MAX_PHRASE_LENGTH} values, and this one runs {len(self.body)}") + + return self + + @property + def length(self) -> int: + """The ticks the phrase's own values cover.""" + return len(self.body) + + @property + def differences(self) -> bytes: + """The step from each value to the next, which is the shape a shift leaves alone.""" + return bytes((following - value) % BYTE_VALUES for value, following in zip(self.body, self.body[1:])) + + @property + def size(self) -> int: + """The bytes the phrase takes in the song block, its table entry included.""" + return PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + len(self.body) diff --git a/src/sampletones_player/compression/dictionary/prune.py b/src/sampletones_player/compression/dictionary/prune.py new file mode 100644 index 000000000..2b9e60254 --- /dev/null +++ b/src/sampletones_player/compression/dictionary/prune.py @@ -0,0 +1,44 @@ +from typing import List, Mapping, NamedTuple + +from sampletones_player.compression.dictionary.table import PhraseTable + + +class _KeptPhrase(NamedTuple): + references: int + phrase_id: int + + +def _pays_for_itself( + table: PhraseTable, + references: Mapping[int, int], + savings: Mapping[int, int], +) -> List[_KeptPhrase]: + return [ + _KeptPhrase(references=references[phrase_id], phrase_id=phrase_id) + for phrase_id in range(len(table)) + if references[phrase_id] > 0 and savings[phrase_id] > table[phrase_id].size + ] + + +def prune( + table: PhraseTable, + references: Mapping[int, int], + savings: Mapping[int, int], +) -> PhraseTable: + """Rebuilds a table around the phrases that pay for themselves, most named first. + + A phrase earns its place by sparing the streams more bytes than its own entry takes, and the + ids it competes for are worth a byte apiece: the cheap ones ride inside an opcode, so the + phrases named most often take them and the tokens naming those shed a byte each. + + Args: + table: The table the tokens were parsed against. + references: How many tokens name each phrase id. + savings: The bytes each phrase's tokens spare the streams. + + Returns: + PhraseTable: The phrases worth keeping, ordered by how often they are named. + """ + kept = _pays_for_itself(table, references, savings) + kept.sort(key=lambda entry: (-entry.references, entry.phrase_id)) + return PhraseTable(phrases=tuple(table[entry.phrase_id] for entry in kept)) diff --git a/src/sampletones_player/compression/dictionary/table.py b/src/sampletones_player/compression/dictionary/table.py new file mode 100644 index 000000000..5b13edd33 --- /dev/null +++ b/src/sampletones_player/compression/dictionary/table.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Iterable, Set, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.specification.compression import ( + MAX_PHRASE_IDS, + PHRASE_TABLE_COUNT_SIZE, +) + + +class PhraseTable(BaseModel): + """The dictionary a song's tokens name, in the order the song block writes it. + + A phrase's position is its id, and the cheap ids ride inside a token's opcode, so the order + is part of the encoding: the phrases a song leans on hardest take the ids that cost least. + + Attributes: + phrases: The phrases, in id order. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + phrases: Tuple[Phrase, ...] + + @model_validator(mode="after") + def _validate_the_table_fits_the_ids_a_token_reaches(self) -> PhraseTable: + if len(self.phrases) > MAX_PHRASE_IDS: + raise ValueError(f"a token names one of {MAX_PHRASE_IDS} phrases, and the table holds {len(self.phrases)}") + + return self + + def __len__(self) -> int: + return len(self.phrases) + + def __getitem__(self, phrase_id: int) -> Phrase: + return self.phrases[phrase_id] + + @property + def size(self) -> int: + """The bytes the whole dictionary takes in the song block.""" + return PHRASE_TABLE_COUNT_SIZE + sum(phrase.size for phrase in self.phrases) + + +def phrase_table(phrases: Iterable[Phrase]) -> PhraseTable: + """Collects phrases into a table, each shape kept once and the ids capped at what fits. + + Args: + phrases: The phrases to hold, in the order they are preferred. + + Returns: + PhraseTable: The table, holding as many of them as a token can name. + """ + collected: Tuple[Phrase, ...] = () + seen: Set[bytes] = set() + for phrase in phrases: + if phrase.body in seen or len(collected) == MAX_PHRASE_IDS: + continue + + seen.add(phrase.body) + collected += (phrase,) + + return PhraseTable(phrases=collected) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py new file mode 100644 index 000000000..0f47db2f2 --- /dev/null +++ b/src/sampletones_player/compression/encode.py @@ -0,0 +1,145 @@ +from dataclasses import replace +from typing import Dict, Final, FrozenSet, Iterable, Sequence, Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.prune import prune +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.parse.song import parse_planes +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.search import search_phrases +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken +from sampletones_player.compression.tokens.types import TokenUnion +from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag + +STREAM_START: Final[int] = 0 +SETTLING_ROUNDS: Final[int] = 3 + + +def emit(tokens: Sequence[TokenUnion]) -> bytes: + """Writes a plane's tokens out as the bytes the driver reads them from. + + Args: + tokens: The tokens the plane is written as, in the order they are read. + + Returns: + bytes: The plane's token stream. + """ + stream = bytearray() + for token in tokens: + match token: + case HoldToken(): + stream.append(TokenTag.HOLD | (token.ticks - 1)) + case LiteralToken(): + stream.append(TokenTag.LITERAL | (len(token.values) - 1)) + stream.extend(token.values) + case PhraseToken(): + tag = TokenTag.TRANSPOSED_PHRASE if token.transpose else TokenTag.PHRASE + named = min(token.phrase_id, PHRASE_ID_ESCAPE) + stream.append(tag | named) + if named == PHRASE_ID_ESCAPE: + stream.append(token.phrase_id) + + stream.append(token.ticks - 1) + if token.transpose: + stream.append(token.transpose) + + return bytes(stream) + + +def _references(parses: Iterable[Parse], phrases: int) -> Dict[int, int]: + references = {phrase_id: 0 for phrase_id in range(phrases)} + for parse in parses: + for token in parse.tokens: + if isinstance(token, PhraseToken): + references[token.phrase_id] += 1 + + return references + + +def _savings( + parses: Sequence[Parse], + baseline: Sequence[Parse], + phrases: int, +) -> Dict[int, int]: + savings = {phrase_id: 0 for phrase_id in range(phrases)} + for parse, plain in zip(parses, baseline): + position = 0 + for token in parse.tokens: + if isinstance(token, PhraseToken): + spared = plain.costs[position + token.ticks] - plain.costs[position] + savings[token.phrase_id] += spared - token.size + + position += token.ticks + + return savings + + +def _settle( + indices: Sequence[PlaneIndex], + table: PhraseTable, + options: CodecOptions, + boundaries: FrozenSet[int], +) -> Tuple[PhraseTable, Tuple[Parse, ...]]: + baseline = parse_planes( + indices, + phrase_table(()), + replace(options, phrases=False), + boundaries, + ) + parses = parse_planes(indices, table, options, boundaries) + for _ in range(SETTLING_ROUNDS): + pruned = prune( + table, + _references(parses, len(table)), + _savings(parses, baseline, len(table)), + ) + if pruned.phrases == table.phrases: + break + + table = pruned + parses = parse_planes(indices, table, options, boundaries) + + return table, parses + + +def encode_planes( + planes: SongPlanes, + seeds: Sequence[Phrase], + options: CodecOptions, + boundaries: FrozenSet[int], +) -> CompressedPlanes: + """Compresses a song's eight planes into the dictionary and streams the driver reads. + + The instruments seed the dictionary, the search fills what they leave behind, and the table + then settles: phrases the parse names keep their place in the order they are leaned on, and + the parse runs again over the ids that frees, which is what puts the busiest phrases inside + the opcodes that name them. + + Args: + planes: The eight planes, two per channel. + seeds: The phrases the song's instruments offer. + options: Which of the codec's layers the encoding is built from. + boundaries: The ticks a token starts on, beyond the first tick of the song. + + Returns: + CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. + """ + indices = tuple(PlaneIndex.from_plane(plane) for plane in planes.planes) + entries = boundaries | {STREAM_START} + table = phrase_table(seeds) if options.phrases else phrase_table(()) + if options.phrases and options.search: + table = search_phrases(indices, table, options, entries) + + table, parses = _settle(indices, table, options, entries) + return CompressedPlanes( + phrases=table, + streams=PlaneOrder.across(emit(parse.tokens) for parse in parses), + ticks=planes.ticks, + ) diff --git a/src/sampletones_player/compression/matches/__init__.py b/src/sampletones_player/compression/matches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/matches/index.py b/src/sampletones_player/compression/matches/index.py new file mode 100644 index 000000000..08559d1cc --- /dev/null +++ b/src/sampletones_player/compression/matches/index.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import pairwise +from typing import List, Tuple + +from sampletones_player.specification.compression import BYTE_VALUES + + +@dataclass(frozen=True) +class PlaneIndex: + """A plane alongside the two readings of it every match is decided by. + + A phrase is stored at one pitch and played at any, so what identifies it is the step from + each value to the next rather than the values themselves. The runs answer the other half: + how far a value carries once a phrase has played its last, which is how a token states a + note that outlasts its envelope. + + Attributes: + plane: The values the plane plays, one per tick. + differences: The step from each value to the next. + runs: How many ticks the value at each position holds for. + """ + + plane: bytes + differences: bytes + runs: Tuple[int, ...] + + @classmethod + def from_plane(cls, plane: bytes) -> PlaneIndex: + """Reads a plane into the form matching is decided against. + + Args: + plane: The values the plane plays, one per tick. + + Returns: + PlaneIndex: The plane, its steps and its runs. + """ + differences = bytes((following - value) % BYTE_VALUES for value, following in pairwise(plane)) + runs: List[int] = [1] * len(plane) + for position in range(len(plane) - 2, -1, -1): + if plane[position] == plane[position + 1]: + runs[position] = runs[position + 1] + 1 + + return cls( + plane=plane, + differences=differences, + runs=tuple(runs), + ) + + @property + def ticks(self) -> int: + """The ticks the plane covers.""" + return len(self.plane) diff --git a/src/sampletones_player/compression/matches/match.py b/src/sampletones_player/compression/matches/match.py new file mode 100644 index 000000000..ebfd4c4c9 --- /dev/null +++ b/src/sampletones_player/compression/matches/match.py @@ -0,0 +1,15 @@ +from typing import NamedTuple + + +class PhraseMatch(NamedTuple): + """A phrase the plane plays from a tick, and the terms it plays it on. + + Attributes: + phrase_id: Position the phrase takes in the table. + ticks: The ticks the plane plays of it, its final value held past its end. + transpose: The shift every byte of it is played at. + """ + + phrase_id: int + ticks: int + transpose: int diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py new file mode 100644 index 000000000..dfee53d7a --- /dev/null +++ b/src/sampletones_player/compression/matches/matcher.py @@ -0,0 +1,84 @@ +from itertools import chain +from typing import Dict, Final, Iterator, List, Tuple + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.match import PhraseMatch +from sampletones_player.compression.matches.played import played_ticks +from sampletones_player.compression.matches.shift import translation +from sampletones_player.specification.compression import BYTE_VALUES + +KEY_LENGTH: Final[int] = 2 +MIN_PHRASE_TICKS: Final[int] = 2 + + +class PhraseMatcher: + """Answers which phrases a plane plays at a position, and for how many ticks. + + Phrases are held under the first steps of their shape, so a position offers a handful of + candidates to confirm rather than the whole dictionary. A candidate confirms at the shift its + first value asks for, which is the one shift that can possibly match there. + + A phrase is offered where the plane plays enough of it for those steps to tell it apart, + which is a tick longer than the shortlist's key. A note cut shorter than that is spelled out, + where a phrase token and a literal cost the same anyway. + """ + + def __init__(self, table: PhraseTable) -> None: + self._bodies: Tuple[bytes, ...] = tuple(phrase.body for phrase in table.phrases) + self._keyed: Dict[bytes, Tuple[int, ...]] = {} + short: List[int] = [] + for phrase_id, phrase in enumerate(table.phrases): + differences = phrase.differences + if len(differences) < KEY_LENGTH: + short.append(phrase_id) + continue + + key = differences[:KEY_LENGTH] + self._keyed[key] = self._keyed.get(key, ()) + (phrase_id,) + + self._short: Tuple[int, ...] = tuple(short) + + def matches( + self, + index: PlaneIndex, + position: int, + limit: int, + *, + transposition: bool, + ) -> Iterator[PhraseMatch]: + """Every phrase the plane plays from ``position``, with the ticks and shift it plays at. + + Args: + index: The plane and the two readings of it matching is decided against. + position: The tick the phrase would start at. + limit: The most ticks a token may cover from there. + transposition: Whether a phrase may play at a shift. + + Yields: + PhraseMatch: The phrase, the ticks it covers and the shift it plays at. + """ + if limit < MIN_PHRASE_TICKS: + return + + origin = index.plane[position] + key = index.differences[position : position + KEY_LENGTH] + for phrase_id in chain(self._keyed.get(key, ()), self._short): + body = self._bodies[phrase_id] + transpose = (origin - body[0]) % BYTE_VALUES + if transpose and not transposition: + continue + + expected = body.translate(translation(transpose)) + ticks = played_ticks( + index, + position, + expected, + limit, + ) + if ticks >= MIN_PHRASE_TICKS: + yield PhraseMatch( + phrase_id=phrase_id, + ticks=ticks, + transpose=transpose, + ) diff --git a/src/sampletones_player/compression/matches/played.py b/src/sampletones_player/compression/matches/played.py new file mode 100644 index 000000000..b733a2f9d --- /dev/null +++ b/src/sampletones_player/compression/matches/played.py @@ -0,0 +1,85 @@ +from sampletones_player.compression.matches.index import PlaneIndex + + +def _common_prefix( + plane: bytes, + position: int, + expected: bytes, + usable: int, +) -> int: + for offset in range(usable): + if plane[position + offset] != expected[offset]: + return offset + + return usable + + +def _matched_ticks( + plane: bytes, + position: int, + expected: bytes, + usable: int, +) -> int: + """The ticks the plane agrees with ``expected`` for, out of the ``usable`` it may play.""" + if plane.startswith(expected[:usable], position): + return usable + + return _common_prefix( + plane, + position, + expected, + usable, + ) + + +def _held_ticks( + index: PlaneIndex, + position: int, + expected: bytes, + limit: int, +) -> int: + """The ticks a phrase covers once it has played out, its final value carrying onwards.""" + end = position + len(expected) + if len(expected) == limit or index.plane[end] != expected[-1]: + return len(expected) + + return min(limit, len(expected) + index.runs[end]) + + +def played_ticks( + index: PlaneIndex, + position: int, + expected: bytes, + limit: int, +) -> int: + """The ticks the plane plays of ``expected`` from ``position``. + + A phrase matches for as long as the plane agrees with it, and a phrase the plane plays whole + keeps covering ticks for as long as the value it ended on holds — which is the note that + outlasts its envelope, stated by the one token. + + Args: + index: The plane and the two readings of it matching is decided against. + position: The tick the phrase would start at. + expected: The values the phrase plays, already at the shift it is played at. + limit: The most ticks a token may cover from there. + + Returns: + int: The ticks the phrase covers, which is none where the plane parts from it at once. + """ + usable = min(len(expected), limit) + played = _matched_ticks( + index.plane, + position, + expected, + usable, + ) + if played < usable or usable < len(expected): + return played + + return _held_ticks( + index, + position, + expected, + limit, + ) diff --git a/src/sampletones_player/compression/matches/shift.py b/src/sampletones_player/compression/matches/shift.py new file mode 100644 index 000000000..4de706e6d --- /dev/null +++ b/src/sampletones_player/compression/matches/shift.py @@ -0,0 +1,23 @@ +from functools import lru_cache + +from sampletones_player.specification.compression import BYTE_VALUES + + +@lru_cache(maxsize=BYTE_VALUES) +def translation(transpose: int) -> bytes: + """The byte table playing a phrase at a shift, every value moved by ``transpose``. + + A shift wraps within the byte, which is the one addition the driver performs and the one the + encoder has to agree with. The tables are kept as they are asked for, since a song reaches + for the same handful of shifts across every plane. + + Args: + transpose: The shift every byte of a phrase is played at. + + Returns: + bytes: The translation table the shift is applied through. + """ + return bytes.maketrans( + bytes(range(BYTE_VALUES)), + bytes((value + transpose) % BYTE_VALUES for value in range(BYTE_VALUES)), + ) diff --git a/src/sampletones_player/compression/options.py b/src/sampletones_player/compression/options.py new file mode 100644 index 000000000..002951b8f --- /dev/null +++ b/src/sampletones_player/compression/options.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CodecOptions: + """Which of the codec's layers an encoding is built from. + + Every layer earns its place on measured ground, so each is switched on its own and a report + reads the bytes each one saves. Literals carry any plane on their own, so an encoding with + every layer off still describes the song. + + Attributes: + holds: Whether a run of one value reaches the stream as a hold. + phrases: Whether tokens name phrases from the dictionary. + transposition: Whether a phrase is played shifted, one entry serving every pitch a note + is played at. + search: Whether the encoder looks for phrases beyond the ones the instruments seed. + """ + + holds: bool + phrases: bool + transposition: bool + search: bool diff --git a/src/sampletones_player/compression/parse/__init__.py b/src/sampletones_player/compression/parse/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/parse/boundaries.py b/src/sampletones_player/compression/parse/boundaries.py new file mode 100644 index 000000000..4b383416e --- /dev/null +++ b/src/sampletones_player/compression/parse/boundaries.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import FrozenSet, List, Tuple + + +@dataclass(frozen=True) +class Boundaries: + """The ticks a token starts on, read forwards and backwards from every tick of a plane. + + A loop re-enters the stream partway through, so the tick it re-enters at holds a token of + its own and nothing spans across it. Knowing the nearest boundary either side of a tick is + what keeps that true while the cheapest reading is searched for. + + Attributes: + entries: The ticks a token is required to start on. + previous: The nearest boundary at or before each tick. + following: The nearest boundary after each tick, the plane's end standing in beyond the + last of them. + """ + + entries: FrozenSet[int] + previous: Tuple[int, ...] + following: Tuple[int, ...] + + @classmethod + def across(cls, ticks: int, entries: FrozenSet[int]) -> Boundaries: + """Reads the boundaries of a plane into the two lookups a parse asks them by. + + Args: + ticks: The ticks the plane covers. + entries: The ticks a token is required to start on. + + Returns: + Boundaries: The entries and the nearest one either side of every tick. + """ + previous: List[int] = [0] * (ticks + 1) + for position in range(1, ticks + 1): + previous[position] = position - 1 if position - 1 in entries else previous[position - 1] + + following: List[int] = [ticks] * (ticks + 1) + for position in range(ticks - 1, -1, -1): + following[position] = position + 1 if position + 1 in entries else following[position + 1] + + return cls( + entries=entries, + previous=tuple(previous), + following=tuple(following), + ) diff --git a/src/sampletones_player/compression/parse/literals.py b/src/sampletones_player/compression/parse/literals.py new file mode 100644 index 000000000..3c1233d9e --- /dev/null +++ b/src/sampletones_player/compression/parse/literals.py @@ -0,0 +1,42 @@ +from collections import deque +from typing import Deque, Sequence + + +class LiteralWindow: + """The tick a literal reaching the tick under consideration is cheapest to start from. + + A literal costs its opcode and its bytes alike whatever its length, so the start worth taking + is the one whose own cost, set against how far back it sits, is least — and a start beaten + by a later one is beaten for good. Keeping the starts in that order leaves the best of them + at the front, which is what lets one pass over a plane price every literal in it. + """ + + def __init__(self, costs: Sequence[int]) -> None: + self._costs = costs + self._starts: Deque[int] = deque() + + def cheapest(self, position: int, earliest: int) -> int: + """The tick the cheapest literal ending at ``position`` starts on. + + The tick before ``position`` joins the window as a start of its own, the starts it has + beaten leave it, and so do the ones that have fallen out of reach. + + Args: + position: The tick the literal ends at. + earliest: The earliest tick that literal may start on. + + Returns: + int: The tick the cheapest literal starts on. + """ + starts = self._starts + costs = self._costs + start = position - 1 + key = costs[start] - start + while starts and costs[starts[-1]] - starts[-1] >= key: + starts.pop() + + starts.append(start) + while starts[0] < earliest: + starts.popleft() + + return starts[0] diff --git a/src/sampletones_player/compression/parse/plane.py b/src/sampletones_player/compression/parse/plane.py new file mode 100644 index 000000000..c57cb0610 --- /dev/null +++ b/src/sampletones_player/compression/parse/plane.py @@ -0,0 +1,130 @@ +from typing import FrozenSet + +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.matcher import PhraseMatcher +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.boundaries import Boundaries +from sampletones_player.compression.parse.literals import LiteralWindow +from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.parse.shortest import Shortest +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken +from sampletones_player.compression.tokens.sizes import ( + hold_size, + literal_size, + phrase_size, +) +from sampletones_player.specification.compression import ( + MAX_HOLD_TICKS, + MAX_LITERAL_BYTES, + MAX_PHRASE_TICKS, +) + + +def _relax_literal( + shortest: Shortest, + plane: bytes, + window: LiteralWindow, + position: int, + earliest: int, +) -> None: + """Spelling the values out reaches ``position`` from wherever that costs least.""" + start = window.cheapest(position, earliest) + cost = shortest.costs[start] + literal_size(position - start) + if shortest.improves(position, cost): + shortest.relax(start, position, cost, LiteralToken(values=plane[start:position])) + + +def _relax_forward( + shortest: Shortest, + index: PlaneIndex, + matcher: PhraseMatcher, + options: CodecOptions, + position: int, + reach: int, + *, + holdable: bool, +) -> None: + """Everything a token starting at ``position`` may cover: a run held on, or a phrase played.""" + plane = index.plane + cost = shortest.costs[position] + if options.holds and holdable and plane[position] == plane[position - 1]: + ticks = min(MAX_HOLD_TICKS, index.runs[position], reach) + shortest.relax( + position, + position + ticks, + cost + hold_size(), + HoldToken(ticks=ticks), + ) + + if not options.phrases: + return + + for phrase_id, ticks, transpose in matcher.matches( + index, + position, + min(MAX_PHRASE_TICKS, reach), + transposition=options.transposition, + ): + shortest.relax( + position, + position + ticks, + cost + phrase_size(phrase_id, transpose), + PhraseToken(phrase_id=phrase_id, ticks=ticks, transpose=transpose), + ) + + +def parse_plane( + index: PlaneIndex, + matcher: PhraseMatcher, + options: CodecOptions, + boundaries: FrozenSet[int], +) -> Parse: + """Reads a plane as the cheapest token stream the dictionary allows. + + Every way of covering a tick is an edge — hold the value, spell it out, or play a phrase from + there — and the cheapest path across them all is the encoding. Costs are the bytes each token + takes, so the parse answers in the currency the program area is measured in. + + A boundary is a tick a token starts on, which is how a loop entry stays reachable: the stream + is re-entered there, and a token that leans on the value the plane already reached starts + elsewhere. + + Args: + index: The plane and the two readings of it matching is decided against. + matcher: The phrases the plane may play. + options: Which of the codec's layers the encoding is built from. + boundaries: The ticks a token starts on. + + Returns: + Parse: The tokens the plane is written as, and what each of its prefixes costs. + """ + plane = index.plane + ticks = index.ticks + entries = Boundaries.across(ticks, boundaries) + previous = entries.previous + following = entries.following + shortest = Shortest.across(ticks) + window = LiteralWindow(shortest.costs) + _relax_forward(shortest, index, matcher, options, 0, following[0], holdable=False) + for position in range(1, ticks + 1): + _relax_literal( + shortest, + plane, + window, + position, + max(position - MAX_LITERAL_BYTES, previous[position]), + ) + if position < ticks: + _relax_forward( + shortest, + index, + matcher, + options, + position, + following[position] - position, + holdable=position not in boundaries, + ) + + return Parse(tokens=shortest.walk(ticks), costs=tuple(shortest.costs)) diff --git a/src/sampletones_player/compression/parse/result.py b/src/sampletones_player/compression/parse/result.py new file mode 100644 index 000000000..e7659c150 --- /dev/null +++ b/src/sampletones_player/compression/parse/result.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from typing import Tuple + +from sampletones_player.compression.tokens.types import TokenUnion + + +@dataclass(frozen=True) +class Parse: + """A plane read as tokens, alongside what each of its prefixes costs. + + Attributes: + tokens: The tokens the plane is written as, in the order they are read. + costs: The bytes each prefix of the plane takes, the whole plane's cost last. + """ + + tokens: Tuple[TokenUnion, ...] + costs: Tuple[int, ...] + + @property + def size(self) -> int: + """The bytes the plane's token stream takes.""" + return self.costs[-1] diff --git a/src/sampletones_player/compression/parse/shortest.py b/src/sampletones_player/compression/parse/shortest.py new file mode 100644 index 000000000..be3bf09b7 --- /dev/null +++ b/src/sampletones_player/compression/parse/shortest.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +from sampletones_player.compression.tokens.types import TokenUnion + +UNREACHED: Final[int] = -1 + + +@dataclass +class Shortest: + """The cheapest way found so far of reaching each tick of a plane. + + Every token is an edge from the tick it starts on to the tick after the ones it covers, and + its cost is the bytes it takes, so the cheapest path across the plane is its encoding. Each + tick remembers what it cost to reach and the token that reached it, which is what lets the + tokens be read back once the far end is settled. + + Attributes: + costs: The bytes reaching each tick takes, ticks not yet reached holding ``UNREACHED``. + origins: The tick each one was reached from. + tokens: The token each tick was reached by. + """ + + costs: List[int] + origins: List[int] + tokens: List[Optional[TokenUnion]] + + @classmethod + def across(cls, ticks: int) -> Shortest: + """Opens a search over a plane of ``ticks`` ticks, its first tick free to reach.""" + return cls( + costs=[0] + [UNREACHED] * ticks, + origins=[0] * (ticks + 1), + tokens=[None] * (ticks + 1), + ) + + def improves(self, end: int, cost: int) -> bool: + """Whether reaching ``end`` for ``cost`` beats what it has been reached for so far.""" + return self.costs[end] == UNREACHED or cost < self.costs[end] + + def relax( + self, + start: int, + end: int, + cost: int, + token: TokenUnion, + ) -> None: + """Takes a token as the way to reach ``end`` where it is the cheapest one found. + + Args: + start: The tick the token starts on. + end: The tick following the ones the token covers. + cost: The bytes reaching ``end`` through this token takes. + token: The token covering the ticks between the two. + """ + if self.costs[end] != UNREACHED and self.costs[end] <= cost: + return + + self.costs[end] = cost + self.origins[end] = start + self.tokens[end] = token + + def walk(self, ticks: int) -> Tuple[TokenUnion, ...]: + """Reads the tokens of the cheapest path back from ``ticks`` to the plane's first tick. + + Args: + ticks: The tick the path ends at, which is the ticks the plane covers. + + Returns: + Tuple[TokenUnion, ...]: The tokens, in the order they are read. + + Raises: + ValueError: If a tick along the path was never reached. + """ + read: List[TokenUnion] = [] + position = ticks + while position > 0: + token = self.tokens[position] + if token is None: + raise ValueError(f"tick {position} of the plane was left unreachable") + + read.append(token) + position = self.origins[position] + + return tuple(reversed(read)) diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py new file mode 100644 index 000000000..a683d8670 --- /dev/null +++ b/src/sampletones_player/compression/parse/song.py @@ -0,0 +1,29 @@ +from typing import FrozenSet, Sequence, Tuple + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.matcher import PhraseMatcher +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.plane import parse_plane +from sampletones_player.compression.parse.result import Parse + + +def parse_planes( + indices: Sequence[PlaneIndex], + table: PhraseTable, + options: CodecOptions, + boundaries: FrozenSet[int], +) -> Tuple[Parse, ...]: + """Reads every plane of a song against one dictionary. + + Args: + indices: The planes and the readings of them matching is decided against. + table: The phrases the planes may play. + options: Which of the codec's layers the encoding is built from. + boundaries: The ticks a token starts on. + + Returns: + Tuple[Parse, ...]: One parse per plane, in the order the planes were given. + """ + matcher = PhraseMatcher(table) + return tuple(parse_plane(index, matcher, options, boundaries) for index in indices) diff --git a/src/sampletones_player/compression/pitch.py b/src/sampletones_player/compression/pitch.py new file mode 100644 index 000000000..34636621a --- /dev/null +++ b/src/sampletones_player/compression/pitch.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Dict, Final, Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH +from sampletones_shared.music import Tuning + +PITCH_COUNT: Final[int] = LIMIT_MAX_PITCH - LIMIT_MIN_PITCH + 1 + + +class PitchTable(BaseModel): + """The timer every pitch sounds at, indexed the way a channel's plane names a pitch. + + A plane states a pitch as its distance above the lowest pitch the project reaches, and the + table resolves that index into the divider the hardware takes. Naming pitches rather than + dividers is what makes a phrase transposable: adding a semitone to an index moves a note, + where adding one to a timer means nothing. + + Attributes: + timers: The timer for each pitch, from the lowest the project reaches upwards. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + timers: Tuple[int, ...] + + @classmethod + def from_tuning(cls, tuning: Tuning) -> PitchTable: + """Builds the table the reconstruction's own generators are tuned by. + + Args: + tuning: Where concert pitch sits for the song being written. + + Returns: + PitchTable: The timer each pitch sounds at, in pitch order. + """ + table = get_timer_table(tuning) + return cls( + timers=tuple( + table[pitch] + for pitch in range( + LIMIT_MIN_PITCH, + LIMIT_MAX_PITCH + 1, + ) + ) + ) + + @property + def indices(self) -> Dict[int, int]: + """The index each timer is written as, the lowest pitch sounding it standing for it. + + Pitches beyond the divider's range share the timer they are clamped to, and they sound + alike, so one index stands for the whole group and a stream naming any of them resolves + back to the timer it was written from. + """ + indices: Dict[int, int] = {} + for index, timer in enumerate(self.timers): + indices.setdefault(timer, index) + + return indices + + @property + def data(self) -> bytes: + """The table as the driver reads it: every low byte, then every high byte.""" + low = bytes(timer & MAX_REGISTER_VALUE for timer in self.timers) + high = bytes(timer >> TIMER_HIGH_SHIFT for timer in self.timers) + return low + high diff --git a/src/sampletones_player/compression/planes/__init__.py b/src/sampletones_player/compression/planes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/planes/channel.py b/src/sampletones_player/compression/planes/channel.py new file mode 100644 index 000000000..7a836d17d --- /dev/null +++ b/src/sampletones_player/compression/planes/channel.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + + +class ChannelPlanes(BaseModel): + """One channel's ticks separated into the two byte series it writes. + + A channel writes two things each tick: how it sounds and what it sounds. Read tick by tick + those two braid together, and each turns over at its own pace — a volume envelope decays + while a pitch holds, a pitch walks while the timbre stays put. Kept apart, each is a series + that repeats and rests on its own terms, which is the form the codec reads them in. + + Attributes: + control: The timbre byte each tick writes, volume riding in it where a channel has one. + value: The pitch each tick sounds, as an index into the pitch table, or the noise + channel's period byte. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + control: bytes + value: bytes + + @model_validator(mode="after") + def _validate_both_planes_cover_the_same_ticks(self) -> ChannelPlanes: + if len(self.control) != len(self.value): + raise ValueError( + f"a channel's planes cover the same ticks, and these cover " + f"{len(self.control)} and {len(self.value)}" + ) + + if not self.control: + raise ValueError("a channel's planes cover at least one tick") + + return self + + @property + def ticks(self) -> int: + """The ticks both planes cover.""" + return len(self.control) + + @property + def ordered(self) -> Tuple[bytes, ...]: + """Both planes, in the order the song block writes them.""" + return (self.control, self.value) diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py new file mode 100644 index 000000000..a8121eeb3 --- /dev/null +++ b/src/sampletones_player/compression/planes/order.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Iterable, NamedTuple + +from sampletones_player.specification.compression import PLANE_COUNT + + +class PlaneOrder(NamedTuple): + """One byte series per plane, in the order the song block writes them. + + The song block states its planes as a run of eight, and both readings of a song take that + shape: the values each plane plays tick by tick, and the tokens those values are written as. + Naming the eight is what lets either be carried whole and read back by the channel it + belongs to. + + Attributes: + pulse1_control: The first pulse channel's timbre and volume. + pulse1_value: The first pulse channel's pitch. + pulse2_control: The second pulse channel's timbre and volume. + pulse2_value: The second pulse channel's pitch. + triangle_control: The triangle channel's linear counter. + triangle_value: The triangle channel's pitch. + noise_control: The noise channel's timbre and volume. + noise_value: The noise channel's period. + """ + + pulse1_control: bytes + pulse1_value: bytes + pulse2_control: bytes + pulse2_value: bytes + triangle_control: bytes + triangle_value: bytes + noise_control: bytes + noise_value: bytes + + @classmethod + def across(cls, planes: Iterable[bytes]) -> PlaneOrder: + """Gathers a song's planes under the names the song block writes them by. + + Args: + planes: The planes, in the order the song block writes them. + + Returns: + PlaneOrder: The planes, each under its own name. + + Raises: + ValueError: If the planes given are other than the ones a song block holds. + """ + gathered = tuple(planes) + if len(gathered) != PLANE_COUNT: + raise ValueError(f"a song block holds {PLANE_COUNT} planes, and these are {len(gathered)}") + + return cls._make(gathered) diff --git a/src/sampletones_player/compression/planes/rebuild.py b/src/sampletones_player/compression/planes/rebuild.py new file mode 100644 index 000000000..235151146 --- /dev/null +++ b/src/sampletones_player/compression/planes/rebuild.py @@ -0,0 +1,73 @@ +from typing import Tuple + +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.registers.triangle import TriangleRegisters +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) + + +def _pulse_registers( + planes: ChannelPlanes, + timers: Tuple[int, ...], +) -> Tuple[PulseRegisters, ...]: + return tuple( + PulseRegisters( + control=control, + timer_low=timers[index] & MAX_REGISTER_VALUE, + timer_high=timers[index] >> TIMER_HIGH_SHIFT, + ) + for control, index in zip(planes.control, planes.value) + ) + + +def _triangle_registers( + planes: ChannelPlanes, + timers: Tuple[int, ...], +) -> Tuple[TriangleRegisters, ...]: + return tuple( + TriangleRegisters( + linear_counter=control, + timer_low=timers[index] & MAX_REGISTER_VALUE, + timer_high=timers[index] >> TIMER_HIGH_SHIFT, + ) + for control, index in zip(planes.control, planes.value) + ) + + +def _noise_registers(planes: ChannelPlanes) -> Tuple[NoiseRegisters, ...]: + return tuple( + NoiseRegisters( + control=control, + period=period, + ) + for control, period in zip(planes.control, planes.value) + ) + + +def streams_from_planes( + planes: SongPlanes, + pitches: PitchTable, +) -> ChannelStreams: + """Rebuilds a song's four streams from the eight planes they were separated into. + + Args: + planes: The eight planes, two per channel. + pitches: The timer each pitch sounds at. + + Returns: + ChannelStreams: The per-tick register values every channel plays. + """ + timers = pitches.timers + return ChannelStreams( + pulse1=_pulse_registers(planes.pulse1, timers), + pulse2=_pulse_registers(planes.pulse2, timers), + triangle=_triangle_registers(planes.triangle, timers), + noise=_noise_registers(planes.noise), + ) diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py new file mode 100644 index 000000000..584b7ac88 --- /dev/null +++ b/src/sampletones_player/compression/planes/separate.py @@ -0,0 +1,76 @@ +from typing import Dict, Final, Sequence + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.specification.channels import TONE_CHANNELS +from sampletones_player.specification.registers import TIMER_HIGH_SHIFT + +CONTROL_VALUE_INDEX: Final[int] = 0 +FIRST_VALUE_INDEX: Final[int] = 1 +SECOND_VALUE_INDEX: Final[int] = 2 + + +def _tone_planes( + registers: Sequence[ChannelRegisters], + indices: Dict[int, int], +) -> ChannelPlanes: + control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers) + value = bytes( + indices[tick.values[FIRST_VALUE_INDEX] | (tick.values[SECOND_VALUE_INDEX] << TIMER_HIGH_SHIFT)] + for tick in registers + ) + return ChannelPlanes(control=control, value=value) + + +def _noise_planes(registers: Sequence[ChannelRegisters]) -> ChannelPlanes: + control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers) + value = bytes(tick.values[FIRST_VALUE_INDEX] for tick in registers) + return ChannelPlanes(control=control, value=value) + + +def channel_planes( + channel: ChannelName, + registers: Sequence[ChannelRegisters], + pitches: PitchTable, +) -> ChannelPlanes: + """Separates one channel's ticks into the two planes the codec reads. + + Args: + channel: The channel the registers belong to. + registers: The channel's per-tick register values. + pitches: The timer each pitch sounds at. + + Returns: + ChannelPlanes: The channel's control and value planes. + """ + if channel in TONE_CHANNELS: + return _tone_planes(registers, pitches.indices) + + return _noise_planes(registers) + + +def planes_from_streams(streams: ChannelStreams, pitches: PitchTable) -> SongPlanes: + """Separates a song's four streams into the eight planes the codec compresses. + + Every channel is carried to the song's full length first, so the eight planes cover the same + ticks and the decoder advances them together. + + Args: + streams: The per-tick register values every channel plays. + pitches: The timer each pitch sounds at. + + Returns: + SongPlanes: The eight planes, two per channel. + """ + indices = pitches.indices + pulse1, pulse2, triangle, noise = streams.padded + return SongPlanes( + pulse1=_tone_planes(pulse1, indices), + pulse2=_tone_planes(pulse2, indices), + triangle=_tone_planes(triangle, indices), + noise=_noise_planes(noise), + ) diff --git a/src/sampletones_player/compression/planes/song.py b/src/sampletones_player/compression/planes/song.py new file mode 100644 index 000000000..3a62034fc --- /dev/null +++ b/src/sampletones_player/compression/planes/song.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.order import PlaneOrder + + +class SongPlanes(BaseModel): + """Every channel of a song separated into planes, the whole of what the codec compresses. + + Attributes: + pulse1: The first pulse channel's planes. + pulse2: The second pulse channel's planes. + triangle: The triangle channel's planes. + noise: The noise channel's planes. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + pulse1: ChannelPlanes + pulse2: ChannelPlanes + triangle: ChannelPlanes + noise: ChannelPlanes + + @classmethod + def from_order(cls, planes: PlaneOrder) -> SongPlanes: + """Gathers eight planes back into the four channels that write them. + + Args: + planes: The eight planes, in the order the song block writes them. + + Returns: + SongPlanes: The planes under the channel each pair belongs to. + """ + return cls( + pulse1=ChannelPlanes( + control=planes.pulse1_control, + value=planes.pulse1_value, + ), + pulse2=ChannelPlanes( + control=planes.pulse2_control, + value=planes.pulse2_value, + ), + triangle=ChannelPlanes( + control=planes.triangle_control, + value=planes.triangle_value, + ), + noise=ChannelPlanes( + control=planes.noise_control, + value=planes.noise_value, + ), + ) + + @model_validator(mode="after") + def _validate_every_channel_reaches_the_same_tick(self) -> SongPlanes: + lengths = {channels.ticks for channels in self.ordered} + if len(lengths) > 1: + raise ValueError(f"a song's channels cover the same ticks, and these cover {sorted(lengths)}") + + return self + + @property + def ordered(self) -> Tuple[ChannelPlanes, ...]: + """The four channels in the order the generator names run.""" + return (self.pulse1, self.pulse2, self.triangle, self.noise) + + @property + def planes(self) -> PlaneOrder: + """The eight planes in the order the song block writes them.""" + return PlaneOrder( + pulse1_control=self.pulse1.control, + pulse1_value=self.pulse1.value, + pulse2_control=self.pulse2.control, + pulse2_value=self.pulse2.value, + triangle_control=self.triangle.control, + triangle_value=self.triangle.value, + noise_control=self.noise.control, + noise_value=self.noise.value, + ) + + @property + def ticks(self) -> int: + """The ticks the song lasts.""" + return self.pulse1.ticks diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py new file mode 100644 index 000000000..e3055c5bc --- /dev/null +++ b/src/sampletones_player/compression/search.py @@ -0,0 +1,180 @@ +from typing import Dict, Final, FrozenSet, List, NamedTuple, Sequence + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.parse.song import parse_planes +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.sizes import phrase_size +from sampletones_player.specification.compression import MAX_PHRASE_IDS + +MIN_CANDIDATE_LENGTH: Final[int] = 3 +MAX_CANDIDATE_LENGTH: Final[int] = 48 +MAX_CANDIDATE_ENTRIES: Final[int] = 200_000 +MAX_SEARCH_ROUNDS: Final[int] = 64 +CONFIRMED_CANDIDATES: Final[int] = 3 +SHIFTED_OCCURRENCE_TRANSPOSE: Final[int] = 1 + + +class _Span(NamedTuple): + start: int + end: int + + +class _Occurrence(NamedTuple): + plane: int + position: int + + +class _Candidate(NamedTuple): + gain: int + body: bytes + + +def _residue_spans(parse: Parse) -> List[_Span]: + spans: List[_Span] = [] + position = 0 + for token in parse.tokens: + if isinstance(token, LiteralToken): + spans.append(_Span(start=position, end=position + token.ticks)) + + position += token.ticks + + return spans + + +def _candidates( + indices: Sequence[PlaneIndex], + parses: Sequence[Parse], +) -> Dict[bytes, List[_Occurrence]]: + found: Dict[bytes, List[_Occurrence]] = {} + entries = 0 + for plane, (index, parse) in enumerate(zip(indices, parses)): + for span in _residue_spans(parse): + if entries > MAX_CANDIDATE_ENTRIES: + return found + + for position in range(span.start, span.end): + longest = min(MAX_CANDIDATE_LENGTH, span.end - position) + for length in range(MIN_CANDIDATE_LENGTH, longest + 1): + key = index.differences[position : position + length - 1] + found.setdefault(key, []).append( + _Occurrence(plane=plane, position=position), + ) + entries += 1 + + return found + + +def _spread( + occurrences: Sequence[_Occurrence], + length: int, +) -> List[_Occurrence]: + spread: List[_Occurrence] = [] + reached = -1 + covered = -1 + for occurrence in occurrences: + if occurrence.plane != covered or occurrence.position >= reached: + spread.append(occurrence) + covered = occurrence.plane + reached = occurrence.position + length + + return spread + + +def _gain( + occurrences: Sequence[_Occurrence], + length: int, + parses: Sequence[Parse], + phrase_id: int, +) -> int: + parsed = 0 + tokens = 0 + for order, occurrence in enumerate(occurrences): + costs = parses[occurrence.plane].costs + parsed += costs[occurrence.position + length] - costs[occurrence.position] + tokens += phrase_size(phrase_id, 0 if order == 0 else SHIFTED_OCCURRENCE_TRANSPOSE) + + return parsed - tokens + + +def _ranked( + indices: Sequence[PlaneIndex], + parses: Sequence[Parse], + phrase_id: int, +) -> List[_Candidate]: + ranked: List[_Candidate] = [] + for key, occurrences in _candidates(indices, parses).items(): + length = len(key) + 1 + spread = _spread(occurrences, length) + if len(spread) < 2: + continue + + first = spread[0] + body = indices[first.plane].plane[first.position : first.position + length] + gain = _gain(spread, length, parses, phrase_id) - Phrase(body=body).size + if gain > 0: + ranked.append(_Candidate(gain=gain, body=body)) + + ranked.sort(key=lambda candidate: (-candidate.gain, candidate.body)) + return ranked[:CONFIRMED_CANDIDATES] + + +def _total(table: PhraseTable, parses: Sequence[Parse]) -> int: + return table.size + sum(parse.size for parse in parses) + + +def search_phrases( + indices: Sequence[PlaneIndex], + table: PhraseTable, + options: CodecOptions, + boundaries: FrozenSet[int], +) -> PhraseTable: + """Fills the dictionary with the phrases the song's own planes repeat. + + A candidate is scored by what the parse pays for it today against what a token naming it + would pay instead, the entry it takes in the dictionary included, so a run a hold already + covers for one byte scores nothing and the slots go to shapes that repeat at a price. The + best few candidates of each round are confirmed by parsing the whole song again with each + one added, and the round keeps whichever genuinely shrank the song. + + Candidates are counted by their shape rather than their values, so a figure played at five + pitches is one candidate seen five times. + + Args: + indices: The planes and the readings of them matching is decided against. + table: The phrases the instruments seeded. + options: Which of the codec's layers the encoding is built from. + boundaries: The ticks a token starts on. + + Returns: + PhraseTable: The seeded phrases alongside the ones the search earned. + """ + parses = parse_planes(indices, table, options, boundaries) + total = _total(table, parses) + for _ in range(MAX_SEARCH_ROUNDS): + if len(table) == MAX_PHRASE_IDS: + return table + + settled = False + for candidate in _ranked(indices, parses, len(table)): + enlarged = phrase_table(table.phrases + (Phrase(body=candidate.body),)) + trial = parse_planes( + indices, + enlarged, + options, + boundaries, + ) + if _total(enlarged, trial) < total: + table = enlarged + parses = trial + total = _total(enlarged, trial) + settled = True + break + + if not settled: + return table + + return table diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py new file mode 100644 index 000000000..184fed3d0 --- /dev/null +++ b/src/sampletones_player/compression/seeds.py @@ -0,0 +1,46 @@ +from typing import List, Tuple + +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.project.project import Project +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import channel_planes +from sampletones_player.registers.channel import channel_registers +from sampletones_player.specification.compression import MAX_PHRASE_LENGTH +from sampletones_shared.music import Tuning + + +def phrases_from_project( + project: Project, + tuning: Tuning, +) -> Tuple[Phrase, ...]: + """The phrases a project's own instruments offer the dictionary. + + A song is built by playing sample slices at rows, so the shapes its planes repeat are the + slices themselves: each one reaches the dictionary as the two planes it writes, at the pitch + and level it was reconstructed at, and every row playing it names those entries at the shift + the row asks for. + + Args: + project: The project whose samples the song plays. + tuning: Where concert pitch sits, which decides the timer each pitch sounds at. + + Returns: + Tuple[Phrase, ...]: The phrases, in instrument-table order. + """ + timer_table = get_timer_table(tuning) + pitches = PitchTable.from_tuning(tuning) + phrases: List[Phrase] = [] + for sample_slice in iterate_sample_slices(project): + channel = sample_slice.channel + played = {channel: CHANNEL_TO_EXPORTER_MAP[channel].from_features(sample_slice.features)} + planes = channel_planes( + channel, + channel_registers(channel, played, timer_table), + pitches, + ) + phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes.ordered) + + return tuple(phrases) diff --git a/src/sampletones_player/compression/tokens/__init__.py b/src/sampletones_player/compression/tokens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/tokens/hold.py b/src/sampletones_player/compression/tokens/hold.py new file mode 100644 index 000000000..f03ab9a8d --- /dev/null +++ b/src/sampletones_player/compression/tokens/hold.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from sampletones_player.compression.tokens.sizes import hold_size + + +@dataclass(frozen=True) +class HoldToken: + """The plane keeps the value it reached, for ``ticks`` ticks.""" + + ticks: int + + @property + def size(self) -> int: + """The bytes the token takes.""" + return hold_size() diff --git a/src/sampletones_player/compression/tokens/literal.py b/src/sampletones_player/compression/tokens/literal.py new file mode 100644 index 000000000..eb816f328 --- /dev/null +++ b/src/sampletones_player/compression/tokens/literal.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from sampletones_player.compression.tokens.sizes import literal_size + + +@dataclass(frozen=True) +class LiteralToken: + """The plane takes the values verbatim, one per tick.""" + + values: bytes + + @property + def ticks(self) -> int: + """The ticks the token covers.""" + return len(self.values) + + @property + def size(self) -> int: + """The bytes the token takes.""" + return literal_size(len(self.values)) diff --git a/src/sampletones_player/compression/tokens/phrase.py b/src/sampletones_player/compression/tokens/phrase.py new file mode 100644 index 000000000..522606192 --- /dev/null +++ b/src/sampletones_player/compression/tokens/phrase.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + +from sampletones_player.compression.tokens.sizes import phrase_size + + +@dataclass(frozen=True) +class PhraseToken: + """The plane plays a phrase from the table, shifted by ``transpose``, for ``ticks`` ticks. + + A count past the phrase's own length holds its final value onwards, the way a note whose + envelope has finished keeps sounding, and a count short of it cuts the note off. + """ + + phrase_id: int + ticks: int + transpose: int + + @property + def size(self) -> int: + """The bytes the token takes.""" + return phrase_size(self.phrase_id, self.transpose) diff --git a/src/sampletones_player/compression/tokens/sizes.py b/src/sampletones_player/compression/tokens/sizes.py new file mode 100644 index 000000000..80d946333 --- /dev/null +++ b/src/sampletones_player/compression/tokens/sizes.py @@ -0,0 +1,35 @@ +from sampletones_player.specification.compression import ( + OPCODE_SIZE, + PHRASE_COUNT_SIZE, + PHRASE_ESCAPE_SIZE, + PHRASE_ID_ESCAPE, + TRANSPOSE_SIZE, +) + + +def hold_size() -> int: + """The bytes a hold takes, its opcode carrying the count.""" + return OPCODE_SIZE + + +def literal_size(length: int) -> int: + """The bytes a literal of ``length`` values takes, its opcode carrying the length.""" + return OPCODE_SIZE + length + + +def phrase_size(phrase_id: int, transpose: int) -> int: + """The bytes a phrase token takes. + + The opcode carries the phrase's id where the id is one of the cheap ones, and a further byte + names it beyond those. A count byte follows, and a shifted phrase carries the shift as well. + + Args: + phrase_id: Position the phrase takes in the table. + transpose: The shift every byte of the phrase is played at. + + Returns: + int: The bytes the token takes. + """ + escape = PHRASE_ESCAPE_SIZE if phrase_id >= PHRASE_ID_ESCAPE else 0 + shift = TRANSPOSE_SIZE if transpose else 0 + return OPCODE_SIZE + PHRASE_COUNT_SIZE + escape + shift diff --git a/src/sampletones_player/compression/tokens/types.py b/src/sampletones_player/compression/tokens/types.py new file mode 100644 index 000000000..edeb685cb --- /dev/null +++ b/src/sampletones_player/compression/tokens/types.py @@ -0,0 +1,7 @@ +from typing import Union + +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken + +TokenUnion = Union[HoldToken, LiteralToken, PhraseToken] diff --git a/src/sampletones_player/registers/channel.py b/src/sampletones_player/registers/channel.py new file mode 100644 index 000000000..bf62431ff --- /dev/null +++ b/src/sampletones_player/registers/channel.py @@ -0,0 +1,119 @@ +from typing import Dict, List, Literal, Mapping, Sequence, Tuple, Type, overload + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import ( + InstructionT, + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.triangle import TriangleRegisters + + +def channel_instructions( + instructions: Sequence[InstructionUnion], + instruction_type: Type[InstructionT], +) -> List[InstructionT]: + """One channel's stream, read as the instruction type that channel sounds. + + A reconstruction holds a stream for every channel, and a channel standing by holds one + describing no frame. Such a channel reaches the player resting for a single tick, which is + the shortest stream a song lays its records out from. + + Args: + instructions: The channel's stream, as the reconstruction holds it. + instruction_type: The instruction type the channel's encoder reads. + + Returns: + List[InstructionT]: The stream, covering at least one tick. + + Raises: + TypeError: If the stream holds an instruction another channel sounds. + """ + typed: List[InstructionT] = [] + for instruction in instructions: + if not isinstance(instruction, instruction_type): + raise TypeError( + f"a {instruction_type.__name__} stream holds {type(instruction).__name__} " + f"{instruction.name}, which another channel sounds" + ) + + typed.append(instruction) + + if typed: + return typed + + resting: InstructionT = instruction_type.null_instruction() + return [resting] + + +@overload +def channel_registers( + channel: Literal[ChannelName.PULSE1, ChannelName.PULSE2], + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + timer_table: Dict[int, int], +) -> Tuple[PulseRegisters, ...]: ... + + +@overload +def channel_registers( + channel: Literal[ChannelName.TRIANGLE], + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + timer_table: Dict[int, int], +) -> Tuple[TriangleRegisters, ...]: ... + + +@overload +def channel_registers( + channel: Literal[ChannelName.NOISE], + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + timer_table: Dict[int, int], +) -> Tuple[NoiseRegisters, ...]: ... + + +@overload +def channel_registers( + channel: ChannelName, + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + timer_table: Dict[int, int], +) -> Tuple[ChannelRegisters, ...]: ... + + +def channel_registers( + channel: ChannelName, + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + timer_table: Dict[int, int], +) -> Tuple[ChannelRegisters, ...]: + """Encodes one channel's instructions into the register values its ticks write. + + The channel decides all three halves of the answer: which stream of the song it plays, the + instruction type that stream is read as, and the register set those instructions become. + Naming the channel is therefore the whole of what a caller states, and a channel the song + leaves out rests through it. + + Args: + channel: The channel to encode. + instructions: The per-tick instructions each channel carries. + timer_table: The timer register value each pitch sounds at. + + Returns: + Tuple[ChannelRegisters, ...]: One register set per tick, covering at least one tick. + + Raises: + TypeError: If the channel's stream holds an instruction another channel sounds. + """ + played = instructions.get(channel, ()) + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: + pulse = channel_instructions(played, PulseInstruction) + return tuple(PulseRegisters.from_instructions(pulse, timer_table)) + case ChannelName.TRIANGLE: + triangle = channel_instructions(played, TriangleInstruction) + return tuple(TriangleRegisters.from_instructions(triangle, timer_table)) + case ChannelName.NOISE: + noise = channel_instructions(played, NoiseInstruction) + return tuple(NoiseRegisters.from_instructions(noise)) diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py index 8c7c00cbd..bcbeaa53d 100644 --- a/src/sampletones_player/specification/channels.py +++ b/src/sampletones_player/specification/channels.py @@ -1,4 +1,4 @@ -from typing import Dict, Final, Tuple +from typing import Dict, Final, FrozenSet, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_player.specification.registers import ( @@ -21,3 +21,11 @@ ChannelName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH), ChannelName.NOISE: (NOISE_CONTROL, NOISE_PERIOD), } + +TONE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( + { + ChannelName.PULSE1, + ChannelName.PULSE2, + ChannelName.TRIANGLE, + } +) diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py new file mode 100644 index 000000000..9a29fdc99 --- /dev/null +++ b/src/sampletones_player/specification/compression.py @@ -0,0 +1,52 @@ +from enum import IntEnum +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.binary import WORD_SIZE + + +class TokenTag(IntEnum): + """What a token's opcode byte says its top two bits. + + Attributes: + HOLD: The plane keeps the value it reached, for the ticks the operand counts. + LITERAL: The plane takes the bytes that follow, one per tick. + PHRASE: The plane plays a phrase from the table at the pitch it was stored at. + TRANSPOSED_PHRASE: The plane plays a phrase shifted by the signed byte that follows. + """ + + HOLD = 0x00 + LITERAL = 0x40 + PHRASE = 0x80 + TRANSPOSED_PHRASE = 0xC0 + + +TOKEN_TAG_MASK: Final[int] = 0xC0 +TOKEN_OPERAND_MASK: Final[int] = 0x3F + +OPCODE_SIZE: Final[int] = 1 +PHRASE_COUNT_SIZE: Final[int] = 1 +PHRASE_ESCAPE_SIZE: Final[int] = 1 +TRANSPOSE_SIZE: Final[int] = 1 + +MAX_HOLD_TICKS: Final[int] = TOKEN_OPERAND_MASK + 1 +MAX_LITERAL_BYTES: Final[int] = TOKEN_OPERAND_MASK + 1 +MAX_PHRASE_TICKS: Final[int] = 256 + +PHRASE_ID_ESCAPE: Final[int] = TOKEN_OPERAND_MASK +CHEAP_PHRASE_IDS: Final[int] = PHRASE_ID_ESCAPE +MAX_PHRASE_IDS: Final[int] = 256 +MAX_PHRASE_LENGTH: Final[int] = 255 + +PHRASE_TABLE_COUNT_SIZE: Final[int] = 1 +PHRASE_TABLE_ENTRY_SIZE: Final[int] = WORD_SIZE +PHRASE_LENGTH_SIZE: Final[int] = 1 + +BYTE_VALUES: Final[int] = 256 +MAX_BYTE_VALUE: Final[int] = BYTE_VALUES - 1 + +INITIAL_PLANE_VALUE: Final[int] = 0 + +PLANES_PER_CHANNEL: Final[int] = 2 +PLANE_COUNT: Final[int] = len(ChannelName.items()) * PLANES_PER_CHANNEL +PLANE_STATE_SIZE: Final[int] = 8 diff --git a/tests/integration/nsf/report.py b/tests/integration/nsf/report.py new file mode 100644 index 000000000..c689926fe --- /dev/null +++ b/tests/integration/nsf/report.py @@ -0,0 +1,115 @@ +import csv +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Sequence, Tuple + +COLUMNS: Final[Tuple[str, ...]] = ( + "corpus", + "variant", + "ticks", + "bytes", + "bytes per tick", + "ratio", + "ticks that fit", + "phrases", + "dictionary", + "seconds", +) + + +@dataclass(frozen=True) +class ReportRow: + """One corpus song measured under one variant of the codec. + + Attributes: + corpus: The song measured. + variant: The layers the encoding was built from. + ticks: The ticks the song lasts. + size: The bytes the song's data takes. + variable: The part of ``size`` that grows with the song, the fixed tables aside. + phrases: The phrases the dictionary holds. + dictionary: The bytes the dictionary takes, counted within ``size``. + seconds: How long the encoding took. + records: The bytes the same song takes as one record per tick per channel. + space: The program area a song is written into. + """ + + corpus: str + variant: str + ticks: int + size: int + variable: int + phrases: int + dictionary: int + seconds: float + records: int + space: int + + @property + def bytes_per_tick(self) -> float: + """The bytes each tick of the song costs.""" + return self.size / self.ticks + + @property + def ratio(self) -> float: + """How many times smaller the song is than one record per tick per channel.""" + return self.records / self.size + + @property + def fitting_ticks(self) -> int: + """The ticks a song at this rate reaches before it fills the program area. + + The pitch table and the dictionary are paid once however long the song runs, so what the + remaining space is measured against is the part that grows with the ticks. + """ + return int((self.space - (self.size - self.variable)) * self.ticks // self.variable) + + @property + def cells(self) -> Tuple[str, ...]: + """The row as the report prints it, column by column.""" + return ( + self.corpus, + self.variant, + f"{self.ticks}", + f"{self.size}", + f"{self.bytes_per_tick:.3f}", + f"{self.ratio:.2f}", + f"{self.fitting_ticks}", + f"{self.phrases}", + f"{self.dictionary}", + f"{self.seconds:.2f}", + ) + + +def write_csv(rows: Sequence[ReportRow], path: Path) -> None: + """Writes the measurements as a table another tool reads. + + Args: + rows: The measurements, in the order they are reported. + path: Where the table is written. + """ + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(COLUMNS) + for row in rows: + writer.writerow(row.cells) + + +def write_markdown(rows: Sequence[ReportRow], path: Path, state: int) -> None: + """Writes the measurements as a table a reader reads. + + Args: + rows: The measurements, in the order they are reported. + path: Where the table is written. + state: The zero-page bytes the decoder's plane state takes. + """ + lines = [ + "# Compression report", + "", + f"Decoder state: {state} bytes of zero page.", + "", + "| " + " | ".join(COLUMNS) + " |", + "|" + "|".join("---" for _ in COLUMNS) + "|", + ] + lines.extend("| " + " | ".join(row.cells) + " |" for row in rows) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/tests/integration/nsf/songs.py b/tests/integration/nsf/songs.py new file mode 100644 index 000000000..fb1a7c121 --- /dev/null +++ b/tests/integration/nsf/songs.py @@ -0,0 +1,32 @@ +from typing import Final + +from sampletones_core.project.project import Project +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.nsf import PROGRAM_SIZE + +RECORD_BYTES_PER_TICK: Final[int] = 11 + + +def available_bytes(driver_image: DriverImage) -> int: + """The program area the song block is written into, behind the driver.""" + return PROGRAM_SIZE - len(driver_image.code) + + +def lengthened(project: Project, frames: int) -> Project: + """``project`` with its order repeated to ``frames`` positions, over the same samples. + + The song is copied rather than edited so the session's own project keeps the arrangement + every other case reads. + """ + longer = Project.create( + rows_per_pattern=project.song.rows_per_pattern, + settings=project.settings, + ) + for sample in project.samples: + longer.samples.append(sample) + + longer.song = project.song.model_copy(deep=True) + while longer.song.order_length() < frames: + longer.song.duplicate_frame(longer.song.order_length() - 1) + + return longer diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py new file mode 100644 index 000000000..e5aea3c4f --- /dev/null +++ b/tests/integration/nsf/test_compression_report.py @@ -0,0 +1,387 @@ +from dataclasses import dataclass +from math import ceil +from pathlib import Path +from time import process_time +from typing import Dict, Final, List, Optional, Sequence, Tuple + +import pytest + +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SongTiming +from sampletones_player.builder import song_from_project, song_from_reconstruction +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.encode import STREAM_START, encode_planes +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.matcher import PhraseMatcher +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.plane import parse_plane +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.rebuild import streams_from_planes +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.driver.image import DriverImage +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.song import Song +from sampletones_player.specification.compression import ( + MAX_LITERAL_BYTES, + PLANE_COUNT, + PLANE_STATE_SIZE, +) +from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_shared.music import Tuning +from tests.integration.nsf.report import ReportRow, write_csv, write_markdown +from tests.integration.nsf.songs import ( + RECORD_BYTES_PER_TICK, + available_bytes, + lengthened, +) +from tests.integration.output import resolve_output_directory, resolve_output_path +from tests.integration.paths import COMPRESSION_OUTPUT_ENV + +LITERALS: Final[str] = "literals" +HOLDS: Final[str] = "holds" +SEARCH: Final[str] = "search" +RECORDS: Final[str] = "records" +REGISTER_PLANES: Final[str] = "register planes" +SPLIT_CONTROL: Final[str] = "split control" +CONTROL_LEVEL_MASK: Final[int] = 0x3F + +PLANE_VARIANTS: Final[Tuple[Tuple[str, CodecOptions], ...]] = ( + (LITERALS, CodecOptions(holds=False, phrases=False, transposition=False, search=False)), + (HOLDS, CodecOptions(holds=True, phrases=False, transposition=False, search=False)), + ("instruments", CodecOptions(holds=True, phrases=True, transposition=False, search=False)), + ("transposition", CodecOptions(holds=True, phrases=True, transposition=True, search=False)), + (SEARCH, CodecOptions(holds=True, phrases=True, transposition=True, search=True)), +) + +ARRANGEMENT: Final[str] = "arrangement" +LONG_ARRANGEMENT: Final[str] = "arrangement, three minutes" +TARGET_SECONDS: Final[int] = 180 +MAX_ENCODER_SECONDS: Final[float] = 120.0 +CSV_FILENAME: Final[str] = "report.csv" +MARKDOWN_FILENAME: Final[str] = "report.md" + + +@dataclass(frozen=True) +class CorpusEntry: + """One song the report measures, alongside the phrases its own instruments offer.""" + + name: str + song: Song + seeds: Tuple[Phrase, ...] + tuning: Tuning + + @property + def pitches(self) -> PitchTable: + """The timer each pitch of the song sounds at.""" + return PitchTable.from_tuning(self.tuning) + + @property + def planes(self) -> SongPlanes: + """The eight planes the song separates into.""" + return planes_from_streams(self.song.streams, self.pitches) + + @property + def records(self) -> int: + """The bytes the song takes as one record per tick per channel.""" + return RECORD_BYTES_PER_TICK * self.song.ticks + + +@dataclass(frozen=True) +class Encoding: + """One corpus song compressed under one variant of the codec.""" + + entry: CorpusEntry + variant: str + planes: SongPlanes + compressed: CompressedPlanes + seconds: float + + @property + def size(self) -> int: + """The bytes the dictionary, the streams and the pitch table take together.""" + return self.compressed.size + len(self.entry.pitches.data) + + @property + def streams(self) -> int: + """The bytes the token streams take, which is the part that grows with the song.""" + return sum(len(stream) for stream in self.compressed.streams) + + +def _sample_project(sample: Sample, settings: ProjectSettings) -> Project: + project = Project.create(settings=settings) + project.samples.append(sample) + return project + + +def _register_planes(streams: ChannelStreams) -> Tuple[bytes, ...]: + return tuple( + bytes(tick.values[register] for tick in stream) + for stream in streams.padded + for register in range(len(stream[0].values)) + ) + + +def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: + split: List[bytes] = [] + for channels in planes.ordered: + if channels in (planes.pulse1, planes.pulse2): + split.append(bytes(control >> DUTY_CYCLE_SHIFT for control in channels.control)) + split.append(bytes(control & CONTROL_LEVEL_MASK for control in channels.control)) + else: + split.append(channels.control) + + split.append(channels.value) + + return tuple(split) + + +def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int: + matcher = PhraseMatcher(phrase_table(())) + entries = frozenset({STREAM_START}) + return sum(parse_plane(PlaneIndex.from_plane(plane), matcher, options, entries).size for plane in planes) + + +@pytest.fixture(scope="module") +def corpus( + instrument_catalog: Dict[str, Sample], + integration_project: Project, +) -> Tuple[CorpusEntry, ...]: + """The songs the report measures: each sample alone, and the arrangement at two lengths.""" + tuning = Tuning() + entries: List[CorpusEntry] = [] + for name, sample in instrument_catalog.items(): + reconstruction = sample.reconstruction + entries.append( + CorpusEntry( + name=name, + song=song_from_reconstruction(reconstruction, loop_tick=None), + seeds=phrases_from_project( + _sample_project(sample, integration_project.settings), + reconstruction.config.library.tuning, + ), + tuning=reconstruction.config.library.tuning, + ) + ) + + groove = SongTiming.from_project(integration_project).groove() + frames = ceil(TARGET_SECONDS * integration_project.settings.nes_frequency / groove.total_ticks) + for name, project in ( + (ARRANGEMENT, integration_project), + (LONG_ARRANGEMENT, lengthened(integration_project, frames)), + ): + entries.append( + CorpusEntry( + name=name, + song=song_from_project(project, tuning, loop_tick=None), + seeds=phrases_from_project(project, tuning), + tuning=tuning, + ) + ) + + return tuple(entries) + + +@pytest.fixture(scope="module") +def encodings(corpus: Tuple[CorpusEntry, ...]) -> Tuple[Encoding, ...]: + """Every corpus song compressed under every variant of the codec.""" + encoded: List[Encoding] = [] + for entry in corpus: + planes = entry.planes + for variant, options in PLANE_VARIANTS: + started = process_time() + compressed = encode_planes(planes, entry.seeds, options, frozenset()) + encoded.append( + Encoding( + entry=entry, + variant=variant, + planes=planes, + compressed=compressed, + seconds=process_time() - started, + ) + ) + + return tuple(encoded) + + +def _measured_row( + entry: CorpusEntry, + variant: str, + planes: Sequence[bytes], + fixed: int, + space: int, +) -> ReportRow: + _, holds = PLANE_VARIANTS[1] + started = process_time() + coded = _coded_size(planes, holds) + return ReportRow( + corpus=entry.name, + variant=variant, + ticks=entry.song.ticks, + size=fixed + coded, + variable=coded, + phrases=0, + dictionary=0, + seconds=process_time() - started, + records=entry.records, + space=space, + ) + + +def _baseline_rows(entry: CorpusEntry, space: int) -> Tuple[ReportRow, ...]: + return ( + ReportRow( + corpus=entry.name, + variant=RECORDS, + ticks=entry.song.ticks, + size=entry.records, + variable=entry.records, + phrases=0, + dictionary=0, + seconds=0.0, + records=entry.records, + space=space, + ), + _measured_row(entry, REGISTER_PLANES, _register_planes(entry.song.streams), 0, space), + _measured_row( + entry, + SPLIT_CONTROL, + _split_control_planes(entry.planes), + len(entry.pitches.data), + space, + ), + ) + + +def _rows( + corpus: Sequence[CorpusEntry], + encodings: Sequence[Encoding], + space: int, +) -> Tuple[ReportRow, ...]: + rows: List[ReportRow] = [] + for entry in corpus: + rows.extend(_baseline_rows(entry, space)) + for encoding in encodings: + if encoding.entry is not entry: + continue + + rows.append( + ReportRow( + corpus=entry.name, + variant=encoding.variant, + ticks=entry.song.ticks, + size=encoding.size, + variable=encoding.streams, + phrases=len(encoding.compressed.phrases), + dictionary=encoding.compressed.phrases.size, + seconds=encoding.seconds, + records=entry.records, + space=space, + ) + ) + + return tuple(rows) + + +class TestTheCodecAnswersWithTheSongItWasGiven: + """What the encoder writes, the decoder plays back, whichever layers are switched on.""" + + def test_every_encoding_plays_back_as_the_planes_it_was_written_from( + self, + encodings: Tuple[Encoding, ...], + ) -> None: + for encoding in encodings: + assert decode_planes(encoding.compressed) == encoding.planes + + def test_every_encoding_reaches_the_registers_the_song_writes( + self, + encodings: Tuple[Encoding, ...], + ) -> None: + """The planes are a reading of the streams, so playing them back writes the same registers.""" + for encoding in encodings: + played = streams_from_planes(decode_planes(encoding.compressed), encoding.entry.pitches) + written = encoding.entry.song.streams + assert [played.at(tick) for tick in range(written.ticks)] == [ + written.at(tick) for tick in range(written.ticks) + ] + + def test_a_plane_the_codec_finds_nothing_in_stays_within_its_literal_bound( + self, + encodings: Tuple[Encoding, ...], + ) -> None: + """With every layer switched off a plane costs its own bytes and one opcode per run of them.""" + for encoding in encodings: + if encoding.variant != LITERALS: + continue + + bound = encoding.compressed.ticks + ceil(encoding.compressed.ticks / MAX_LITERAL_BYTES) + for stream in encoding.compressed.streams: + assert len(stream) <= bound + + +class TestTheProgramAreaHoldsAWholeSong: + """What the compression is for: an arrangement of minutes rather than seconds.""" + + def test_a_three_minute_arrangement_fits_the_program_area( + self, + encodings: Tuple[Encoding, ...], + driver_image: DriverImage, + ) -> None: + searched = [ + encoding for encoding in encodings if encoding.entry.name == LONG_ARRANGEMENT and encoding.variant == SEARCH + ] + assert searched + assert SONG_HEADER_SIZE + searched[0].size <= available_bytes(driver_image) + + def test_every_variant_of_every_song_undercuts_a_record_per_tick( + self, + encodings: Tuple[Encoding, ...], + ) -> None: + """The pitch table is paid once, so what a song's own data is held against is the records.""" + for encoding in encodings: + assert encoding.compressed.size < encoding.entry.records + + def test_the_encoder_answers_within_the_budget_an_export_allows( + self, + encodings: Tuple[Encoding, ...], + ) -> None: + """The gate measures this under coverage, which multiplies the encoder's own cost tenfold. + + The budget is set against that reading, so it catches an encoder an export would wait on + rather than the ordinary drift of a machine under load. + """ + for encoding in encodings: + assert encoding.seconds < MAX_ENCODER_SECONDS + + +class TestTheReportStatesWhatEachLayerSaves: + """The measurements the format's constants are settled from.""" + + def test_the_report_is_written( + self, + corpus: Tuple[CorpusEntry, ...], + encodings: Tuple[Encoding, ...], + driver_image: DriverImage, + compression_output_dir: Optional[Path], + tmp_path: Path, + ) -> None: + rows = _rows(corpus, encodings, available_bytes(driver_image)) + csv_path = resolve_output_path(compression_output_dir, tmp_path, CSV_FILENAME) + markdown_path = resolve_output_path(compression_output_dir, tmp_path, MARKDOWN_FILENAME) + write_csv(rows, csv_path) + write_markdown(rows, markdown_path, PLANE_COUNT * PLANE_STATE_SIZE) + assert csv_path.read_text(encoding="utf-8").count("\n") == len(rows) + 1 + assert markdown_path.exists() + + +@pytest.fixture(scope="session") +def compression_output_dir() -> Optional[Path]: + """The persistent output directory ``SAMPLETONES_COMPRESSION_OUTPUT_DIR`` names.""" + return resolve_output_directory(COMPRESSION_OUTPUT_ENV) diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py index 6025d4a3d..8c0fe139b 100644 --- a/tests/integration/nsf/test_song_export.py +++ b/tests/integration/nsf/test_song_export.py @@ -1,5 +1,3 @@ -from typing import Final - import pytest from sampletones_core.project.project import Project @@ -8,37 +6,14 @@ from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song -from sampletones_player.specification.nsf import PROGRAM_SIZE from sampletones_player.specification.song import SONG_HEADER_SIZE from sampletones_shared.exceptions import SongTooLargeError from sampletones_shared.music import Tuning - -RECORD_BYTES_PER_TICK: Final[int] = 11 - - -def available_bytes(driver_image: DriverImage) -> int: - """The program area the song block is written into, behind the driver.""" - return PROGRAM_SIZE - len(driver_image.code) - - -def lengthened(project: Project, frames: int) -> Project: - """``project`` with its order repeated to ``frames`` positions, over the same samples. - - The song is copied rather than edited so the session's own project keeps the arrangement - every other case reads. - """ - longer = Project.create( - rows_per_pattern=project.song.rows_per_pattern, - settings=project.settings, - ) - for sample in project.samples: - longer.samples.append(sample) - - longer.song = project.song.model_copy(deep=True) - while longer.song.order_length() < frames: - longer.song.duplicate_frame(longer.song.order_length() - 1) - - return longer +from tests.integration.nsf.songs import ( + RECORD_BYTES_PER_TICK, + available_bytes, + lengthened, +) @pytest.fixture diff --git a/tests/integration/paths.py b/tests/integration/paths.py index 0b015e24d..c27997891 100644 --- a/tests/integration/paths.py +++ b/tests/integration/paths.py @@ -27,3 +27,4 @@ def _repo_root() -> Path: BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR" NSF_OUTPUT_ENV: Final[str] = "SAMPLETONES_NSF_OUTPUT_DIR" +COMPRESSION_OUTPUT_ENV: Final[str] = "SAMPLETONES_COMPRESSION_OUTPUT_DIR" diff --git a/tests/unit/sampletones_player/compression/__init__.py b/tests/unit/sampletones_player/compression/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/dictionary/__init__.py b/tests/unit/sampletones_player/compression/dictionary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/dictionary/phrases.py b/tests/unit/sampletones_player/compression/dictionary/phrases.py new file mode 100644 index 000000000..4142bec8c --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/phrases.py @@ -0,0 +1,13 @@ +from typing import Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase + +BODY_STRIDE: int = 0x100 + + +def phrase(*values: int) -> Phrase: + return Phrase(body=bytes(values)) + + +def distinct(count: int) -> Tuple[Phrase, ...]: + return tuple(phrase(value % BODY_STRIDE, value // BODY_STRIDE) for value in range(count)) diff --git a/tests/unit/sampletones_player/compression/dictionary/test_phrase.py b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py new file mode 100644 index 000000000..fc945a7ac --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py @@ -0,0 +1,34 @@ +import pytest +from pydantic import ValidationError + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.specification.compression import ( + MAX_PHRASE_LENGTH, + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_ENTRY_SIZE, +) +from tests.unit.sampletones_player.compression.dictionary.phrases import phrase + + +class TestAPhraseIsAShapeRatherThanValues: + """What identifies a phrase is the step from each value to the next, which a shift leaves alone.""" + + def test_the_steps_read_the_body_pairwise(self) -> None: + assert phrase(10, 12, 9).differences == bytes((2, 253)) + + def test_a_shifted_phrase_keeps_the_steps_of_the_one_it_was_stored_from(self) -> None: + assert phrase(70, 72, 69).differences == phrase(10, 12, 9).differences + + def test_a_phrase_covers_the_ticks_its_body_states(self) -> None: + assert phrase(1, 2, 3).length == 3 + + def test_a_phrase_costs_its_entry_its_length_and_its_body(self) -> None: + assert phrase(1, 2, 3).size == PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + 3 + + def test_a_body_reaching_past_a_length_byte_is_refused(self) -> None: + with pytest.raises(ValidationError): + Phrase(body=bytes(MAX_PHRASE_LENGTH + 1)) + + def test_a_phrase_covering_no_tick_is_refused(self) -> None: + with pytest.raises(ValidationError): + Phrase(body=b"") diff --git a/tests/unit/sampletones_player/compression/dictionary/test_prune.py b/tests/unit/sampletones_player/compression/dictionary/test_prune.py new file mode 100644 index 000000000..479ce6acd --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/test_prune.py @@ -0,0 +1,21 @@ +from sampletones_player.compression.dictionary.prune import prune +from sampletones_player.compression.dictionary.table import phrase_table +from tests.unit.sampletones_player.compression.dictionary.phrases import phrase + + +class TestPruningKeepsWhatPaysForItself: + """A phrase earns its place by sparing more bytes than its own entry takes.""" + + def test_a_phrase_no_token_names_is_dropped(self) -> None: + table = phrase_table((phrase(1, 2), phrase(3, 4))) + pruned = prune(table, {0: 2, 1: 0}, {0: 100, 1: 100}) + assert pruned.phrases == (phrase(1, 2),) + + def test_a_phrase_sparing_less_than_its_entry_is_dropped(self) -> None: + table = phrase_table((phrase(1, 2),)) + assert prune(table, {0: 1}, {0: phrase(1, 2).size}).phrases == () + + def test_the_phrases_named_most_take_the_cheap_ids(self) -> None: + table = phrase_table((phrase(1, 2), phrase(3, 4))) + pruned = prune(table, {0: 1, 1: 9}, {0: 100, 1: 100}) + assert pruned.phrases == (phrase(3, 4), phrase(1, 2)) diff --git a/tests/unit/sampletones_player/compression/dictionary/test_table.py b/tests/unit/sampletones_player/compression/dictionary/test_table.py new file mode 100644 index 000000000..ef6727a1a --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/test_table.py @@ -0,0 +1,32 @@ +import pytest +from pydantic import ValidationError + +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.specification.compression import ( + MAX_PHRASE_IDS, + PHRASE_TABLE_COUNT_SIZE, +) +from tests.unit.sampletones_player.compression.dictionary.phrases import distinct, phrase + + +class TestTheTableHoldsWhatATokenCanName: + """A phrase's position is its id, and the cheap ids ride inside an opcode.""" + + def test_a_shape_offered_twice_is_held_once(self) -> None: + table = phrase_table((phrase(1, 2), phrase(1, 2), phrase(3, 4))) + assert table.phrases == (phrase(1, 2), phrase(3, 4)) + + def test_a_phrase_is_reached_by_the_id_its_position_gives_it(self) -> None: + table = phrase_table((phrase(1, 2), phrase(3, 4))) + assert table[1] == phrase(3, 4) + + def test_the_table_stops_where_a_token_stops_naming(self) -> None: + assert len(phrase_table(distinct(MAX_PHRASE_IDS + 10))) == MAX_PHRASE_IDS + + def test_a_table_beyond_the_ids_a_token_reaches_is_refused(self) -> None: + with pytest.raises(ValidationError): + PhraseTable(phrases=tuple(distinct(MAX_PHRASE_IDS + 1))) + + def test_the_dictionary_costs_its_count_and_its_phrases(self) -> None: + table = phrase_table((phrase(1, 2), phrase(3, 4, 5))) + assert table.size == PHRASE_TABLE_COUNT_SIZE + phrase(1, 2).size + phrase(3, 4, 5).size diff --git a/tests/unit/sampletones_player/compression/matches/__init__.py b/tests/unit/sampletones_player/compression/matches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/matches/test_index.py b/tests/unit/sampletones_player/compression/matches/test_index.py new file mode 100644 index 000000000..29e8df41b --- /dev/null +++ b/tests/unit/sampletones_player/compression/matches/test_index.py @@ -0,0 +1,16 @@ +from sampletones_player.compression.matches.index import PlaneIndex + + +class TestAPlaneIsReadAsStepsAndRuns: + """Matching is decided against the steps between values and how far each value carries.""" + + def test_the_steps_read_the_plane_pairwise(self) -> None: + assert PlaneIndex.from_plane(bytes((3, 5, 2))).differences == bytes((2, 253)) + + def test_a_run_counts_the_ticks_its_value_holds_for(self) -> None: + assert PlaneIndex.from_plane(bytes((7, 7, 7, 9))).runs == (3, 2, 1, 1) + + def test_a_single_tick_carries_no_steps(self) -> None: + index = PlaneIndex.from_plane(bytes((5,))) + assert index.differences == b"" + assert index.ticks == 1 diff --git a/tests/unit/sampletones_player/compression/matches/test_matcher.py b/tests/unit/sampletones_player/compression/matches/test_matcher.py new file mode 100644 index 000000000..ed5f6c428 --- /dev/null +++ b/tests/unit/sampletones_player/compression/matches/test_matcher.py @@ -0,0 +1,60 @@ +from typing import Final, List, Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.match import PhraseMatch +from sampletones_player.compression.matches.matcher import KEY_LENGTH, PhraseMatcher +from sampletones_player.specification.compression import MAX_PHRASE_TICKS + +MOTIF: Final[bytes] = bytes((40, 44, 47)) + + +def found(plane: bytes, phrases: Tuple[Phrase, ...], position: int) -> List[PhraseMatch]: + index = PlaneIndex.from_plane(plane) + matcher = PhraseMatcher(phrase_table(phrases)) + return list( + matcher.matches( + index, + position, + min(MAX_PHRASE_TICKS, len(plane) - position), + transposition=True, + ) + ) + + +class TestWhichPhrasesAPlanePlays: + """One dictionary entry serves every pitch and every length a figure is played at.""" + + def test_a_phrase_matches_where_the_plane_plays_it(self) -> None: + assert found(MOTIF, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=0)] + + def test_a_phrase_matches_the_same_figure_played_higher(self) -> None: + higher = bytes(value + 5 for value in MOTIF) + assert found(higher, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=5)] + + def test_a_phrase_matches_the_same_figure_played_lower(self) -> None: + """A shift is added to a byte, so a fall reaches the token as the byte that wraps to it.""" + lower = bytes(value - 3 for value in MOTIF) + assert found(lower, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=253)] + + def test_a_note_outlasting_its_phrase_holds_the_phrase_out(self) -> None: + plane = MOTIF + bytes((MOTIF[-1],)) * 4 + assert found(plane, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(plane), transpose=0)] + + def test_a_note_cut_short_plays_as_much_of_the_phrase_as_sounded(self) -> None: + plane = MOTIF[:3] + bytes((99,)) + assert found(plane, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=3, transpose=0)] + + def test_a_note_cut_before_its_shape_is_told_apart_is_offered_nowhere(self) -> None: + """A phrase is shortlisted by its first steps, so a shorter start names no phrase.""" + plane = MOTIF[:KEY_LENGTH] + bytes((99,)) + assert found(plane, (Phrase(body=MOTIF),), 0) == [] + + def test_a_figure_the_plane_never_plays_is_offered_nowhere(self) -> None: + assert found(bytes((1, 9, 1)), (Phrase(body=MOTIF),), 0) == [] + + def test_a_phrase_is_offered_wherever_the_plane_plays_it(self) -> None: + plane = MOTIF + bytes((0,)) + MOTIF + entered = found(plane, (Phrase(body=MOTIF),), len(MOTIF) + 1) + assert entered == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=0)] diff --git a/tests/unit/sampletones_player/compression/matches/test_shift.py b/tests/unit/sampletones_player/compression/matches/test_shift.py new file mode 100644 index 000000000..624b17350 --- /dev/null +++ b/tests/unit/sampletones_player/compression/matches/test_shift.py @@ -0,0 +1,17 @@ +from sampletones_player.compression.matches.shift import translation +from sampletones_player.specification.compression import BYTE_VALUES + +MOTIF: bytes = bytes((40, 44, 47)) + + +class TestAShiftMovesEveryValueOfAPhrase: + """The driver adds the shift to a byte, so the encoder agrees with it byte for byte.""" + + def test_a_rise_moves_every_value_up(self) -> None: + assert MOTIF.translate(translation(5)) == bytes(value + 5 for value in MOTIF) + + def test_a_fall_reaches_a_phrase_as_the_byte_that_wraps_to_it(self) -> None: + assert MOTIF.translate(translation(BYTE_VALUES - 3)) == bytes(value - 3 for value in MOTIF) + + def test_the_shift_a_phrase_is_stored_at_leaves_it_alone(self) -> None: + assert MOTIF.translate(translation(0)) == MOTIF diff --git a/tests/unit/sampletones_player/compression/parse/__init__.py b/tests/unit/sampletones_player/compression/parse/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/parse/test_boundaries.py b/tests/unit/sampletones_player/compression/parse/test_boundaries.py new file mode 100644 index 000000000..d9b7dc1f3 --- /dev/null +++ b/tests/unit/sampletones_player/compression/parse/test_boundaries.py @@ -0,0 +1,29 @@ +from typing import FrozenSet + +from sampletones_player.compression.parse.boundaries import Boundaries + +TICKS: int = 10 +ENTRIES: FrozenSet[int] = frozenset({0, 4}) + + +class TestWhereATokenMayStartAndHowFarItReaches: + """A loop re-enters the stream partway through, so nothing spans the tick it re-enters at.""" + + def test_the_entries_are_the_ticks_a_token_starts_on(self) -> None: + assert Boundaries.across(TICKS, ENTRIES).entries == ENTRIES + + def test_a_tick_looks_back_to_the_boundary_behind_it(self) -> None: + boundaries = Boundaries.across(TICKS, ENTRIES) + assert boundaries.previous[6] == 4 + + def test_a_tick_before_any_boundary_looks_back_to_the_start(self) -> None: + boundaries = Boundaries.across(TICKS, ENTRIES) + assert boundaries.previous[3] == 0 + + def test_a_tick_looks_forward_to_the_boundary_ahead_of_it(self) -> None: + boundaries = Boundaries.across(TICKS, ENTRIES) + assert boundaries.following[0] == 4 + + def test_a_tick_past_the_last_boundary_looks_forward_to_the_end_of_the_plane(self) -> None: + boundaries = Boundaries.across(TICKS, ENTRIES) + assert boundaries.following[4] == TICKS diff --git a/tests/unit/sampletones_player/compression/parse/test_plane.py b/tests/unit/sampletones_player/compression/parse/test_plane.py new file mode 100644 index 000000000..be9866932 --- /dev/null +++ b/tests/unit/sampletones_player/compression/parse/test_plane.py @@ -0,0 +1,116 @@ +from typing import Final, FrozenSet, Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.matcher import PhraseMatcher +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.plane import parse_plane +from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken +from sampletones_player.compression.tokens.types import TokenUnion +from sampletones_player.specification.compression import MAX_HOLD_TICKS, MAX_LITERAL_BYTES + +EVERY_LAYER: Final[CodecOptions] = CodecOptions( + holds=True, + phrases=True, + transposition=True, + search=False, +) +LITERALS_ONLY: Final[CodecOptions] = CodecOptions( + holds=False, + phrases=False, + transposition=False, + search=False, +) +START: Final[FrozenSet[int]] = frozenset({0}) +MOTIF: Final[bytes] = bytes((40, 44, 47, 44)) + + +def parsed( + plane: bytes, + phrases: Tuple[Phrase, ...] = (), + options: CodecOptions = EVERY_LAYER, + boundaries: FrozenSet[int] = START, +) -> Parse: + return parse_plane( + PlaneIndex.from_plane(plane), + PhraseMatcher(phrase_table(phrases)), + options, + boundaries, + ) + + +def starts(parse: Parse) -> Tuple[int, ...]: + positions = [] + position = 0 + for token in parse.tokens: + positions.append(position) + position += token.ticks + + return tuple(positions) + + +class TestTheParseCoversThePlaneCheaply: + """Every way of covering a tick is an edge, and the encoding is the cheapest path across them.""" + + def test_the_tokens_cover_every_tick_of_the_plane(self) -> None: + plane = bytes((1, 1, 1, 2, 3, 3)) + parse = parsed(plane) + assert sum(token.ticks for token in parse.tokens) == len(plane) + + def test_the_cost_of_the_whole_plane_is_the_cost_of_its_tokens(self) -> None: + parse = parsed(bytes((1, 1, 2, 2, 2, 9))) + assert parse.size == sum(token.size for token in parse.tokens) + + def test_a_run_reaches_the_stream_as_a_hold(self) -> None: + parse = parsed(bytes((7,)) * 40) + assert parse.tokens == (LiteralToken(values=bytes((7,))), HoldToken(ticks=39)) + + def test_a_run_longer_than_one_hold_reaches_the_stream_as_several(self) -> None: + parse = parsed(bytes((7,)) * (2 * MAX_HOLD_TICKS + 1)) + assert parse.tokens[1:] == (HoldToken(ticks=MAX_HOLD_TICKS),) * 2 + + def test_a_plane_the_codec_finds_nothing_in_spells_itself_out(self) -> None: + plane = bytes(range(MAX_LITERAL_BYTES + 4)) + parse = parsed(plane, options=LITERALS_ONLY) + assert parse.tokens == ( + LiteralToken(values=plane[:MAX_LITERAL_BYTES]), + LiteralToken(values=plane[MAX_LITERAL_BYTES:]), + ) + + def test_a_figure_the_dictionary_holds_reaches_the_stream_as_a_phrase(self) -> None: + parse = parsed(MOTIF, (Phrase(body=MOTIF),)) + assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=0),) + + def test_the_same_figure_played_higher_names_the_same_phrase(self) -> None: + higher = bytes(value + 7 for value in MOTIF) + parse = parsed(higher, (Phrase(body=MOTIF),)) + assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=7),) + + def test_a_phrase_the_layer_switches_off_is_spelled_out_instead(self) -> None: + parse = parsed(MOTIF, (Phrase(body=MOTIF),), options=LITERALS_ONLY) + assert parse.tokens == (LiteralToken(values=MOTIF),) + + +class TestABoundaryIsATickATokenStartsOn: + """A loop entry is re-entered mid-stream, so a token starts there and leans on nothing before it.""" + + def test_a_token_starts_on_every_boundary(self) -> None: + plane = bytes((3,)) * 20 + parse = parsed(plane, boundaries=frozenset({0, 7, 13})) + assert {0, 7, 13} <= set(starts(parse)) + + def test_a_boundary_is_reached_by_a_token_stating_its_own_value(self) -> None: + """A hold plays the value the plane already reached, which a re-entry has yet to state.""" + plane = bytes((3,)) * 20 + parse = parsed(plane, boundaries=frozenset({0, 7})) + entered = parse.tokens[starts(parse).index(7)] + assert isinstance(entered, LiteralToken) + + def test_the_plane_still_reads_back_the_same_ticks(self) -> None: + plane = bytes((3,)) * 9 + bytes((4,)) * 9 + bounded: Tuple[TokenUnion, ...] = parsed(plane, boundaries=frozenset({0, 5, 12})).tokens + assert sum(token.ticks for token in bounded) == len(plane) diff --git a/tests/unit/sampletones_player/compression/planes/__init__.py b/tests/unit/sampletones_player/compression/planes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/planes/conftest.py b/tests/unit/sampletones_player/compression/planes/conftest.py new file mode 100644 index 000000000..3019f5a9d --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/conftest.py @@ -0,0 +1,43 @@ +from typing import Final + +import pytest + +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.registers.streams import ChannelStreams +from sampletones_shared.music import Tuning +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_SILENT_VOLUME, + noise_tick, + player_streams, + pulse_tick, + triangle_tick, +) + +TUNING: Final[Tuning] = Tuning() +PITCHES: Final[PitchTable] = PitchTable.from_tuning(TUNING) +LOW_INDEX: Final[int] = 36 +HIGH_INDEX: Final[int] = 48 +NOISE_PERIOD: Final[int] = 0x0A +SOUNDING_TICKS: Final[int] = 3 + + +@pytest.fixture +def pitches() -> PitchTable: + """The timer each pitch sounds at, at the tuning the fixture's streams were written under.""" + return PITCHES + + +@pytest.fixture +def sounding_streams() -> ChannelStreams: + """Four channels sounding at once, the pulse channel walking and the rest holding still.""" + return player_streams( + pulse1=( + pulse_tick(PLAYER_FULL_VOLUME, 0, PITCHES.timers[LOW_INDEX]), + pulse_tick(PLAYER_FULL_VOLUME, 1, PITCHES.timers[HIGH_INDEX]), + pulse_tick(PLAYER_SILENT_VOLUME, 1, PITCHES.timers[HIGH_INDEX]), + ), + pulse2=(pulse_tick(PLAYER_SILENT_VOLUME, 0, PITCHES.timers[LOW_INDEX]),), + triangle=(triangle_tick(True, PITCHES.timers[LOW_INDEX]),), + noise=(noise_tick(PLAYER_FULL_VOLUME, 0, NOISE_PERIOD),), + ) diff --git a/tests/unit/sampletones_player/compression/planes/test_channel.py b/tests/unit/sampletones_player/compression/planes/test_channel.py new file mode 100644 index 000000000..91a50dec3 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_channel.py @@ -0,0 +1,21 @@ +import pytest +from pydantic import ValidationError + +from sampletones_player.compression.planes.channel import ChannelPlanes + + +class TestAChannelWritesTwoPlanesOfEqualLength: + """The two planes are read tick for tick, so a channel states both across the same ticks.""" + + def test_both_planes_reach_the_ticks_the_channel_covers(self) -> None: + planes = ChannelPlanes(control=bytes(4), value=bytes(4)) + assert planes.ticks == 4 + assert planes.ordered == (planes.control, planes.value) + + def test_planes_covering_different_ticks_are_refused(self) -> None: + with pytest.raises(ValidationError): + ChannelPlanes(control=bytes(3), value=bytes(2)) + + def test_planes_covering_no_tick_are_refused(self) -> None: + with pytest.raises(ValidationError): + ChannelPlanes(control=b"", value=b"") diff --git a/tests/unit/sampletones_player/compression/planes/test_order.py b/tests/unit/sampletones_player/compression/planes/test_order.py new file mode 100644 index 000000000..0fd6f0d46 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_order.py @@ -0,0 +1,21 @@ +import pytest + +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.compression import PLANE_COUNT + + +def numbered(count: int) -> PlaneOrder: + return PlaneOrder.across(bytes((plane,)) for plane in range(count)) + + +class TestThePlanesAreNamedRatherThanNumbered: + """The song block writes eight planes in one order, and each is reached by its own name.""" + + def test_the_planes_take_the_names_the_song_block_writes_them_by(self) -> None: + planes = numbered(PLANE_COUNT) + assert planes.pulse1_control == bytes((0,)) + assert planes.noise_value == bytes((PLANE_COUNT - 1,)) + + def test_a_run_of_planes_other_than_a_song_block_holds_is_refused(self) -> None: + with pytest.raises(ValueError): + numbered(PLANE_COUNT - 1) diff --git a/tests/unit/sampletones_player/compression/planes/test_rebuild.py b/tests/unit/sampletones_player/compression/planes/test_rebuild.py new file mode 100644 index 000000000..bbe22ccf7 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_rebuild.py @@ -0,0 +1,18 @@ +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.rebuild import streams_from_planes +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.registers.streams import ChannelStreams + + +class TestThePlanesReadBackAsTheStreamsTheyCameFrom: + """The separation is a reading of the streams, so it carries every register value.""" + + def test_a_song_rebuilds_from_its_planes( + self, + sounding_streams: ChannelStreams, + pitches: PitchTable, + ) -> None: + planes = planes_from_streams(sounding_streams, pitches) + rebuilt = streams_from_planes(planes, pitches) + for tick in range(sounding_streams.ticks): + assert rebuilt.at(tick) == sounding_streams.at(tick) diff --git a/tests/unit/sampletones_player/compression/planes/test_separate.py b/tests/unit/sampletones_player/compression/planes/test_separate.py new file mode 100644 index 000000000..3830bf91b --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_separate.py @@ -0,0 +1,48 @@ +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import channel_planes, planes_from_streams +from sampletones_player.registers.streams import ChannelStreams +from tests.unit.sampletones_player.compression.planes.conftest import ( + HIGH_INDEX, + LOW_INDEX, + NOISE_PERIOD, + SOUNDING_TICKS, +) + + +class TestAChannelSeparatesIntoTwoPlanes: + """A channel writes how it sounds and what it sounds, and each turns over at its own pace.""" + + def test_a_tone_channel_names_its_pitch_rather_than_its_divider( + self, + sounding_streams: ChannelStreams, + pitches: PitchTable, + ) -> None: + planes = planes_from_streams(sounding_streams, pitches) + assert planes.pulse1.value == bytes((LOW_INDEX, HIGH_INDEX, HIGH_INDEX)) + + def test_the_noise_channel_names_the_period_its_register_takes( + self, + sounding_streams: ChannelStreams, + pitches: PitchTable, + ) -> None: + planes = planes_from_streams(sounding_streams, pitches) + assert planes.noise.value == bytes((NOISE_PERIOD,)) * planes.ticks + + def test_a_channel_running_out_early_holds_its_values_through_the_song( + self, + sounding_streams: ChannelStreams, + pitches: PitchTable, + ) -> None: + planes = planes_from_streams(sounding_streams, pitches) + assert planes.ticks == SOUNDING_TICKS + assert planes.pulse2.control == bytes((planes.pulse2.control[0],)) * SOUNDING_TICKS + + def test_one_channel_separates_the_same_way_the_song_does( + self, + sounding_streams: ChannelStreams, + pitches: PitchTable, + ) -> None: + planes = planes_from_streams(sounding_streams, pitches) + pulse1 = channel_planes(ChannelName.PULSE1, sounding_streams.padded[0], pitches) + assert pulse1 == planes.pulse1 diff --git a/tests/unit/sampletones_player/compression/planes/test_song.py b/tests/unit/sampletones_player/compression/planes/test_song.py new file mode 100644 index 000000000..235eeabc5 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_song.py @@ -0,0 +1,32 @@ +import pytest +from pydantic import ValidationError + +from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.compression import PLANE_COUNT + + +def song(control: bytes, value: bytes) -> SongPlanes: + channel = ChannelPlanes(control=control, value=value) + resting = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value))) + return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=resting) + + +class TestASongGathersItsChannelsPlanes: + """The eight planes advance together, so the song states them under one length.""" + + def test_a_song_carries_two_planes_for_every_channel(self) -> None: + assert len(song(bytes((1, 2)), bytes((3, 4))).planes) == PLANE_COUNT + + def test_the_planes_read_back_under_the_channels_that_write_them(self) -> None: + planes = song(bytes((1, 2)), bytes((3, 4))) + assert SongPlanes.from_order(planes.planes) == planes + + def test_a_song_lasts_the_ticks_its_channels_cover(self) -> None: + assert song(bytes((1, 2)), bytes((3, 4))).ticks == 2 + + def test_channels_covering_different_ticks_are_refused(self) -> None: + short = ChannelPlanes(control=bytes(1), value=bytes(1)) + long = ChannelPlanes(control=bytes(2), value=bytes(2)) + with pytest.raises(ValidationError): + SongPlanes(pulse1=short, pulse2=long, triangle=short, noise=short) diff --git a/tests/unit/sampletones_player/compression/test_decode.py b/tests/unit/sampletones_player/compression/test_decode.py new file mode 100644 index 000000000..5ecb01256 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_decode.py @@ -0,0 +1,53 @@ +from typing import Final + +from sampletones_player.compression.decode import decode_plane +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag + +MOTIF: Final[bytes] = bytes((40, 44, 47)) +DICTIONARY = phrase_table((Phrase(body=MOTIF),)) + + +class TestWhatTheDriverReadsFromAStream: + """The decoder is the reading the 6502 performs, stated where it is testable.""" + + def test_a_literal_writes_the_values_that_follow_it(self) -> None: + stream = bytes((TokenTag.LITERAL | 2, 5, 6, 7)) + assert decode_plane(stream, DICTIONARY, 3) == bytes((5, 6, 7)) + + def test_a_hold_writes_the_value_the_plane_reached(self) -> None: + stream = bytes((TokenTag.LITERAL | 0, 9, TokenTag.HOLD | 2)) + assert decode_plane(stream, DICTIONARY, 4) == bytes((9, 9, 9, 9)) + + def test_a_phrase_writes_the_body_the_table_holds(self) -> None: + stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) - 1)) + assert decode_plane(stream, DICTIONARY, len(MOTIF)) == MOTIF + + def test_a_phrase_running_past_its_body_holds_the_value_it_ended_on(self) -> None: + stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) + 1)) + assert decode_plane(stream, DICTIONARY, len(MOTIF) + 2) == MOTIF + bytes((MOTIF[-1],)) * 2 + + def test_a_phrase_cut_short_writes_as_much_of_the_body_as_sounded(self) -> None: + stream = bytes((TokenTag.PHRASE | 0, 1)) + assert decode_plane(stream, DICTIONARY, 2) == MOTIF[:2] + + def test_a_shifted_phrase_writes_the_body_moved_by_the_shift(self) -> None: + stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, len(MOTIF) - 1, 5)) + assert decode_plane(stream, DICTIONARY, len(MOTIF)) == bytes(value + 5 for value in MOTIF) + + def test_a_shift_walks_the_byte_around_where_it_reaches_past_one(self) -> None: + """The driver adds the shift to a byte, so a fall reaches it as the byte that wraps to it.""" + stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, 0, 0xFD)) + assert decode_plane(stream, DICTIONARY, 1) == bytes((MOTIF[0] - 3,)) + + def test_an_escaped_phrase_names_its_id_in_the_byte_that_follows(self) -> None: + table = phrase_table( + tuple(Phrase(body=bytes((value, value))) for value in range(PHRASE_ID_ESCAPE)) + (Phrase(body=MOTIF),) + ) + stream = bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, PHRASE_ID_ESCAPE, len(MOTIF) - 1)) + assert decode_plane(stream, table, len(MOTIF)) == MOTIF + + def test_a_token_reaching_past_the_song_stops_where_the_song_does(self) -> None: + stream = bytes((TokenTag.LITERAL | 0, 4, TokenTag.HOLD | 63)) + assert decode_plane(stream, DICTIONARY, 3) == bytes((4, 4, 4)) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py new file mode 100644 index 000000000..19767d592 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -0,0 +1,105 @@ +from typing import Final, FrozenSet, Tuple + +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.encode import emit, encode_planes +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken +from sampletones_player.specification.compression import ( + MAX_PHRASE_TICKS, + PHRASE_ID_ESCAPE, + TokenTag, +) + +EVERY_LAYER: Final[CodecOptions] = CodecOptions( + holds=True, + phrases=True, + transposition=True, + search=True, +) +SEEDED: Final[CodecOptions] = CodecOptions( + holds=True, + phrases=True, + transposition=True, + search=False, +) +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() +MOTIF: Final[bytes] = bytes((40, 44, 47, 44)) +TIMBRE: Final[bytes] = bytes((0x3F, 0x3A, 0x35, 0x30)) +REPEATS: Final[int] = 12 + + +def song_planes(control: bytes, value: bytes) -> SongPlanes: + channel = ChannelPlanes(control=control, value=value) + resting = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value))) + return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=resting) + + +class TestWhatATokenLooksLikeOnTheBus: + """The opcode layout is what the driver reads, so the bytes themselves are the contract.""" + + def test_a_hold_carries_its_count_inside_its_opcode(self) -> None: + assert emit((HoldToken(ticks=3),)) == bytes((TokenTag.HOLD | 2,)) + + def test_a_literal_carries_its_length_inside_its_opcode(self) -> None: + assert emit((LiteralToken(values=bytes((0x10, 0x20))),)) == bytes((TokenTag.LITERAL | 1, 0x10, 0x20)) + + def test_a_phrase_carries_a_cheap_id_inside_its_opcode(self) -> None: + token = PhraseToken(phrase_id=2, ticks=5, transpose=0) + assert emit((token,)) == bytes((TokenTag.PHRASE | 2, 4)) + + def test_a_shifted_phrase_states_the_shift_after_the_count(self) -> None: + token = PhraseToken(phrase_id=2, ticks=5, transpose=0xFD) + assert emit((token,)) == bytes((TokenTag.TRANSPOSED_PHRASE | 2, 4, 0xFD)) + + def test_a_phrase_beyond_the_cheap_ids_names_itself_in_the_byte_that_follows(self) -> None: + token = PhraseToken(phrase_id=200, ticks=MAX_PHRASE_TICKS, transpose=0) + assert emit((token,)) == bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, 200, MAX_PHRASE_TICKS - 1)) + + def test_a_stream_takes_the_bytes_the_parse_counted(self) -> None: + tokens = (LiteralToken(values=MOTIF), HoldToken(ticks=4), PhraseToken(phrase_id=1, ticks=2, transpose=3)) + assert len(emit(tokens)) == sum(token.size for token in tokens) + + +class TestEncodingASong: + """The instruments seed the dictionary, the search fills the rest, and the table settles.""" + + def test_a_song_plays_back_as_the_planes_it_was_written_from(self) -> None: + planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) + compressed = encode_planes(planes, (), EVERY_LAYER, NO_BOUNDARIES) + assert decode_planes(compressed) == planes + + def test_the_figure_the_song_repeats_reaches_the_dictionary(self) -> None: + """The search states the figure at whatever length pays best, the motif being its unit.""" + planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS) + compressed = encode_planes(planes, (), EVERY_LAYER, NO_BOUNDARIES) + assert compressed.phrases.phrases + for phrase in compressed.phrases.phrases: + assert phrase.body == MOTIF * (len(phrase.body) // len(MOTIF)) + + def test_a_seed_the_song_never_leans_on_leaves_the_dictionary(self) -> None: + """A phrase earns its entry by sparing more than the entry costs.""" + planes = song_planes(bytes((0x30,)) * len(MOTIF), MOTIF) + compressed = encode_planes(planes, (Phrase(body=MOTIF),), SEEDED, NO_BOUNDARIES) + assert compressed.phrases.phrases == () + + def test_the_figure_played_most_takes_the_cheapest_id(self) -> None: + planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS) + seeds: Tuple[Phrase, ...] = (Phrase(body=bytes((0x30,)) * 8), Phrase(body=MOTIF)) + compressed = encode_planes(planes, seeds, SEEDED, NO_BOUNDARIES) + assert compressed.phrases[0] == Phrase(body=MOTIF) + + def test_a_song_re_entered_at_a_boundary_still_plays_back_whole(self) -> None: + planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) + compressed = encode_planes(planes, (), EVERY_LAYER, frozenset({len(MOTIF) * 3})) + assert decode_planes(compressed) == planes + + def test_every_plane_of_the_song_carries_a_stream(self) -> None: + planes = song_planes(bytes((0x30,)) * 4, MOTIF) + compressed = encode_planes(planes, (), EVERY_LAYER, NO_BOUNDARIES) + assert len(compressed.streams) == len(planes.planes) + assert compressed.ticks == planes.ticks diff --git a/tests/unit/sampletones_player/compression/test_pitch.py b/tests/unit/sampletones_player/compression/test_pitch.py new file mode 100644 index 000000000..19a867aef --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_pitch.py @@ -0,0 +1,44 @@ +from typing import Final + +from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH +from sampletones_shared.music import Tuning + +TUNING: Final[Tuning] = Tuning() + + +class TestThePitchTableNamesEveryPitchAProjectSounds: + """A plane names a pitch by its distance above the lowest one the project reaches.""" + + def test_the_table_spans_the_pitches_the_project_offers(self) -> None: + table = PitchTable.from_tuning(TUNING) + assert len(table.timers) == PITCH_COUNT == LIMIT_MAX_PITCH - LIMIT_MIN_PITCH + 1 + + def test_every_timer_resolves_back_to_an_index_sounding_it(self) -> None: + """Pitches clamped to the same divider sound alike, so one index stands for them.""" + table = PitchTable.from_tuning(TUNING) + indices = table.indices + for timer in table.timers: + assert table.timers[indices[timer]] == timer + + def test_a_higher_index_never_sounds_a_slower_divider(self) -> None: + table = PitchTable.from_tuning(TUNING) + for timer, following in zip(table.timers, table.timers[1:]): + assert following <= timer + + def test_the_driver_reads_the_low_bytes_then_the_high_bytes(self) -> None: + table = PitchTable.from_tuning(TUNING) + data = table.data + assert len(data) == 2 * PITCH_COUNT + for index, timer in enumerate(table.timers): + assert data[index] == timer & MAX_REGISTER_VALUE + assert data[PITCH_COUNT + index] == timer >> TIMER_HIGH_SHIFT + + def test_a_retuned_table_moves_the_dividers(self) -> None: + standard = PitchTable.from_tuning(TUNING) + retuned = PitchTable.from_tuning(Tuning(a4_frequency=432.0)) + assert retuned.timers != standard.timers diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py new file mode 100644 index 000000000..1c20db496 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -0,0 +1,43 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import channel_planes +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.registers.channel import channel_registers +from sampletones_shared.music import Tuning +from tests.suite.performance import make_pulse_reconstruction, project_with_sample + +TUNING: Final[Tuning] = Tuning() +ROWS_PER_PATTERN: Final[int] = 8 +SOUNDING_TICKS: Final[int] = 5 +PLANES_PER_SLICE: Final[int] = 2 + + +class TestTheInstrumentsSeedTheDictionary: + """A song plays sample slices at rows, so the shapes its planes repeat are the slices.""" + + def test_a_sample_offers_both_planes_of_the_channel_it_plays(self) -> None: + reconstruction = make_pulse_reconstruction(count=SOUNDING_TICKS) + project, _ = project_with_sample(reconstruction, rows_per_pattern=ROWS_PER_PATTERN) + assert len(phrases_from_project(project, TUNING)) == PLANES_PER_SLICE + + def test_the_phrases_are_the_planes_the_slice_writes(self) -> None: + reconstruction = make_pulse_reconstruction(count=SOUNDING_TICKS) + project, sample = project_with_sample(reconstruction, rows_per_pattern=ROWS_PER_PATTERN) + registers = channel_registers( + ChannelName.PULSE1, + {ChannelName.PULSE1: sample.reconstruction.get_channel_instructions(ChannelName.PULSE1)}, + get_timer_table(TUNING), + ) + planes = channel_planes(ChannelName.PULSE1, registers, PitchTable.from_tuning(TUNING)) + assert tuple(phrase.body for phrase in phrases_from_project(project, TUNING)) == planes.ordered + + def test_a_project_holding_no_sample_offers_nothing(self) -> None: + project, _ = project_with_sample( + make_pulse_reconstruction(count=SOUNDING_TICKS), + rows_per_pattern=ROWS_PER_PATTERN, + ) + project.samples.clear() + assert phrases_from_project(project, TUNING) == () diff --git a/tests/unit/sampletones_player/compression/tokens/__init__.py b/tests/unit/sampletones_player/compression/tokens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/tokens/test_hold.py b/tests/unit/sampletones_player/compression/tokens/test_hold.py new file mode 100644 index 000000000..4e9a86220 --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_hold.py @@ -0,0 +1,9 @@ +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.specification.compression import MAX_HOLD_TICKS, OPCODE_SIZE + + +class TestWhatAHoldCosts: + """A hold states a count inside its own opcode, so its length is free.""" + + def test_a_hold_costs_its_opcode_however_long_it_runs(self) -> None: + assert HoldToken(ticks=1).size == HoldToken(ticks=MAX_HOLD_TICKS).size == OPCODE_SIZE diff --git a/tests/unit/sampletones_player/compression/tokens/test_literal.py b/tests/unit/sampletones_player/compression/tokens/test_literal.py new file mode 100644 index 000000000..d78b237bf --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_literal.py @@ -0,0 +1,16 @@ +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.sizes import literal_size +from sampletones_player.specification.compression import OPCODE_SIZE + +SPELLED_OUT: int = 5 + + +class TestWhatALiteralCosts: + """A literal spells its values out, so it costs its opcode and every one of them.""" + + def test_a_literal_covers_a_tick_for_every_value_it_states(self) -> None: + assert LiteralToken(values=bytes(SPELLED_OUT)).ticks == SPELLED_OUT + + def test_a_literal_costs_its_opcode_and_the_values_it_spells_out(self) -> None: + token = LiteralToken(values=bytes(SPELLED_OUT)) + assert token.size == literal_size(SPELLED_OUT) == OPCODE_SIZE + SPELLED_OUT diff --git a/tests/unit/sampletones_player/compression/tokens/test_phrase.py b/tests/unit/sampletones_player/compression/tokens/test_phrase.py new file mode 100644 index 000000000..f0a8e87bd --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_phrase.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_player.compression.tokens.phrase import PhraseToken +from sampletones_player.compression.tokens.sizes import phrase_size +from sampletones_player.specification.compression import ( + CHEAP_PHRASE_IDS, + MAX_BYTE_VALUE, + OPCODE_SIZE, + PHRASE_COUNT_SIZE, + PHRASE_ESCAPE_SIZE, + TRANSPOSE_SIZE, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestWhatAPhraseTokenCosts(BaseTestSuite): + """A token's bytes are what the parse is decided in, so each states its own size.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + phrase_id: int + transpose: int + + @property + def label(self) -> str: + shift = "shifted" if self.transpose else "plain" + return f"phrase_{self.phrase_id}_{shift}" + + test_cases = ( + TestCase(phrase_id=0, transpose=0, expected=OPCODE_SIZE + PHRASE_COUNT_SIZE), + TestCase( + phrase_id=0, + transpose=1, + expected=OPCODE_SIZE + PHRASE_COUNT_SIZE + TRANSPOSE_SIZE, + ), + TestCase( + phrase_id=CHEAP_PHRASE_IDS, + transpose=0, + expected=OPCODE_SIZE + PHRASE_COUNT_SIZE + PHRASE_ESCAPE_SIZE, + ), + TestCase( + phrase_id=CHEAP_PHRASE_IDS, + transpose=MAX_BYTE_VALUE, + expected=OPCODE_SIZE + PHRASE_COUNT_SIZE + PHRASE_ESCAPE_SIZE + TRANSPOSE_SIZE, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_phrase_token_pays_for_the_id_and_the_shift_it_names(self, test_case: TestCase) -> None: + token = PhraseToken(phrase_id=test_case.phrase_id, ticks=1, transpose=test_case.transpose) + assert token.size == test_case.expected == phrase_size(test_case.phrase_id, test_case.transpose) diff --git a/tests/unit/sampletones_player/registers/test_channel.py b/tests/unit/sampletones_player/registers/test_channel.py new file mode 100644 index 000000000..c9b79b769 --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_channel.py @@ -0,0 +1,89 @@ +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_player.registers.channel import channel_instructions, channel_registers +from sampletones_player.specification.registers import ( + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SOUNDING_RELOAD, +) +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_REFERENCE_PITCH, + PLAYER_TIMER_TABLE, + sounding_pulse, +) + +SOUNDING_TICKS: Final[int] = 4 +BASS_PITCH: Final[int] = 45 +NOISE_PERIOD: Final[int] = 10 +NOISE_VOLUME: Final[int] = 8 + + +def melody() -> List[InstructionUnion]: + return [sounding_pulse(PLAYER_REFERENCE_PITCH, PLAYER_FULL_VOLUME, 0) for _ in range(SOUNDING_TICKS)] + + +class TestChannelInstructions: + """A channel's stream read as the instruction type its encoder takes.""" + + def test_a_stream_of_the_channels_own_type_passes_through(self) -> None: + instructions = melody() + assert channel_instructions(instructions, PulseInstruction) == instructions + + def test_a_channel_describing_no_frame_rests_for_a_tick(self) -> None: + assert channel_instructions([], PulseInstruction) == [PulseInstruction.null_instruction()] + + def test_a_resting_channel_rests_in_its_own_type(self) -> None: + assert channel_instructions([], NoiseInstruction) == [NoiseInstruction.null_instruction()] + + def test_a_stream_of_another_channels_type_raises(self) -> None: + with pytest.raises(TypeError): + channel_instructions(melody(), TriangleInstruction) + + +class TestChannelRegisters: + """Naming the channel is the whole of what it takes to encode a stream.""" + + def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release(self) -> None: + registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: melody()}, PLAYER_TIMER_TABLE) + assert len(registers) == SOUNDING_TICKS + 1 + + def test_a_channel_the_song_leaves_out_rests_for_a_tick(self) -> None: + assert len(channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)) == 1 + + def test_a_pitch_reaches_the_timer_the_table_states(self) -> None: + registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: melody()}, PLAYER_TIMER_TABLE) + timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert registers[0].values[1:] == (timer & 0xFF, timer >> 8) + + def test_the_triangle_channel_answers_in_its_own_registers(self) -> None: + instructions: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH)] + registers = channel_registers( + ChannelName.TRIANGLE, + {ChannelName.TRIANGLE: instructions}, + PLAYER_TIMER_TABLE, + ) + assert registers[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD + + def test_the_noise_channel_answers_in_its_own_registers(self) -> None: + instructions: List[InstructionUnion] = [ + NoiseInstruction(on=True, period=NOISE_PERIOD, volume=NOISE_VOLUME, short=False), + ] + registers = channel_registers( + ChannelName.NOISE, + {ChannelName.NOISE: instructions}, + PLAYER_TIMER_TABLE, + ) + assert registers[0].control & 0x0F == NOISE_VOLUME + + def test_a_channel_holding_another_channels_instructions_raises(self) -> None: + with pytest.raises(TypeError): + channel_registers(ChannelName.TRIANGLE, {ChannelName.TRIANGLE: melody()}, PLAYER_TIMER_TABLE) diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index 0a50732dd..5b6ea6427 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -16,7 +16,6 @@ from sampletones_core.timing import SongTiming from sampletones_player.builder import ( SONG_START, - channel_instructions, instructions_from_instruments, loop_tick_from_instruments, song_from_project, @@ -84,24 +83,6 @@ def bass(*, loop: bool) -> InstrumentExport: ) -class TestChannelInstructions: - """A channel's stream read as the instruction type its encoder takes.""" - - def test_a_stream_of_the_channels_own_type_passes_through(self) -> None: - instructions = melody() - assert channel_instructions(instructions, PulseInstruction) == instructions - - def test_a_channel_describing_no_frame_rests_for_a_tick(self) -> None: - assert channel_instructions([], PulseInstruction) == [PulseInstruction.null_instruction()] - - def test_a_resting_channel_rests_in_its_own_type(self) -> None: - assert channel_instructions([], NoiseInstruction) == [NoiseInstruction.null_instruction()] - - def test_a_stream_of_another_channels_type_raises(self) -> None: - with pytest.raises(TypeError): - channel_instructions(melody(), TriangleInstruction) - - class TestStreamsFromInstructions: """The four channels encoded together, each through the encoder its own type names.""" From 336a5b96bc6598c9aeaba2e0b18996d78e2c5464 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 14:35:06 +0200 Subject: [PATCH 051/142] Increased: test coverage threshold --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 52ae49d59..b8632e7a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,7 +147,7 @@ source = [ [tool.coverage.report] show_missing = true -fail_under = 80 +fail_under = 90 [tool.mypy] python_version = "3.12" From b22a9048b43e54996404004d3598cdcbfd8cd31c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 15:24:19 +0200 Subject: [PATCH 052/142] Extracted: measured-duration tests into their own benchmark pass --- .github/workflows/ci.yml | 5 +- Makefile | 6 +- docs/development/guidelines.md | 1 + scripts/linux/dev/tests.sh | 8 +- scripts/windows/dev/tests.bat | 11 +- tests/benchmarks/__init__.py | 0 tests/benchmarks/conftest.py | 15 ++ tests/benchmarks/test_compression.py | 46 ++++++ tests/integration/nsf/corpus.py | 146 ++++++++++++++++++ .../nsf/test_compression_report.py | 96 +----------- 10 files changed, 236 insertions(+), 98 deletions(-) create mode 100644 tests/benchmarks/__init__.py create mode 100644 tests/benchmarks/conftest.py create mode 100644 tests/benchmarks/test_compression.py create mode 100644 tests/integration/nsf/corpus.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a9cc8760..687508232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,4 +78,7 @@ jobs: run: uv run python -m pytest src/ --doctest-modules --no-cov - name: Run the unit and integration suites with coverage - run: uv run python -m pytest -n auto --cov + run: uv run python -m pytest -n auto --cov --ignore=tests/benchmarks + + - name: Run the benchmarks + run: uv run python -m pytest tests/benchmarks --no-cov diff --git a/Makefile b/Makefile index 3a9a98d87..4e859c64e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install build release system-deps run clean pre-commit test \ +.PHONY: help setup install build release system-deps run clean pre-commit test benchmarks \ ftm-samples nsf-samples nsf-render compression-report icons player check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format @@ -69,6 +69,7 @@ help: @echo $(Q) make build - Compile standalone executable (respects current deployment config)$(Q) @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) + @echo $(Q) make benchmarks - Run the measured-duration suite on its own$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) @echo $(Q) make nsf-samples - Emit example .nsf files to build/nsf via the integration suite$(Q) @echo $(Q) make nsf-render - Render the .nsf files in build/nsf to waves with ffmpeg$(Q) @@ -111,6 +112,9 @@ pre-commit: test: $(call script,dev/tests) +benchmarks: + uv run python -m pytest tests/benchmarks --no-cov + ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: uv run python -m pytest tests/integration/famitracker diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 0d2277aae..d6e86790a 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -85,6 +85,7 @@ These rules govern the Python in this repository. They complement 1. A test file mirrors the ownership of the code it exercises. 1. When functionality moves between packages, move its direct unit tests in the same change. +1. **A test whose assertion is a measured duration lives in `tests/benchmarks/`.** The gated suite runs across six workers and under coverage, which multiplies the cost of the code being measured, so those tests run in a pass of their own — serial and uncovered — where the reading is the code's own cost. `make test` runs that pass after the covered one, and `make benchmarks` runs it alone. 1. Parametrize tests that share a body, using a test-case dataclass. 1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. 1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions. diff --git a/scripts/linux/dev/tests.sh b/scripts/linux/dev/tests.sh index bbd392299..ad698d7ef 100755 --- a/scripts/linux/dev/tests.sh +++ b/scripts/linux/dev/tests.sh @@ -7,10 +7,14 @@ uv run python -m pytest src/ --doctest-modules --no-cov DOCTEST_EXIT=$? echo "Running pytest with coverage..." -uv run python -m pytest -n 6 --cov +uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks PYTEST_EXIT=$? -if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]]; then +echo "Running benchmarks..." +uv run python -m pytest tests/benchmarks --no-cov +BENCHMARK_EXIT=$? + +if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]] || [[ $BENCHMARK_EXIT -ne 0 ]]; then echo "Tests failed." exit 1 fi diff --git a/scripts/windows/dev/tests.bat b/scripts/windows/dev/tests.bat index 1a9fa0c73..a449a0659 100644 --- a/scripts/windows/dev/tests.bat +++ b/scripts/windows/dev/tests.bat @@ -6,9 +6,13 @@ uv run python -m pytest src/ --doctest-modules --no-cov set DOCTEST_EXIT=%ERRORLEVEL% echo Running pytest with coverage... -uv run python -m pytest -n 6 --cov +uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks set PYTEST_EXIT=%ERRORLEVEL% +echo Running benchmarks... +uv run python -m pytest tests/benchmarks --no-cov +set BENCHMARK_EXIT=%ERRORLEVEL% + if not %DOCTEST_EXIT%==0 ( echo Tests failed. exit /b 1 @@ -19,5 +23,10 @@ if not %PYTEST_EXIT%==0 ( exit /b 1 ) +if not %BENCHMARK_EXIT%==0 ( + echo Tests failed. + exit /b 1 +) + echo All tests passed. exit /b 0 diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py new file mode 100644 index 000000000..3c2949f8d --- /dev/null +++ b/tests/benchmarks/conftest.py @@ -0,0 +1,15 @@ +from tests.integration.conftest import ( + audio_directory, + instrument_catalog, + integration_project, + module_config, + synth_config, +) + +__all__ = [ + "audio_directory", + "instrument_catalog", + "integration_project", + "module_config", + "synth_config", +] diff --git a/tests/benchmarks/test_compression.py b/tests/benchmarks/test_compression.py new file mode 100644 index 000000000..4494215c0 --- /dev/null +++ b/tests/benchmarks/test_compression.py @@ -0,0 +1,46 @@ +from time import process_time +from typing import Final + +import pytest + +from sampletones_core.project.project import Project +from sampletones_player.compression.encode import encode_planes +from sampletones_player.compression.options import CodecOptions +from tests.integration.nsf.corpus import ( + LONG_ARRANGEMENT, + TARGET_SECONDS, + CorpusEntry, + arrangement_entry, + lengthened_arrangement, +) + +EVERY_LAYER: Final[CodecOptions] = CodecOptions( + holds=True, + phrases=True, + transposition=True, + search=True, +) +MAX_ENCODER_SECONDS: Final[float] = 30.0 + + +@pytest.fixture(scope="module") +def long_arrangement(integration_project: Project) -> CorpusEntry: + """The three-minute song an export is measured against.""" + return arrangement_entry( + LONG_ARRANGEMENT, + lengthened_arrangement(integration_project, TARGET_SECONDS), + ) + + +class TestTheEncoderKeepsWithinWhatAnExportAllows: + """The codec runs while the user waits for the file, so its cost is held to a bound.""" + + def test_a_three_minute_song_encodes_within_the_budget( + self, + long_arrangement: CorpusEntry, + ) -> None: + """The bound stands where an export would keep the user waiting, against five seconds today.""" + planes = long_arrangement.planes + started = process_time() + encode_planes(planes, long_arrangement.seeds, EVERY_LAYER, frozenset()) + assert process_time() - started < MAX_ENCODER_SECONDS diff --git a/tests/integration/nsf/corpus.py b/tests/integration/nsf/corpus.py new file mode 100644 index 000000000..661075243 --- /dev/null +++ b/tests/integration/nsf/corpus.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass +from math import ceil +from typing import Dict, Final, List, Tuple + +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SongTiming +from sampletones_player.builder import song_from_project, song_from_reconstruction +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.song import Song +from sampletones_shared.music import Tuning +from tests.integration.nsf.songs import RECORD_BYTES_PER_TICK, lengthened + +ARRANGEMENT: Final[str] = "arrangement" +LONG_ARRANGEMENT: Final[str] = "arrangement, three minutes" +TARGET_SECONDS: Final[int] = 180 + + +@dataclass(frozen=True) +class CorpusEntry: + """One song the codec is measured on, alongside the phrases its own instruments offer.""" + + name: str + song: Song + seeds: Tuple[Phrase, ...] + tuning: Tuning + + @property + def pitches(self) -> PitchTable: + """The timer each pitch of the song sounds at.""" + return PitchTable.from_tuning(self.tuning) + + @property + def planes(self) -> SongPlanes: + """The eight planes the song separates into.""" + return planes_from_streams(self.song.streams, self.pitches) + + @property + def records(self) -> int: + """The bytes the song takes as one record per tick per channel.""" + return RECORD_BYTES_PER_TICK * self.song.ticks + + +def _sample_project( + sample: Sample, + settings: ProjectSettings, +) -> Project: + project = Project.create(settings=settings) + project.samples.append(sample) + return project + + +def sample_entries( + instrument_catalog: Dict[str, Sample], + settings: ProjectSettings, +) -> Tuple[CorpusEntry, ...]: + """Each catalog sample as a song of its own, played at the tuning it was reconstructed at. + + Args: + instrument_catalog: The samples the integration suite reads. + settings: The project settings a sample is seeded under. + + Returns: + Tuple[CorpusEntry, ...]: One entry per sample, in catalog order. + """ + entries: List[CorpusEntry] = [] + for name, sample in instrument_catalog.items(): + tuning = sample.reconstruction.config.library.tuning + entries.append( + CorpusEntry( + name=name, + song=song_from_reconstruction(sample.reconstruction, loop_tick=None), + seeds=phrases_from_project(_sample_project(sample, settings), tuning), + tuning=tuning, + ) + ) + + return tuple(entries) + + +def arrangement_entry( + name: str, + project: Project, +) -> CorpusEntry: + """A whole project flattened into one song, at concert tuning. + + Args: + name: What the entry is called in a report. + project: The arrangement the song is walked from. + + Returns: + CorpusEntry: The song and the phrases the project's instruments offer. + """ + tuning = Tuning() + return CorpusEntry( + name=name, + song=song_from_project(project, tuning, loop_tick=None), + seeds=phrases_from_project(project, tuning), + tuning=tuning, + ) + + +def lengthened_arrangement( + project: Project, + seconds: int, +) -> Project: + """``project`` with its order repeated until the song lasts ``seconds``. + + The corpus arrangement is a couple of seconds long, and what the program area is measured + against is a song of minutes, so the order is played through as many times as that takes. + + Args: + project: The arrangement to repeat. + seconds: How long the song is to last. + + Returns: + Project: A copy of the project, its order repeated. + """ + groove = SongTiming.from_project(project).groove() + frames = ceil(seconds * project.settings.nes_frequency / groove.total_ticks) + return lengthened(project, frames) + + +def build_corpus( + instrument_catalog: Dict[str, Sample], + integration_project: Project, +) -> Tuple[CorpusEntry, ...]: + """The songs the codec is measured on: each sample alone, and the arrangement at two lengths. + + Args: + instrument_catalog: The samples the integration suite reads. + integration_project: The arrangement those samples are played in. + + Returns: + Tuple[CorpusEntry, ...]: The samples first, then the arrangement, then the long one. + """ + return ( + *sample_entries(instrument_catalog, integration_project.settings), + arrangement_entry(ARRANGEMENT, integration_project), + arrangement_entry(LONG_ARRANGEMENT, lengthened_arrangement(integration_project, TARGET_SECONDS)), + ) diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py index e5aea3c4f..85863d0e5 100644 --- a/tests/integration/nsf/test_compression_report.py +++ b/tests/integration/nsf/test_compression_report.py @@ -8,26 +8,18 @@ from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project -from sampletones_core.project.settings import ProjectSettings -from sampletones_core.timing import SongTiming -from sampletones_player.builder import song_from_project, song_from_reconstruction from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.decode import decode_planes -from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import phrase_table from sampletones_player.compression.encode import STREAM_START, encode_planes from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.plane import parse_plane -from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.rebuild import streams_from_planes -from sampletones_player.compression.planes.separate import planes_from_streams from sampletones_player.compression.planes.song import SongPlanes -from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.driver.image import DriverImage from sampletones_player.registers.streams import ChannelStreams -from sampletones_player.song import Song from sampletones_player.specification.compression import ( MAX_LITERAL_BYTES, PLANE_COUNT, @@ -35,13 +27,9 @@ ) from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT from sampletones_player.specification.song import SONG_HEADER_SIZE -from sampletones_shared.music import Tuning +from tests.integration.nsf.corpus import LONG_ARRANGEMENT, CorpusEntry, build_corpus from tests.integration.nsf.report import ReportRow, write_csv, write_markdown -from tests.integration.nsf.songs import ( - RECORD_BYTES_PER_TICK, - available_bytes, - lengthened, -) +from tests.integration.nsf.songs import available_bytes from tests.integration.output import resolve_output_directory, resolve_output_path from tests.integration.paths import COMPRESSION_OUTPUT_ENV @@ -61,39 +49,10 @@ (SEARCH, CodecOptions(holds=True, phrases=True, transposition=True, search=True)), ) -ARRANGEMENT: Final[str] = "arrangement" -LONG_ARRANGEMENT: Final[str] = "arrangement, three minutes" -TARGET_SECONDS: Final[int] = 180 -MAX_ENCODER_SECONDS: Final[float] = 120.0 CSV_FILENAME: Final[str] = "report.csv" MARKDOWN_FILENAME: Final[str] = "report.md" -@dataclass(frozen=True) -class CorpusEntry: - """One song the report measures, alongside the phrases its own instruments offer.""" - - name: str - song: Song - seeds: Tuple[Phrase, ...] - tuning: Tuning - - @property - def pitches(self) -> PitchTable: - """The timer each pitch of the song sounds at.""" - return PitchTable.from_tuning(self.tuning) - - @property - def planes(self) -> SongPlanes: - """The eight planes the song separates into.""" - return planes_from_streams(self.song.streams, self.pitches) - - @property - def records(self) -> int: - """The bytes the song takes as one record per tick per channel.""" - return RECORD_BYTES_PER_TICK * self.song.ticks - - @dataclass(frozen=True) class Encoding: """One corpus song compressed under one variant of the codec.""" @@ -115,12 +74,6 @@ def streams(self) -> int: return sum(len(stream) for stream in self.compressed.streams) -def _sample_project(sample: Sample, settings: ProjectSettings) -> Project: - project = Project.create(settings=settings) - project.samples.append(sample) - return project - - def _register_planes(streams: ChannelStreams) -> Tuple[bytes, ...]: return tuple( bytes(tick.values[register] for tick in stream) @@ -155,38 +108,7 @@ def corpus( integration_project: Project, ) -> Tuple[CorpusEntry, ...]: """The songs the report measures: each sample alone, and the arrangement at two lengths.""" - tuning = Tuning() - entries: List[CorpusEntry] = [] - for name, sample in instrument_catalog.items(): - reconstruction = sample.reconstruction - entries.append( - CorpusEntry( - name=name, - song=song_from_reconstruction(reconstruction, loop_tick=None), - seeds=phrases_from_project( - _sample_project(sample, integration_project.settings), - reconstruction.config.library.tuning, - ), - tuning=reconstruction.config.library.tuning, - ) - ) - - groove = SongTiming.from_project(integration_project).groove() - frames = ceil(TARGET_SECONDS * integration_project.settings.nes_frequency / groove.total_ticks) - for name, project in ( - (ARRANGEMENT, integration_project), - (LONG_ARRANGEMENT, lengthened(integration_project, frames)), - ): - entries.append( - CorpusEntry( - name=name, - song=song_from_project(project, tuning, loop_tick=None), - seeds=phrases_from_project(project, tuning), - tuning=tuning, - ) - ) - - return tuple(entries) + return build_corpus(instrument_catalog, integration_project) @pytest.fixture(scope="module") @@ -348,18 +270,6 @@ def test_every_variant_of_every_song_undercuts_a_record_per_tick( for encoding in encodings: assert encoding.compressed.size < encoding.entry.records - def test_the_encoder_answers_within_the_budget_an_export_allows( - self, - encodings: Tuple[Encoding, ...], - ) -> None: - """The gate measures this under coverage, which multiplies the encoder's own cost tenfold. - - The budget is set against that reading, so it catches an encoder an export would wait on - rather than the ordinary drift of a machine under load. - """ - for encoding in encodings: - assert encoding.seconds < MAX_ENCODER_SECONDS - class TestTheReportStatesWhatEachLayerSaves: """The measurements the format's constants are settled from.""" From 6454308310740c32676c809a04fe1754fb0ebbe7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 16:31:04 +0200 Subject: [PATCH 053/142] Extracted: the stems list into one shared element --- docs/development/architecture.md | 4 +- docs/guide/interface.md | 2 +- .../categories/elements/global_.py | 14 + .../categories/elements/main.py | 6 - .../categories/hierarchy.py | 1 + .../coordinators/tabs/main.py | 1 + .../layout/general/__init__.py | 2 + .../layout/general/stems.py | 8 + .../layout/tabs/main/converter.py | 4 - .../logic/main/converter.py | 13 +- .../parameters/main.py | 3 + src/sampletones_application/tags/general.py | 4 + src/sampletones_application/tags/main.py | 19 +- .../ui/elements/context_menu.py | 28 ++ .../ui/elements/layout/well.py | 37 ++ .../ui/elements/stems/__init__.py | 0 .../ui/elements/stems/list.py | 420 ++++++++++++++++++ .../ui/elements/tree/tree.py | 16 +- .../ui/panels/main/converter.py | 274 ++---------- .../view_model/main/converter.py | 60 +-- .../view_model/shared/stems.py | 77 ++++ src/sampletones_config/lang/en.yaml | 19 +- .../layout/general/stems.yaml | 4 + .../layout/tabs/main/converter.yaml | 4 - .../logic/main/test_converter.py | 2 +- .../sampletones_application/test_startup.py | 48 +- .../ui/elements/stems/__init__.py | 0 .../ui/elements/stems/test_list.py | 279 ++++++++++++ .../view_model/main/test_converter.py | 16 +- 29 files changed, 1006 insertions(+), 359 deletions(-) create mode 100644 src/sampletones_application/layout/general/stems.py create mode 100644 src/sampletones_application/ui/elements/layout/well.py create mode 100644 src/sampletones_application/ui/elements/stems/__init__.py create mode 100644 src/sampletones_application/ui/elements/stems/list.py create mode 100644 src/sampletones_application/view_model/shared/stems.py create mode 100644 src/sampletones_config/layout/general/stems.yaml create mode 100644 tests/unit/sampletones_application/ui/elements/stems/__init__.py create mode 100644 tests/unit/sampletones_application/ui/elements/stems/test_list.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 126fcc46b..a0b3a3df9 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -205,7 +205,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m - A panel creates its entire widget tree in one call to `create_panel(parent)`, rooting its subtree at `self.tag` inside the coordinator-injected `parent`, and calls DPG afterwards only in `update_view()`, `update_*` methods, and event callbacks wired by DPG itself. - Panels hold only visual state: their tag, their child widget references, and layout dimensions. Domain objects stay in logic; panels receive projections of them. - A panel never encodes its own placement: it does not compose a column tag (`SUF_PANEL_*`) as its parent, and it never hosts a sibling panel. Tab layout is the coordinator's (see the Coordinators reference). Where a section is a card, one card is one panel is one module; the coordinator declares which cards a tab contains and how they are arranged. -- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — and the `card()` context manager binds SURFACE to a card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE. +- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — the `card()` context manager binds SURFACE to a card, and the `well()` context manager binds recessed GROUND to a region sunk inside one, so a list reads as one body rather than as content loose on its card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE. - Every mutation from outside goes through `update_view(view_model)` or through a direct DPG call (`dpg_configure_item`, `dpg_set_value`) triggered by an `update_*` method. - Callback wiring from coordinators sets public `on_x` attributes *after* construction; panels must therefore tolerate `None` hooks until wiring is complete. - A widget whose rendering needs synchronous per-item queries declares a consumer-owned `Protocol` of exactly that surface (e.g. `TreeLogicProtocol`, through which the file trees query per-node favorite and playability state); the owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. Hooks and view models remain the default — the Protocol is the exception for query-heavy widgets where projecting a whole tree per repaint would be disproportionate. @@ -216,7 +216,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m | Path | Role | |------|------| | `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar | -| `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold) and the `card()` context manager, driven declaratively by tab coordinators | +| `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold) and the `card()` and `well()` context managers, driven declaratively by tab coordinators | | `ui/panels/` | Domain-level composite panels, organised by feature area | | `ui/themes/` | DPG themes and per-widget style helpers | | `ui/resources/` | Icons and image resources loaded at startup | diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 0639ca960..9fab46ab8 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -40,7 +40,7 @@ The rows sit under **level** bands, and a level is a turn to choose: every recor on level 1 picks its channels before any on level 2, so a lead can take what it needs before a pad does. Drag a row by its handle onto another row to share that row's level, or onto the gap between two levels to give it a level of its own. -Right-clicking a row names the same moves in words. **Order** decides how the levels +Right-clicking a row names the same moves in words, alongside the recording's own actions — its name or path to the clipboard, and the file shown in your file manager. **Order** decides how the levels take turns — round by round, or one level filled before the next picks — and **x** takes a row out. Untick **Stems mode** and the first recording stays as your single selection. diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 4b8598e0b..de42507c8 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -55,6 +55,20 @@ class ContextElements(AbstractElement): SIZE_BYTES = "size_bytes" +class StemsElements(AbstractElement): + """The vocabulary of a stems list, shared by every card that draws one.""" + + LEVEL_CAPTION = "level_caption" + HANDLE = "handle" + HANDLE_TOOLTIP = "handle_tooltip" + REMOVE = "remove" + INERT_TOOLTIP = "inert_tooltip" + STATUS_ROW = "status_row" + STATUS_CHANNEL = "status_channel" + STATUS_REMOVE = "status_remove" + STATUS_HANDLE = "status_handle" + + class NodeDetailElements(AbstractElement): SAMPLE_RATE = "detail_sample_rate" NES_FREQUENCY = "detail_nes_frequency" diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index ead4bb350..7a7d4ca00 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -81,7 +81,6 @@ class ConverterElements(AbstractElement): HIERARCHY_MODE_TOOLTIP = "hierarchy_mode_tooltip" HIERARCHY_ROUND_ROBIN = "hierarchy_round_robin" HIERARCHY_STRICT = "hierarchy_strict" - STEM_REMOVE = "stem_remove" STEMS_EMPTY_HINT = "stems_empty_hint" CONVERT_STEMS_BUTTON = "convert_stems_button" DISCARD_STEMS_DIALOG = "discard_stems_dialog" @@ -92,12 +91,7 @@ class ConverterElements(AbstractElement): STEM_SELECTION_PROMPT = "stem_selection_prompt" STEM_SELECTION_LIMIT = "stem_selection_limit" ADD_STEMS_BUTTON = "add_stems_button" - STATUS_STEM_REMOVE = "status_stem_remove" STATUS_STEMS_MODE = "status_stems_mode" - STEM_LEVEL_CAPTION = "stem_level_caption" - STEM_HANDLE = "stem_handle" - STEM_HANDLE_TOOLTIP = "stem_handle_tooltip" - STEM_INERT_TOOLTIP = "stem_inert_tooltip" class ConverterStemMoveElements(AbstractElement): diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 8a2689e60..9110ffdd2 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -62,6 +62,7 @@ class Panel(StrEnum): TRACEBACK = auto() CONTEXT = auto() STATUS = auto() + STEMS = auto() GRAPH = auto() PITCH = auto() diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 8bf80a5a0..c96daeb11 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -205,6 +205,7 @@ def __init__( ) self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, + stems_layout=layout.stems, inputs=layout.inputs, path_colors=layout.path_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_CONVERTER_PANEL), diff --git a/src/sampletones_application/layout/general/__init__.py b/src/sampletones_application/layout/general/__init__.py index 4c793068d..1c09e6923 100644 --- a/src/sampletones_application/layout/general/__init__.py +++ b/src/sampletones_application/layout/general/__init__.py @@ -12,6 +12,7 @@ from sampletones_application.layout.general.responsive import ResponsiveLayout from sampletones_application.layout.general.section_header import SectionHeaderLayout from sampletones_application.layout.general.status_bar import StatusBarLayout +from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.layout.general.tables import TablesLayout from sampletones_application.layout.general.window import WindowLayout @@ -26,6 +27,7 @@ class GeneralLayout(BaseModel, extra="forbid", frozen=True): inputs: InputsLayout buttons: ButtonsLayout tables: TablesLayout + stems: StemsListLayout pitch_stepper: PitchStepperLayout plus_minus_buttons: PlusMinusButtonsLayout caret: CaretLayout diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py new file mode 100644 index 000000000..ab8728d8d --- /dev/null +++ b/src/sampletones_application/layout/general/stems.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class StemsListLayout(BaseModel, extra="forbid", frozen=True): + handle_width: int + channel_column_width: int + remove_button_width: int + level_strip_height: int diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index ee0e74bdd..003338189 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -6,9 +6,5 @@ class ConverterLayout(BaseModel, extra="forbid", frozen=True): width: int button_height: int - handle_width: int - channel_column_width: int - remove_button_width: int - level_strip_height: int stem_selection: Dimensions stem_selection_footer: int diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 8b1ae38ea..c8245dec7 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -28,8 +28,8 @@ ACTIVE_PHASES, ConversionPhase, ConverterViewModel, - StemSourceRow, ) +from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.configs import Config from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName, HierarchyMode @@ -493,11 +493,16 @@ def _update_stems_output_path(self) -> None: if sources: self._output_path = group_output_path(self._config_manager.config, sources) - def _stem_rows(self, config: Config) -> Tuple[StemSourceRow, ...]: - """The gathered recordings as the panel reads them, each stating where it stands.""" + def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: + """The gathered recordings as the panel reads them, each stating where it stands. + + A gathered recording is named by its path, so the list reports every gesture under the + path it landed on. + """ enabled = list(config.generation.channels) return tuple( - StemSourceRow( + StemRowViewModel( + key=str(source.path), path=source.path, channels=frozenset(effective_channels(source, enabled)), level=level_index, diff --git a/src/sampletones_application/parameters/main.py b/src/sampletones_application/parameters/main.py index ec602b45e..d7e9fa948 100644 --- a/src/sampletones_application/parameters/main.py +++ b/src/sampletones_application/parameters/main.py @@ -6,6 +6,7 @@ from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.layout.tabs.main import MainLayout from sampletones_application.parameters.geometry import TabGeometry from sampletones_application.ui.elements.tree.colors import TreeColors @@ -24,6 +25,7 @@ class MainTabParameters: config_height: int main: MainLayout inputs: InputsLayout + stems: StemsListLayout path_colors: PathColors tree_colors: TreeColors scheduling: SchedulingBehavior @@ -36,6 +38,7 @@ def from_config(cls, config: LayoutConfig) -> MainTabParameters: config_height=config.tabs.main.config.height, main=config.tabs.main, inputs=general.inputs, + stems=general.stems, path_colors=general.colors.paths, tree_colors=TreeColors.create( general.colors, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 1535236d4..77aa24852 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -729,3 +729,7 @@ SUF_COLLAPSE_BODY = compose_tag("collapse", "body") SUF_COLLAPSE_RAIL = compose_tag("collapse", "rail") SUF_COLLAPSE_CHEVRON = compose_tag("collapse", "chevron") +SUF_ROW = "row" +SUF_LEVEL = "level" +SUF_WELL = "well" +SUF_PAYLOAD = "payload" diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 3eaf3ac9c..3431e5065 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -292,12 +292,6 @@ Widget.WINDOW, "stems", ) -TAG_MAIN_CONVERTER_GROUP_STEMS = TagName( - Page.MAIN, - Panel.CONVERTER, - Widget.GROUP, - "stems", -) TAG_MAIN_CONVERTER_TEXT_STEMS_HINT = TagName( Page.MAIN, Panel.CONVERTER, @@ -316,11 +310,6 @@ Widget.WINDOW, "stem_selection", ) -PRE_MAIN_CONVERTER_STEM = compose_tag( - Page.MAIN, - Panel.CONVERTER, - "stem", -) TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT = TagName( Page.MAIN, Panel.CONVERTER, @@ -345,13 +334,13 @@ Widget.BUTTON, "cancel_stems", ) -PRE_MAIN_CONVERTER_CANDIDATE = compose_tag( +PRE_MAIN_CONVERTER_STEMS = compose_tag( Page.MAIN, Panel.CONVERTER, - "candidate", + "stems", ) -PRE_MAIN_CONVERTER_LEVEL = compose_tag( +PRE_MAIN_CONVERTER_CANDIDATE = compose_tag( Page.MAIN, Panel.CONVERTER, - "level", + "candidate", ) diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index e63e62f56..ca1710623 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -1,14 +1,17 @@ import contextlib +from pathlib import Path from typing import Iterator, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg +from sampletones_application.categories.manager import LanguageManager from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.system.paths import open_path_in_explorer @contextlib.contextmanager @@ -78,3 +81,28 @@ def add_detail_items( FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) if tooltip is not None: show_tooltip(detail_text, tooltip) + + +def add_path_menu_items( + language_manager: LanguageManager, + path: Path, +) -> None: + """Add the shared block of filesystem actions for ``path`` to the context menu being built. + + Every menu naming something on disk offers the same three: the name and the full path to the + clipboard, and the file revealed in the file manager. Routing the file browsers and the + converter's gathered recordings through one builder keeps them reading alike. + """ + dpg.add_separator() + dpg.add_menu_item( + label=language_manager["global.context.label.copy_filename"], + callback=lambda: dpg.set_clipboard_text(str(path.name)), + ) + dpg.add_menu_item( + label=language_manager["global.context.label.copy_path"], + callback=lambda: dpg.set_clipboard_text(str(path)), + ) + dpg.add_menu_item( + label=language_manager["global.context.label.open_in_explorer"], + callback=lambda: open_path_in_explorer(path), + ) diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py new file mode 100644 index 000000000..ae2e78408 --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -0,0 +1,37 @@ +from contextlib import contextmanager +from typing import Iterator + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import TAG_GLOBAL_THEME_PANEL_GROUND +from sampletones_application.ui.themes.registry import ThemeRegistry + + +@contextmanager +def well( + parent: str, + tag: str, + *, + height: int = 0, + show: bool = True, +) -> Iterator[str]: + """Open a recessed region inside a card and bind its depth theme. + + A well sinks a list below the card it sits on, the way a column of cards sits below the + tab around it, so a run of rows reads as one body rather than as content loose on the + card. Alongside ``card()`` this is where the recessed depth theme is bound; the region + sizes itself to its rows unless ``height`` reserves a footprint. + """ + with dpg.child_window( + tag=tag, + parent=parent, + width=-1, + height=height, + auto_resize_y=height == 0, + border=False, + no_scrollbar=True, + show=show, + ): + yield tag + + ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_GROUND).bind_to_item(tag) diff --git a/src/sampletones_application/ui/elements/stems/__init__.py b/src/sampletones_application/ui/elements/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py new file mode 100644 index 000000000..e1f711961 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -0,0 +1,420 @@ +from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_GROUP, + SUF_HANDLE, + SUF_HANDLER_REGISTRY, + SUF_LEVEL, + SUF_PAYLOAD, + SUF_ROW, + SUF_STRIP, + SUF_TABLE, + SUF_TEXT, + SUF_WELL, + TAG_GLOBAL_THEME_DANGER_BUTTON, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.layout.well import well +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_delete_item, + dpg_set_value, +) +from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import MessageCallback, StringCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +RowShape = Tuple[Tuple[str, ...], Tuple[Tuple[str, int, bool], ...]] + +ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] +KeyOffsetCallback = Callable[[str, int], None] +KeyPairCallback = Callable[[str, str], None] + + +class GUIStemsList(CallbackMixin): + """The stems of one setup, as a table of rows banded by the levels they pick on. + + Both the converter's gathered recordings and a reconstruction's recorded assignment are the + same list, so one definition draws them and each owner turns on the affordances it can + honour: ``draggable`` gives a row a handle and opens a drop strip between the bands, and + ``removable`` gives it the danger-toned button that takes it out. Rows are keyed by the + identity their owner reports gestures under, and every column lines up across the bands + because the table holds one fixed column per channel in play. + """ + + def __init__( + self, + *, + prefix: str, + layout: StemsListLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + draggable: bool, + removable: bool, + ) -> None: + self._prefix = prefix + self._layout = layout + self._language_manager = language_manager + self._status_bar = status_bar + self._draggable = draggable + self._removable = removable + + self._level_template = language_manager["global.stems.template.level_caption"] + self._lbl_handle = language_manager["global.stems.label.handle"] + self._lbl_remove = language_manager["global.stems.label.remove"] + self._msg_handle = language_manager["global.stems.message.handle_tooltip"] + self._msg_inert = language_manager["global.stems.message.inert_tooltip"] + + self._payload = compose_tag(prefix, SUF_PAYLOAD) + self._well_tag = compose_tag(prefix, SUF_WELL) + self._body_tag = compose_tag(prefix, SUF_GROUP) + self._name_handler_tag = compose_tag(prefix, SUF_TEXT, SUF_HANDLER_REGISTRY) + self._channel_handler_tag = compose_tag(prefix, SUF_CHANNELS, SUF_HANDLER_REGISTRY) + self._button_handler_tag = compose_tag(prefix, SUF_BUTTON, SUF_HANDLER_REGISTRY) + self._handle_handler_tag = compose_tag(prefix, SUF_HANDLE, SUF_HANDLER_REGISTRY) + + self._rows: Dict[str, StemRowViewModel] = {} + self._channels_in_play: Tuple[ChannelName, ...] = () + self._shape: RowShape = ((), ()) + self._live = True + + self.on_channels_changed: Optional[ChannelsCallback] = None + self.on_remove_requested: Optional[StringCallback] = None + self.on_menu_requested: Optional[StringCallback] = None + self.on_dropped_on_row: Optional[KeyPairCallback] = None + self.on_dropped_on_level: Optional[KeyOffsetCallback] = None + + @property + def _handler_tags(self) -> Tuple[str, ...]: + """The registries the rows bind to, one per widget kind the list draws.""" + shared = (self._name_handler_tag, self._channel_handler_tag, self._button_handler_tag) + return shared + (self._handle_handler_tag,) if self._draggable else shared + + @property + def tag(self) -> str: + """The recessed region the list is drawn in, which is what an owner shows and hides.""" + return self._well_tag + + def create(self, parent: str, *, show: bool = True) -> None: + """Build the list's recessed region and the handlers its rows share.""" + self._create_handlers() + with well(parent, self._well_tag, show=show): + dpg.add_group(tag=self._body_tag) + + def update_view(self, view_model: StemsListViewModel) -> None: + self._rows = {row.key: row for row in view_model.rows} + self._channels_in_play = view_model.channels_in_play + self._live = view_model.live + self._sync_rows(view_model) + if self._draggable: + for level_index in range(view_model.level_count + 1): + dpg_set_value(self.level_tag(level_index, SUF_STRIP), False) + + for row in view_model.rows: + self._render_row(row) + + def row(self, key: str) -> Optional[StemRowViewModel]: + """The row a gesture named, as the list last rendered it.""" + return self._rows.get(key) + + def _create_handlers(self) -> None: + """Register one handler registry per row-widget kind. + + A row's widgets carry their key as user data and share a registry with their kind, so + the hover explanation and the right-click both read the row they landed on rather than + needing a handler of their own. + """ + for handler_tag in self._handler_tags: + dpg_delete_item(handler_tag) + + with dpg.item_handler_registry(tag=self._name_handler_tag): + dpg.add_item_clicked_handler(callback=self._on_name_clicked) + dpg.add_item_hover_handler(callback=self._hover_callback(self._name_message)) + + with dpg.item_handler_registry(tag=self._channel_handler_tag): + dpg.add_item_hover_handler(callback=self._hover_callback(self._channel_message)) + + with dpg.item_handler_registry(tag=self._button_handler_tag): + dpg.add_item_hover_handler(callback=self._hover_callback(self._remove_message)) + + if self._draggable: + with dpg.item_handler_registry(tag=self._handle_handler_tag): + dpg.add_item_hover_handler(callback=self._hover_callback(self._handle_message)) + + def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender, int], None]: + """Route a hovered row widget's explanation to the status bar. + + An item hover handler names the hovered item, whose user data is the row it belongs to, + so one callback per widget kind explains every row of that kind. + """ + + def hover_callback(_sender: Sender, app_data: int) -> None: + self._status_bar.set(message_function, user_data=dpg.get_item_user_data(app_data)) + + return hover_callback + + def _sync_rows(self, view_model: StemsListViewModel) -> None: + """Rebuild the bands when the recordings or the levels change, keep them otherwise.""" + shape = self._row_shape(view_model) + if shape == self._shape: + return + + self._shape = shape + dpg.delete_item(self._body_tag, children_only=True) + for level_index in range(view_model.level_count): + self._create_level_strip(level_index) + self._create_level_caption(level_index) + self._create_level_table(level_index, view_model) + + if view_model.level_count: + self._create_level_strip(view_model.level_count) + + @staticmethod + def _row_shape(view_model: StemsListViewModel) -> RowShape: + """What the bands are built from: the channels in play, and where each row stands.""" + return ( + tuple(str(channel_name) for channel_name in view_model.channels_in_play), + tuple((row.key, row.level, row.takes_part) for row in view_model.rows), + ) + + def _create_level_strip(self, position: int) -> None: + """The gap a level is broken at: a recording dropped here takes a level of its own.""" + if not self._draggable: + return + + dpg.add_selectable( + label="", + tag=self.level_tag(position, SUF_STRIP), + parent=self._body_tag, + height=self._layout.level_strip_height, + user_data=position, + payload_type=self._payload, + drop_callback=self._on_dropped_on_level, + ) + + def _create_level_caption(self, level_index: int) -> None: + caption = dpg.add_text( + self._level_template.format(level_index + 1).upper(), + tag=self.level_tag(level_index, SUF_TEXT), + parent=self._body_tag, + ) + FontRegistry.bind_to_item(caption, Font.MONO_SMALL) + + def _create_level_table(self, level_index: int, view_model: StemsListViewModel) -> None: + with dpg.table( + tag=self.level_tag(level_index, SUF_TABLE), + parent=self._body_tag, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + ): + if self._draggable: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.handle_width) + + dpg.add_table_column(width_stretch=True) + for _channel_name in view_model.channels_in_play: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width) + + if self._removable: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) + + for row in view_model.rows: + if row.level == level_index: + self._create_row(row, view_model) + + def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + with dpg.table_row(tag=self.row_tag(row.key, SUF_GROUP)): + if self._draggable: + self._create_handle(row) + + self._create_name(row) + for channel_name in view_model.channels_in_play: + self._create_channel(row, channel_name) + + if self._removable: + self._create_remove(row) + + def _create_handle(self, row: StemRowViewModel) -> None: + handle = dpg.add_button( + label=self._lbl_handle, + tag=self.row_tag(row.key, SUF_HANDLE), + width=self._layout.handle_width, + user_data=row.key, + payload_type=self._payload, + drop_callback=self._on_dropped_on_row, + ) + with dpg.drag_payload(parent=handle, drag_data=row.key, payload_type=self._payload): + dpg.add_text(row.name) + + dpg.bind_item_handler_registry(handle, self._handle_handler_tag) + show_tooltip(handle, self._msg_handle) + + def _create_name(self, row: StemRowViewModel) -> None: + name = dpg.add_selectable( + label=row.name, + tag=self.row_tag(row.key, SUF_TEXT), + user_data=row.key, + payload_type=self._payload, + drop_callback=self._on_dropped_on_row, + ) + FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) + dpg.bind_item_handler_registry(name, self._name_handler_tag) + show_tooltip(name, self._row_explanation(row)) + + def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: + checkbox_tag = self.channel_tag(row.key, channel_name) + dpg.add_checkbox( + label=channel_label(self._language_manager, channel_name), + tag=checkbox_tag, + default_value=channel_name in row.channels, + user_data=(row.key, channel_name), + callback=self._on_channels_changed, + ) + ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(checkbox_tag) + dpg.bind_item_handler_registry(checkbox_tag, self._channel_handler_tag) + + def _create_remove(self, row: StemRowViewModel) -> None: + remove = dpg.add_button( + label=self._lbl_remove, + tag=self.row_tag(row.key, SUF_BUTTON), + width=self._layout.remove_button_width, + user_data=row.key, + callback=self._on_remove_requested, + ) + ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove) + dpg.bind_item_handler_registry(remove, self._button_handler_tag) + + def _render_row(self, row: StemRowViewModel) -> None: + for channel_name in self._channels_in_play: + tag = self.channel_tag(row.key, channel_name) + dpg_configure_item(tag, enabled=self._live) + dpg_set_value(tag, channel_name in row.channels) + + name_tag = self.row_tag(row.key, SUF_TEXT) + dpg_set_value(name_tag, False) + dpg_configure_item(name_tag, enabled=row.takes_part and self._live) + if self._draggable: + dpg_configure_item(self.row_tag(row.key, SUF_HANDLE), enabled=self._live) + + if self._removable: + dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live) + + def _row_explanation(self, row: StemRowViewModel) -> str: + """What the row's hover states: where the recording is, and where it holds no channel, why + it is greyed out.""" + if row.takes_part: + return str(row.path) + + return f"{row.path}\n{self._msg_inert}" + + def _name_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._rows.get(user_data) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_row"].format(name=row.name) + + def _channel_message( + self, + *_args: Any, + user_data: Tuple[str, ChannelName], + **_kwargs: Any, + ) -> str: + key, channel_name = user_data + row = self._rows.get(key) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_channel"].format( + channel=channel_label(self._language_manager, channel_name), + name=row.name, + ) + + def _remove_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._rows.get(user_data) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_remove"].format(name=row.name) + + def _handle_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._rows.get(user_data) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_handle"].format(name=row.name) + + def _on_channels_changed( + self, + _sender: Sender, + _value: bool, + user_data: Tuple[str, ChannelName], + ) -> None: + key, _channel_name = user_data + channels = frozenset( + channel_name + for channel_name in self._channels_in_play + if dpg.get_value(self.channel_tag(key, channel_name)) + ) + self.call(self.on_channels_changed, key, channels) + + def _on_remove_requested(self, _sender: Sender, _app_data: Any, user_data: str) -> None: + self.call(self.on_remove_requested, user_data) + + def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + key = dpg.get_item_user_data(clicked_item) + if isinstance(key, str): + self.call(self.on_menu_requested, key) + + def _on_dropped_on_row(self, sender: Sender, app_data: str) -> None: + """A recording was dropped on a row, so it joins that row's level at its place.""" + target = dpg.get_item_user_data(sender) + if isinstance(target, str): + self.call(self.on_dropped_on_row, app_data, target) + + def _on_dropped_on_level(self, sender: Sender, app_data: str) -> None: + """A recording was dropped in a gap, so it takes a level of its own there.""" + position = dpg.get_item_user_data(sender) + if isinstance(position, int): + self.call(self.on_dropped_on_level, app_data, position) + + def row_tag(self, key: str, suffix: str) -> str: + """The tag one of a row's widgets carries, which is how anything outside addresses it.""" + return compose_tag(self._prefix, SUF_ROW, key, suffix) + + def level_tag(self, level_index: int, suffix: str) -> str: + """The tag one of a band's widgets carries: its caption, its table, or the strip above it.""" + return compose_tag(self._prefix, SUF_LEVEL, str(level_index), suffix) + + def channel_tag(self, key: str, channel_name: ChannelName) -> str: + """The tag the box giving ``key`` a channel carries.""" + return compose_tag( + self._prefix, + SUF_ROW, + key, + SUF_CHANNELS, + compose_tag(channel_name, SUF_CHECKBOX), + ) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 30547bb31..563e55683 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -49,6 +49,7 @@ from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import ( add_detail_items, + add_path_menu_items, add_play_menu_item, ) from sampletones_application.ui.elements.fonts.font import Font @@ -110,7 +111,6 @@ PathCallback, VoidCallback, ) -from sampletones_shared.utils.system.paths import open_path_in_explorer NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset() @@ -832,19 +832,7 @@ def _add_context_menu_play_item(self, node: FileSystemNode) -> None: ) def _add_context_menu_path_items(self, path: Path) -> None: - dpg.add_separator() - dpg.add_menu_item( - label=self._language_manager["global.context.label.copy_filename"], - callback=lambda: dpg.set_clipboard_text(str(path.name)), - ) - dpg.add_menu_item( - label=self._language_manager["global.context.label.copy_path"], - callback=lambda: dpg.set_clipboard_text(str(path)), - ) - dpg.add_menu_item( - label=self._language_manager["global.context.label.open_in_explorer"], - callback=lambda: open_path_in_explorer(path), - ) + add_path_menu_items(self._language_manager, path) def _add_context_menu_sequencer_items(self, node: FileSystemNode) -> None: """Add the send-to-sequencer item, live while its host reports the sequencer accepts one.""" diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 2e56e34f9..75ffa8beb 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -3,39 +3,29 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import channel_label from sampletones_application.categories.elements.main import ConverterStemMoveElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.conversion import MIN_CHANNEL_CAP from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.layout.tabs.main.converter import ConverterLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( - SUF_BUTTON, - SUF_CHANNELS, - SUF_CHECKBOX, - SUF_GROUP, - SUF_HANDLE, SUF_HANDLER_REGISTRY, - SUF_STRIP, - SUF_TABLE, - SUF_TEXT, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_PANEL_EMPHASIS, TAG_GLOBAL_THEME_PRIMARY_BUTTON, ) from sampletones_application.tags.main import ( - PRE_MAIN_CONVERTER_LEVEL, - PRE_MAIN_CONVERTER_STEM, + PRE_MAIN_CONVERTER_STEMS, TAG_MAIN_CONVERTER_BUTTON_ACTION, TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, TAG_MAIN_CONVERTER_GROUP, TAG_MAIN_CONVERTER_GROUP_CONTROLS, TAG_MAIN_CONVERTER_GROUP_CONVERT, - TAG_MAIN_CONVERTER_GROUP_STEMS, TAG_MAIN_CONVERTER_GROUP_SUMMARY, TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, TAG_MAIN_CONVERTER_PANEL, @@ -52,14 +42,14 @@ TAG_MAIN_CONVERTER_WINDOW_STEMS, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import context_menu +from sampletones_application.ui.elements.context_menu import add_path_menu_items, context_menu from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_application.ui.elements.stems.list import GUIStemsList from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( @@ -76,16 +66,14 @@ from sampletones_application.view_model.main.converter import ( ConverterAction, ConverterViewModel, - StemSourceRow, ) +from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import PathCallback, VoidCallback -RowShape = Tuple[Tuple[str, ...], Tuple[Tuple[str, int, bool], ...]] PathOffsetCallback = Callable[[Path, int], None] -STEM_PAYLOAD: str = compose_tag(PRE_MAIN_CONVERTER_STEM, "payload") LEVEL_ABOVE: int = -1 LEVEL_BELOW: int = 1 POSITION_EARLIER: int = -1 @@ -97,13 +85,15 @@ class GUIConverterPanel(GUIPanel): In stems mode the card lists the recordings being gathered under the levels they pick on. A row is dragged by its handle onto another row to share that row's level, or onto the gap - between two levels to open one of its own; the row's menu names the same moves in words. + between two levels to open one of its own; the row's menu names the same moves in words and + offers the recording's own filesystem actions. """ def __init__( self, *, layout: ConverterLayout, + stems_layout: StemsListLayout, inputs: InputsLayout, path_colors: PathColors, initial_collapsed: bool = False, @@ -139,16 +129,19 @@ def __init__( self._msg_destination = language_manager["global.status.message.destination"] self._msg_status_convert = language_manager["main.converter.message.status_convert"] self._status_action_message = self._msg_status_convert - self._level_template = language_manager["main.converter.template.stem_level_caption"] self._hierarchy_labels: Dict[HierarchyMode, str] = { HierarchyMode.ROUND_ROBIN: language_manager["main.converter.label.hierarchy_round_robin"], HierarchyMode.STRICT: language_manager["main.converter.label.hierarchy_strict"], } self._settings_handler_tag = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_HANDLER_REGISTRY) - self._row_handler_tag = compose_tag(PRE_MAIN_CONVERTER_STEM, SUF_HANDLER_REGISTRY) - self._rows: Tuple[StemSourceRow, ...] = () - self._channels_in_play: Tuple[ChannelName, ...] = () - self._shape: RowShape = ((), ()) + self._stems_list = GUIStemsList( + prefix=PRE_MAIN_CONVERTER_STEMS, + layout=stems_layout, + language_manager=language_manager, + status_bar=status_bar, + draggable=True, + removable=True, + ) super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) @@ -170,6 +163,11 @@ def create_panel(self, parent: str) -> None: self._create_summary() self._create_conversion_status() + @property + def stems_list(self) -> GUIStemsList: + """The list the gathered recordings are drawn in, which is what addresses their widgets.""" + return self._stems_list + def is_visible(self) -> bool: return bool(dpg.get_item_configuration(self.tag)["show"]) @@ -184,9 +182,6 @@ def _create_handlers(self) -> None: with dpg.item_handler_registry(tag=self._settings_handler_tag): dpg.add_item_deactivated_after_edit_handler(callback=self._on_channel_cap_edited) - with dpg.item_handler_registry(tag=self._row_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_row_clicked) - def _update_visibility(self, view_model: ConverterViewModel) -> None: dpg_configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not view_model.has_input) @@ -292,7 +287,13 @@ def _create_stems_list(self) -> None: wrap=0, ) FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) - dpg.add_group(tag=TAG_MAIN_CONVERTER_GROUP_STEMS) + self._stems_list.create(TAG_MAIN_CONVERTER_WINDOW_STEMS) + + self._stems_list.on_channels_changed = self._on_source_channels_changed + self._stems_list.on_remove_requested = self._on_source_removed + self._stems_list.on_menu_requested = self._show_row_menu + self._stems_list.on_dropped_on_row = self._on_dropped_on_source + self._stems_list.on_dropped_on_level = self._on_dropped_on_level def _update_setup(self, view_model: ConverterViewModel) -> None: dpg_set_value(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, view_model.stems_mode) @@ -314,155 +315,7 @@ def _update_setup(self, view_model: ConverterViewModel) -> None: def _update_stems_list(self, view_model: ConverterViewModel) -> None: dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_STEMS, show=view_model.stems_mode) dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=view_model.source_count == 0) - self._rows = view_model.stem_sources - self._channels_in_play = view_model.channels_in_play - self._sync_stem_rows(view_model) - for level_index in range(view_model.level_count + 1): - dpg_set_value(self._level_tag(level_index, SUF_STRIP), False) - - for row in view_model.stem_sources: - self._render_stem_row(row, view_model) - - def _sync_stem_rows(self, view_model: ConverterViewModel) -> None: - """Rebuilds the bands when the recordings or the levels change, keeps them otherwise.""" - shape = self._row_shape(view_model) - if shape == self._shape: - return - - self._shape = shape - dpg.delete_item(TAG_MAIN_CONVERTER_GROUP_STEMS, children_only=True) - for level_index in range(view_model.level_count): - self._create_level_strip(level_index) - self._create_level_caption(level_index) - self._create_level_table(level_index, view_model) - - if view_model.level_count: - self._create_level_strip(view_model.level_count) - - @staticmethod - def _row_shape(view_model: ConverterViewModel) -> RowShape: - """What the bands are built from: the channels in play, and where each row stands.""" - return ( - tuple(str(channel_name) for channel_name in view_model.channels_in_play), - tuple((str(row.path), row.level, row.takes_part) for row in view_model.stem_sources), - ) - - def _create_level_strip(self, position: int) -> None: - """The gap a level is broken at: a recording dropped here takes a level of its own.""" - dpg.add_selectable( - label="", - tag=self._level_tag(position, SUF_STRIP), - parent=TAG_MAIN_CONVERTER_GROUP_STEMS, - height=self._layout.level_strip_height, - user_data=position, - payload_type=STEM_PAYLOAD, - drop_callback=self._on_dropped_on_level, - ) - - def _create_level_caption(self, level_index: int) -> None: - caption = dpg.add_text( - self._level_template.format(level_index + 1).upper(), - tag=self._level_tag(level_index, SUF_TEXT), - parent=TAG_MAIN_CONVERTER_GROUP_STEMS, - ) - FontRegistry.bind_to_item(caption, Font.MONO_SMALL) - - def _create_level_table(self, level_index: int, view_model: ConverterViewModel) -> None: - with dpg.table( - tag=self._level_tag(level_index, SUF_TABLE), - parent=TAG_MAIN_CONVERTER_GROUP_STEMS, - header_row=False, - policy=dpg.mvTable_SizingFixedFit, - resizable=False, - ): - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.handle_width) - dpg.add_table_column(width_stretch=True) - for _channel_name in view_model.channels_in_play: - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width) - - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) - for row in view_model.stem_sources: - if row.level == level_index: - self._create_stem_row(row, view_model) - - def _create_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: - with dpg.table_row(tag=self._row_tag(row.path, SUF_GROUP)): - self._create_row_handle(row) - self._create_row_name(row) - for channel_name in view_model.channels_in_play: - self._create_row_channel(row, channel_name) - - self._create_row_remove(row) - - def _create_row_handle(self, row: StemSourceRow) -> None: - handle = dpg.add_button( - label=self._language_manager["main.converter.label.stem_handle"], - tag=self._row_tag(row.path, SUF_HANDLE), - width=self._layout.handle_width, - user_data=row.path, - payload_type=STEM_PAYLOAD, - drop_callback=self._on_dropped_on_source, - ) - with dpg.drag_payload(parent=handle, drag_data=str(row.path), payload_type=STEM_PAYLOAD): - dpg.add_text(row.name) - - show_tooltip(handle, self._language_manager["main.converter.message.stem_handle_tooltip"]) - - def _create_row_name(self, row: StemSourceRow) -> None: - name = dpg.add_selectable( - label=row.name, - tag=self._row_tag(row.path, SUF_TEXT), - user_data=row.path, - payload_type=STEM_PAYLOAD, - drop_callback=self._on_dropped_on_source, - ) - FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) - dpg.bind_item_handler_registry(name, self._row_handler_tag) - show_tooltip(name, self._row_explanation(row)) - - def _create_row_channel(self, row: StemSourceRow, channel_name: ChannelName) -> None: - checkbox_tag = self._channel_tag(row.path, channel_name) - dpg.add_checkbox( - label=channel_label(self._language_manager, channel_name), - tag=checkbox_tag, - default_value=channel_name in row.channels, - user_data=row.path, - callback=self._on_source_channels_changed, - ) - ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(checkbox_tag) - - def _create_row_remove(self, row: StemSourceRow) -> None: - remove = dpg.add_button( - label=self._language_manager["main.converter.label.stem_remove"], - tag=self._row_tag(row.path, SUF_BUTTON), - width=self._layout.remove_button_width, - user_data=row.path, - callback=self._on_source_removed, - ) - self._status_bar.bind_to_item( - remove, - self._language_manager["main.converter.message.status_stem_remove"], - ) - - def _row_explanation(self, row: StemSourceRow) -> str: - """What the row's hover states: where the recording is, and where it holds no channel, why - it is greyed out.""" - if row.takes_part: - return str(row.path) - - return f"{row.path}\n{self._language_manager['main.converter.message.stem_inert_tooltip']}" - - def _render_stem_row(self, row: StemSourceRow, view_model: ConverterViewModel) -> None: - live = not view_model.is_active - for channel_name in view_model.channels_in_play: - tag = self._channel_tag(row.path, channel_name) - dpg_configure_item(tag, enabled=live) - dpg_set_value(tag, channel_name in row.channels) - - dpg_set_value(self._row_tag(row.path, SUF_TEXT), False) - dpg_configure_item(self._row_tag(row.path, SUF_TEXT), enabled=row.takes_part) - dpg_configure_item(self._row_tag(row.path, SUF_HANDLE), enabled=live) - dpg_configure_item(self._row_tag(row.path, SUF_BUTTON), enabled=live) + self._stems_list.update_view(view_model.stems_list) def _on_stems_mode_toggled(self, _sender: Sender, value: bool) -> None: self.call(self.on_stems_mode_changed, value) @@ -476,44 +329,22 @@ def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: self.call(self.on_hierarchy_mode_changed, hierarchy_mode) return - def _on_source_channels_changed(self, _sender: Sender, _value: bool, user_data: Path) -> None: - channels = frozenset( - channel_name - for channel_name in self._channels_in_play - if dpg.get_value(self._channel_tag(user_data, channel_name)) - ) - self.call(self.on_source_channels_changed, user_data, channels) - - def _on_source_removed(self, _sender: Sender, _app_data: Any, user_data: Path) -> None: - self.call(self.on_source_removed, user_data) - - def _on_dropped_on_source(self, sender: Sender, app_data: str) -> None: - """A recording was dropped on a row, so it joins that row's level at its place.""" - target = dpg.get_item_user_data(sender) - if isinstance(target, Path): - self.call(self.on_source_dropped_on_source, Path(app_data), target) - - def _on_dropped_on_level(self, sender: Sender, app_data: str) -> None: - """A recording was dropped in a gap, so it takes a level of its own there.""" - position = dpg.get_item_user_data(sender) - if isinstance(position, int): - self.call(self.on_source_dropped_on_level, Path(app_data), position) - - def _on_row_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: - mouse_button, clicked_item = app_data - if mouse_button != dpg.mvMouseButton_Right: - return + def _on_source_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: + self.call(self.on_source_channels_changed, Path(key), channels) + + def _on_source_removed(self, key: str) -> None: + self.call(self.on_source_removed, Path(key)) - path = dpg.get_item_user_data(clicked_item) - if isinstance(path, Path): - self._show_row_menu(path) + def _on_dropped_on_source(self, key: str, target_key: str) -> None: + self.call(self.on_source_dropped_on_source, Path(key), Path(target_key)) - def _row_for(self, path: Path) -> Optional[StemSourceRow]: - return next((row for row in self._rows if row.path == path), None) + def _on_dropped_on_level(self, key: str, position: int) -> None: + self.call(self.on_source_dropped_on_level, Path(key), position) - def _show_row_menu(self, path: Path) -> None: - """Names the moves the row can make, greying out the ones that would change nothing.""" - row = self._row_for(path) + def _show_row_menu(self, key: str) -> None: + """Names the moves the row can make, greying out the ones that would change nothing, + and offers the recording's own filesystem actions below them.""" + row = self._stems_list.row(key) if row is None: return @@ -528,7 +359,9 @@ def _show_row_menu(self, path: Path) -> None: callback=callback, ) - def _row_moves(self, row: StemSourceRow) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: + add_path_menu_items(self._language_manager, row.path) + + def _row_moves(self, row: StemRowViewModel) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: path = row.path return [ ( @@ -566,23 +399,6 @@ def _row_moves(self, row: StemSourceRow) -> List[Tuple[ConverterStemMoveElements def _label(self, element: ConverterStemMoveElements) -> str: return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] - @staticmethod - def _row_tag(path: Path, suffix: str) -> str: - return compose_tag(PRE_MAIN_CONVERTER_STEM, str(path), suffix) - - @staticmethod - def _level_tag(level_index: int, suffix: str) -> str: - return compose_tag(PRE_MAIN_CONVERTER_LEVEL, str(level_index), suffix) - - @classmethod - def _channel_tag(cls, path: Path, channel_name: ChannelName) -> str: - return compose_tag( - PRE_MAIN_CONVERTER_STEM, - str(path), - SUF_CHANNELS, - compose_tag(channel_name, SUF_CHECKBOX), - ) - def _create_action_button(self) -> None: self._theme_convert = ThemeRegistry.get(TAG_GLOBAL_THEME_PRIMARY_BUTTON) self._theme_cancel = ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 6ed4f8afc..ad190ed17 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -5,6 +5,10 @@ from pydantic import BaseModel from sampletones_application.view_model.shared.percent import format_percent +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) from sampletones_core.constants.enums import ChannelName, HierarchyMode @@ -38,51 +42,6 @@ class ConverterAction(StrEnum): ) -class StemSourceRow(BaseModel, frozen=True): - """One recording in the converter's stems list, as the panel renders it. - - A row states where it stands — the level it picks on, the place it takes among the - recordings sharing that level, and how many of each the list holds — so the moves the panel - offers grey themselves out from the row alone. - """ - - path: Path - channels: FrozenSet[ChannelName] - level: int - position: int - level_size: int - level_count: int - - @property - def name(self) -> str: - return self.path.name - - @property - def takes_part(self) -> bool: - """The recording holds a channel, so the conversion mixes it and gives it a stem.""" - return bool(self.channels) - - @property - def is_first_on_level(self) -> bool: - return self.position == 0 - - @property - def is_last_on_level(self) -> bool: - return self.position == self.level_size - 1 - - @property - def has_level_above(self) -> bool: - return self.level > 0 - - @property - def has_level_below(self) -> bool: - return self.level < self.level_count - 1 - - @property - def alone_on_level(self) -> bool: - return self.level_size == 1 - - class ConverterViewModel(BaseModel, frozen=True): """ An immutable snapshot of converter state that defines what the panel is allowed to know. @@ -101,7 +60,7 @@ class ConverterViewModel(BaseModel, frozen=True): is_file: bool other_operation_active: bool stems_mode: bool - stem_sources: Tuple[StemSourceRow, ...] + stem_sources: Tuple[StemRowViewModel, ...] enabled_channels: FrozenSet[ChannelName] channel_cap: int max_channel_cap: int @@ -142,6 +101,15 @@ def channels_in_play(self) -> Tuple[ChannelName, ...]: """ return tuple(channel_name for channel_name in ChannelName.items() if channel_name in self.enabled_channels) + @property + def stems_list(self) -> StemsListViewModel: + """The gathered recordings as the stems list draws them, inert while a conversion runs.""" + return StemsListViewModel( + rows=self.stem_sources, + channels_in_play=self.channels_in_play, + live=not self.is_active, + ) + @property def level_count(self) -> int: """How many levels the gathered recordings are spread over.""" diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py new file mode 100644 index 000000000..070627f62 --- /dev/null +++ b/src/sampletones_application/view_model/shared/stems.py @@ -0,0 +1,77 @@ +from pathlib import Path +from typing import FrozenSet, Tuple + +from pydantic import BaseModel + +from sampletones_core.constants.enums import ChannelName + + +class StemRowViewModel(BaseModel, frozen=True): + """One recording in a stems list, as the list renders it. + + A row states where it stands — the level it picks on, the place it takes among the + recordings sharing that level, and how many of each the list holds — so the moves a list + offers grey themselves out from the row alone. ``key`` is the identity the list reports a + gesture under: the recording's path where the list gathers files, the stem id where it + describes a recorded assignment. + """ + + key: str + path: Path + channels: FrozenSet[ChannelName] + level: int + position: int + level_size: int + level_count: int + + @property + def name(self) -> str: + """The recording's own name, which is what the row reads as.""" + return self.path.stem + + @property + def takes_part(self) -> bool: + """The recording holds a channel, so the list counts it in.""" + return bool(self.channels) + + @property + def is_first_on_level(self) -> bool: + return self.position == 0 + + @property + def is_last_on_level(self) -> bool: + return self.position == self.level_size - 1 + + @property + def has_level_above(self) -> bool: + return self.level > 0 + + @property + def has_level_below(self) -> bool: + return self.level < self.level_count - 1 + + @property + def alone_on_level(self) -> bool: + return self.level_size == 1 + + +class StemsListViewModel(BaseModel, frozen=True): + """What a stems list renders: the rows, the columns they line up in, and whether they answer.""" + + rows: Tuple[StemRowViewModel, ...] + channels_in_play: Tuple[ChannelName, ...] + live: bool + + @property + def row_count(self) -> int: + return len(self.rows) + + @property + def level_count(self) -> int: + """How many levels the listed recordings are spread over.""" + return max((row.level + 1 for row in self.rows), default=0) + + @property + def playing_count(self) -> int: + """How many of the listed recordings hold a channel.""" + return sum(1 for row in self.rows if row.takes_part) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 18b610694..47cf35af9 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -255,6 +255,19 @@ global.status.message.node_group: "Click to {expand_or_collapse} this group. Rig global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample. Right-click to open context menu." global.status.message.retuning_samples: "Retuning samples..." +# ============================================================================= +# Global — Stems +# ============================================================================= +global.stems.template.level_caption: "Level {}" +global.stems.label.handle: "::" +global.stems.label.remove: "x" +global.stems.message.handle_tooltip: "Drag onto another recording to share its level, or onto a gap to open a new one." +global.stems.message.inert_tooltip: "This recording holds no channel, so it takes no part." +global.stems.message.status_row: "{name} — right-click for the actions this recording answers." +global.stems.message.status_channel: "Give {name} the {channel} channel, or take it back." +global.stems.message.status_remove: "Take {name} out." +global.stems.message.status_handle: "Drag {name} onto a row to share its level, or onto a gap for a level of its own." + # ============================================================================= # Global — Player # ============================================================================= @@ -361,7 +374,6 @@ main.converter.label.channel_cap: "Channels per source" main.converter.label.hierarchy_mode: "Order" main.converter.label.hierarchy_round_robin: "Round robin" main.converter.label.hierarchy_strict: "Strict" -main.converter.label.stem_remove: "x" main.converter.label.convert_stems_button: "Convert stems" main.converter.label.discard_stems_button: "Keep the first" main.converter.label.keep_stems_button: "Stay in stems mode" @@ -372,21 +384,16 @@ main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a main.converter.message.stems_empty_hint: "Click recordings in the browser to gather the sources of one reconstruction." main.converter.message.discard_stems_prompt: "Leaving stems mode keeps the first recording and drops the rest. Continue?" main.converter.message.stem_selection_prompt: "Pick the recordings to add." -main.converter.message.status_stem_remove: "Take this recording out of the conversion." main.converter.message.status_stems_mode: "Mix several recordings into one reconstruction." main.converter.title.discard_stems_dialog: "Leave stems mode?" main.converter.title.stem_selection_dialog: "Add recordings" main.converter.template.stem_selection_limit: "Room for {} more of the {} recordings found." -main.converter.template.stem_level_caption: "Level {}" -main.converter.label.stem_handle: "::" main.converter.label.context_move_up: "Move up" main.converter.label.context_move_down: "Move down" main.converter.label.context_join_above: "Join the level above" main.converter.label.context_join_below: "Join the level below" main.converter.label.context_isolate: "Put on its own level" main.converter.label.context_remove_stem: "Remove from the conversion" -main.converter.message.stem_handle_tooltip: "Drag onto another recording to share its level, or onto a gap to open a new one." -main.converter.message.stem_inert_tooltip: "This recording holds no channel, so it takes no part in the conversion." # ============================================================================= # Main tab — Advanced diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml new file mode 100644 index 000000000..c22b344f0 --- /dev/null +++ b/src/sampletones_config/layout/general/stems.yaml @@ -0,0 +1,4 @@ +handle_width: 24 +channel_column_width: 90 +remove_button_width: 24 +level_strip_height: 6 diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index 50723d5ee..3f558a268 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -1,9 +1,5 @@ width: -1 button_height: 45 -handle_width: 24 -channel_column_width: 90 -remove_button_width: 24 -level_strip_height: 6 stem_selection: width: 420 height: 360 diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index e82a8aec5..cc28f0c43 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -558,7 +558,7 @@ def test_the_rows_reach_the_view_in_list_order(self, converter_logic: ConverterL view_model = self._emitted(converter_logic) - assert [row.name for row in view_model.stem_sources] == ["a.wav", "b.wav"] + assert [row.name for row in view_model.stem_sources] == ["a", "b"] assert view_model.stems_mode is True assert view_model.has_input is True diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 149936171..afcf97d11 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -24,7 +24,7 @@ TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, ) -from sampletones_application.ui.panels.main.converter import GUIConverterPanel +from sampletones_application.ui.elements.stems.list import GUIStemsList from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, @@ -415,6 +415,16 @@ def test_the_key_answers_while_a_field_is_edited(self, app: Application, tab: Ta assert app._shortcut_source.shortcut(TAB_SHORTCUT_IDS[tab]).field_transparent +def stems_list(app: Application) -> GUIStemsList: + """The converter card's stems list, which owns the tags its rows carry.""" + return app._main_tab._converter_panel.stems_list + + +def drop(tag: str, payload: str) -> None: + """Deliver ``payload`` to whatever ``tag`` accepts drops with, the way DearPyGui would.""" + dpg.get_item_configuration(tag)["drop_callback"](dpg.get_alias_id(tag), payload) + + class TestConverterStemsCard: """Gathering recordings paints the converter card: a row each, carrying what the reader set.""" @@ -434,8 +444,8 @@ def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Pa paths = self._gather(app, tmp_path, ["a.wav", "b.wav"]) for path in paths: - assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_GROUP)) - assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_BUTTON)) + assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP)) + assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_BUTTON)) def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None: """The row offers a checkbox per channel the configuration enables, ticked as the row holds it.""" @@ -446,16 +456,16 @@ def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Pat converter_logic.set_source_channels(path, frozenset({kept})) - assert dpg.get_value(GUIConverterPanel._channel_tag(path, kept)) is True - assert dpg.get_value(GUIConverterPanel._channel_tag(path, cleared)) is False + assert dpg.get_value(stems_list(app).channel_tag(str(path), kept)) is True + assert dpg.get_value(stems_list(app).channel_tag(str(path), cleared)) is False def test_removing_a_recording_takes_its_row_with_it(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) app._main_tab._converter_logic.remove_source(first) - assert not dpg.does_item_exist(GUIConverterPanel._row_tag(first, SUF_GROUP)) - assert dpg.does_item_exist(GUIConverterPanel._row_tag(second, SUF_GROUP)) + assert not dpg.does_item_exist(stems_list(app).row_tag(str(first), SUF_GROUP)) + assert dpg.does_item_exist(stems_list(app).row_tag(str(second), SUF_GROUP)) def test_leaving_stems_mode_hides_the_list(self, app: Application, tmp_path: Path) -> None: self._gather(app, tmp_path, ["a.wav"]) @@ -475,7 +485,7 @@ def test_the_list_stays_on_screen_while_a_conversion_runs(self, app: Application converter_logic._emit_view_model("running", 0.5) assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True - assert dpg.get_item_configuration(GUIConverterPanel._row_tag(path, SUF_BUTTON))["enabled"] is False + assert dpg.get_item_configuration(stems_list(app).row_tag(str(path), SUF_BUTTON))["enabled"] is False def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) @@ -483,28 +493,27 @@ def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> N converter_logic.isolate_source(second) - assert dpg.does_item_exist(GUIConverterPanel._level_tag(0, SUF_TABLE)) - assert dpg.does_item_exist(GUIConverterPanel._level_tag(1, SUF_TABLE)) - assert dpg.does_item_exist(GUIConverterPanel._level_tag(2, SUF_STRIP)) - assert dpg.get_item_parent(GUIConverterPanel._row_tag(first, SUF_GROUP)) == GUIConverterPanel._level_tag( + assert dpg.does_item_exist(stems_list(app).level_tag(0, SUF_TABLE)) + assert dpg.does_item_exist(stems_list(app).level_tag(1, SUF_TABLE)) + assert dpg.does_item_exist(stems_list(app).level_tag(2, SUF_STRIP)) + assert dpg.get_item_parent(stems_list(app).row_tag(str(first), SUF_GROUP)) == stems_list(app).level_tag( 0, SUF_TABLE ) - assert dpg.get_item_parent(GUIConverterPanel._row_tag(second, SUF_GROUP)) == GUIConverterPanel._level_tag( + assert dpg.get_item_parent(stems_list(app).row_tag(str(second), SUF_GROUP)) == stems_list(app).level_tag( 1, SUF_TABLE ) def test_a_row_carries_a_handle_to_drag_it_by(self, app: Application, tmp_path: Path) -> None: path = self._gather(app, tmp_path, ["a.wav"])[0] - assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_HANDLE)) + assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_HANDLE)) def test_dropping_a_recording_on_a_row_joins_that_rows_level(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) converter_logic = app._main_tab._converter_logic converter_logic.isolate_source(second) - panel = app._main_tab._converter_panel - panel._on_dropped_on_source(dpg.get_alias_id(GUIConverterPanel._row_tag(second, SUF_TEXT)), str(first)) + drop(stems_list(app).row_tag(str(second), SUF_TEXT), str(first)) assert converter_logic._levels.level_count == 1 @@ -512,8 +521,7 @@ def test_dropping_a_recording_in_a_gap_opens_a_level(self, app: Application, tmp first, _second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) converter_logic = app._main_tab._converter_logic - panel = app._main_tab._converter_panel - panel._on_dropped_on_level(dpg.get_alias_id(GUIConverterPanel._level_tag(1, SUF_STRIP)), str(first)) + drop(stems_list(app).level_tag(1, SUF_STRIP), str(first)) assert converter_logic._levels.level_count == 2 assert converter_logic._levels.level_of(first) == 1 @@ -537,5 +545,5 @@ def test_a_recording_holding_no_channel_greys_out_but_stays_listed( app._main_tab._converter_logic.set_source_channels(path, frozenset()) - assert dpg.does_item_exist(GUIConverterPanel._row_tag(path, SUF_GROUP)) - assert dpg.get_item_configuration(GUIConverterPanel._row_tag(path, SUF_TEXT))["enabled"] is False + assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP)) + assert dpg.get_item_configuration(stems_list(app).row_tag(str(path), SUF_TEXT))["enabled"] is False diff --git a/tests/unit/sampletones_application/ui/elements/stems/__init__.py b/tests/unit/sampletones_application/ui/elements/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py new file mode 100644 index 000000000..a92f98ba7 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -0,0 +1,279 @@ +from pathlib import Path +from typing import FrozenSet, Iterator, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_HANDLE, + SUF_LEVEL, + SUF_ROW, + SUF_STRIP, + SUF_TEXT, +) +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName + +ROOT_TAG = "test_root" +PREFIX = "test.stems" +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: + """Stands up the context, fonts and themes the list binds while it draws.""" + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +def build( + layout_config: LayoutConfig, + *, + draggable: bool = True, + removable: bool = True, +) -> GUIStemsList: + stems_list = GUIStemsList( + prefix=PREFIX, + layout=layout_config.general.stems, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + draggable=draggable, + removable=removable, + ) + with dpg.window(tag=ROOT_TAG): + stems_list.create(ROOT_TAG) + + return stems_list + + +def row( + name: str, + *, + channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + level: int = 0, + position: int = 0, + level_size: int = 1, + level_count: int = 1, +) -> StemRowViewModel: + path = Path(f"/audio/{name}.wav") + return StemRowViewModel( + key=str(path), + path=path, + channels=channels, + level=level, + position=position, + level_size=level_size, + level_count=level_count, + ) + + +def view(*rows: StemRowViewModel, live: bool = True) -> StemsListViewModel: + return StemsListViewModel(rows=rows, channels_in_play=CHANNELS, live=live) + + +def row_tag(entry: StemRowViewModel, suffix: str) -> str: + return compose_tag(PREFIX, SUF_ROW, entry.key, suffix) + + +def channel_tag(entry: StemRowViewModel, channel_name: ChannelName) -> str: + return compose_tag(PREFIX, SUF_ROW, entry.key, SUF_CHANNELS, compose_tag(channel_name, SUF_CHECKBOX)) + + +class TestRows: + def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert dpg.get_item_label(row_tag(bass, SUF_TEXT)) == "bass" + for channel_name in CHANNELS: + assert dpg.get_value(channel_tag(bass, channel_name)) + + def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass", channels=frozenset({ChannelName.PULSE1})) + + stems_list.update_view(view(bass)) + + assert dpg.get_value(channel_tag(bass, ChannelName.PULSE1)) + assert not dpg.get_value(channel_tag(bass, ChannelName.TRIANGLE)) + + def test_a_row_holding_no_channel_greys_out(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass", channels=frozenset()) + + stems_list.update_view(view(bass)) + + assert not dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) + + def test_rows_follow_a_changed_list(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass, lead = row("bass"), row("lead") + stems_list.update_view(view(bass, lead, live=True)) + + stems_list.update_view(view(lead)) + + assert not dpg.does_item_exist(row_tag(bass, SUF_TEXT)) + assert dpg.does_item_exist(row_tag(lead, SUF_TEXT)) + + def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert stems_list.row(bass.key) == bass + assert stems_list.row("nothing") is None + + +class TestLevels: + def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + rows = ( + row("bass", level=0, level_count=2), + row("drums", level=1, level_count=2), + ) + + stems_list.update_view(view(*rows)) + + assert dpg.get_value(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) == "LEVEL 1" + assert dpg.get_value(compose_tag(PREFIX, SUF_LEVEL, "1", SUF_TEXT)) == "LEVEL 2" + + def test_a_draggable_list_opens_a_strip_above_each_level_and_below_the_last( + self, dpg_context: None, layout_config + ) -> None: + stems_list = build(layout_config) + rows = ( + row("bass", level=0, level_count=2), + row("drums", level=1, level_count=2), + ) + + stems_list.update_view(view(*rows)) + + for position in range(3): + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, str(position), SUF_STRIP)) + + +class TestAffordances: + def test_a_draggable_list_gives_each_row_a_handle(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, draggable=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert dpg.does_item_exist(row_tag(bass, SUF_HANDLE)) + + def test_a_list_without_dragging_gives_no_handle_and_no_strip(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, draggable=False) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert not dpg.does_item_exist(row_tag(bass, SUF_HANDLE)) + assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_STRIP)) + + def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, removable=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert dpg.does_item_exist(row_tag(bass, SUF_BUTTON)) + + def test_a_list_without_removal_gives_no_button(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, removable=False) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert not dpg.does_item_exist(row_tag(bass, SUF_BUTTON)) + + +class TestGestures: + def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context: None, layout_config) -> None: + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config) + stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) + bass = row("bass") + stems_list.update_view(view(bass)) + + tag = channel_tag(bass, ChannelName.TRIANGLE) + dpg.set_value(tag, False) + dpg.get_item_callback(tag)(tag, False, dpg.get_item_user_data(tag)) + + assert reported == [(bass.key, frozenset({ChannelName.PULSE1}))] + + def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_config) -> None: + removed: List[str] = [] + stems_list = build(layout_config) + stems_list.on_remove_requested = removed.append + bass = row("bass") + stems_list.update_view(view(bass)) + + tag = row_tag(bass, SUF_BUTTON) + dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag)) + + assert removed == [bass.key] + + +class TestBusyState: + def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass, live=False)) + + assert not dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) + assert not dpg.is_item_enabled(row_tag(bass, SUF_HANDLE)) + assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + for channel_name in CHANNELS: + assert not dpg.is_item_enabled(channel_tag(bass, channel_name)) + + def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + stems_list.update_view(view(bass, live=False)) + + stems_list.update_view(view(bass, live=True)) + + assert dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) + assert dpg.is_item_enabled(row_tag(bass, SUF_HANDLE)) + assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 6e16b5e00..99cbee5d6 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -8,8 +8,8 @@ ConversionPhase, ConverterAction, ConverterViewModel, - StemSourceRow, ) +from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.constants.enums import ChannelName, HierarchyMode ENABLED_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( @@ -25,9 +25,11 @@ def _row( position: int = 0, level_size: int = 1, level_count: int = 1, -) -> StemSourceRow: - return StemSourceRow( - path=Path(f"/audio/{name}.wav"), +) -> StemRowViewModel: + path = Path(f"/audio/{name}.wav") + return StemRowViewModel( + key=str(path), + path=path, channels=channels, level=level, position=position, @@ -43,7 +45,7 @@ def _view_model( progress: float = 0.0, input_path: Optional[Path] = Path("/audio/sample.wav"), stems_mode: bool = False, - stem_sources: Tuple[StemSourceRow, ...] = (), + stem_sources: Tuple[StemRowViewModel, ...] = (), channel_cap: int = len(ENABLED_CHANNELS), max_sources: int = MAX_STEM_SOURCES, ) -> ConverterViewModel: @@ -183,8 +185,8 @@ def test_a_list_with_room_takes_another(self) -> None: assert view_model.can_add_source is True - def test_a_row_names_itself_by_its_file(self) -> None: - assert _row("bass").name == "bass.wav" + def test_a_row_names_itself_by_its_recording(self) -> None: + assert _row("bass").name == "bass" def test_a_row_holding_no_channel_offers_nothing_to_convert(self) -> None: view_model = _view_model( From 11980cccbb3d06b4cc1987eec2c0667dd03b7cf9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 16:55:15 +0200 Subject: [PATCH 054/142] Reshaped: the player's song around compressed channel planes --- docs/development/packages.md | 4 +- scripts/linux/dev/tests.sh | 8 +- scripts/windows/dev/tests.bat | 8 +- src/sampletones_config/boundaries/graphs.yaml | 4 +- src/sampletones_player/builder.py | 30 +++++- src/sampletones_player/compression/encode.py | 1 + src/sampletones_player/compression/options.py | 9 ++ .../compression/planes/separate.py | 31 +++++- src/sampletones_player/compression/song.py | 71 ++++++++++++++ src/sampletones_player/song.py | 70 +++++++++++++- tests/benchmarks/test_compression.py | 7 +- .../nsf/test_compression_report.py | 17 +++- tests/suite/player.py | 28 +++--- .../compression/test_encode.py | 42 ++++++-- .../compression/test_song.py | 96 +++++++++++++++++++ .../unit/sampletones_player/nsf/test_song.py | 6 +- tests/unit/sampletones_player/test_builder.py | 3 +- 17 files changed, 382 insertions(+), 53 deletions(-) create mode 100644 src/sampletones_player/compression/song.py create mode 100644 tests/unit/sampletones_player/compression/test_song.py diff --git a/docs/development/packages.md b/docs/development/packages.md index c19f2d67b..fd4e07f9e 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -85,8 +85,8 @@ them. | `clock/` | `PlaySchedule` and `FixedPointStep` — the engine ticks one play call advances a stream by | `specification/` | | `registers/` | The per-tick register values each channel plays, and the four streams together | `specification/` | | `compression/` | The planes a song separates into, the dictionary its tokens name, and the codec that reads them both ways | `specification/`, `registers/` | -| `song.py` | `Song` — the streams, the schedule and the loop point as one value | `clock/`, `registers/` | -| `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded and its rate scheduled | `song.py`, `registers/`, `clock/` | +| `song.py` | `Song` — the compressed planes, the timer table, the schedule and the loop point as one value | `clock/`, `registers/`, `compression/` | +| `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded, its planes compressed and its rate scheduled | `song.py`, `registers/`, `clock/`, `compression/` | | `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` | | `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | diff --git a/scripts/linux/dev/tests.sh b/scripts/linux/dev/tests.sh index ad698d7ef..46d4f865a 100755 --- a/scripts/linux/dev/tests.sh +++ b/scripts/linux/dev/tests.sh @@ -6,14 +6,14 @@ echo "Running doctests..." uv run python -m pytest src/ --doctest-modules --no-cov DOCTEST_EXIT=$? -echo "Running pytest with coverage..." -uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks -PYTEST_EXIT=$? - echo "Running benchmarks..." uv run python -m pytest tests/benchmarks --no-cov BENCHMARK_EXIT=$? +echo "Running pytest with coverage..." +uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks +PYTEST_EXIT=$? + if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]] || [[ $BENCHMARK_EXIT -ne 0 ]]; then echo "Tests failed." exit 1 diff --git a/scripts/windows/dev/tests.bat b/scripts/windows/dev/tests.bat index a449a0659..fd0d1659b 100644 --- a/scripts/windows/dev/tests.bat +++ b/scripts/windows/dev/tests.bat @@ -5,14 +5,14 @@ echo Running doctests... uv run python -m pytest src/ --doctest-modules --no-cov set DOCTEST_EXIT=%ERRORLEVEL% -echo Running pytest with coverage... -uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks -set PYTEST_EXIT=%ERRORLEVEL% - echo Running benchmarks... uv run python -m pytest tests/benchmarks --no-cov set BENCHMARK_EXIT=%ERRORLEVEL% +echo Running pytest with coverage... +uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks +set PYTEST_EXIT=%ERRORLEVEL% + if not %DOCTEST_EXIT%==0 ( echo Tests failed. exit /b 1 diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml index 3bf214023..9436b7e01 100644 --- a/src/sampletones_config/boundaries/graphs.yaml +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -18,8 +18,8 @@ player: clock: [specification] registers: [specification] compression: [specification, registers] - song.py: [clock, registers] - builder.py: [song.py, registers, clock] + song.py: [clock, registers, compression] + builder.py: [song.py, registers, clock, compression] trace: [song.py, specification] nsf: [song.py, registers, specification, driver] export.py: [builder.py, nsf, driver] diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 8f40693f5..c08e297d8 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -1,4 +1,4 @@ -from typing import Dict, Final, Mapping, Optional, Sequence +from typing import Dict, Final, Mapping, Optional, Sequence, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP @@ -9,12 +9,16 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers from sampletones_player.registers.streams import ChannelStreams from sampletones_player.song import Song from sampletones_shared.music import Tuning SONG_START: Final[int] = 0 +NO_SEEDS: Final[Tuple[Phrase, ...]] = () def streams_from_instructions( @@ -54,6 +58,9 @@ def song_from_reconstruction( built against become timers through the very table its generators render from, and the rate it was built at becomes the schedule the driver re-clocks the streams by. + A reconstruction sounds each of its slices once, so the search fills the dictionary from what + the streams themselves repeat. + Args: reconstruction: The reconstruction to play. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. @@ -65,13 +72,16 @@ def song_from_reconstruction( TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ - return Song( + tuning = reconstruction.config.tuning + return Song.from_streams( streams=streams_from_instructions( reconstruction.instructions, - get_timer_table(reconstruction.config.tuning), + get_timer_table(tuning), ), + pitches=PitchTable.from_tuning(tuning), schedule=PlaySchedule.from_parameters(reconstruction.config.nes_frequency), loop_tick=loop_tick, + seeds=NO_SEEDS, ) @@ -129,6 +139,9 @@ def song_from_sample(request: SampleExport) -> Song: both halves of what that takes: the tuning its pitches are measured from, and the rate its envelopes advance at, which the driver re-clocks to the rate the console calls it at. + The request sounds each of its slices once, so the search fills the dictionary from what the + streams themselves repeat. + Args: request: The slices to play together. @@ -139,13 +152,15 @@ def song_from_sample(request: SampleExport) -> Song: TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If two slices name the same channel. """ - return Song( + return Song.from_streams( streams=streams_from_instructions( instructions_from_instruments(request.instruments), get_timer_table(request.tuning), ), + pitches=PitchTable.from_tuning(request.tuning), schedule=PlaySchedule.from_parameters(request.nes_frequency), loop_tick=loop_tick_from_instruments(request.instruments), + seeds=NO_SEEDS, ) @@ -161,6 +176,9 @@ def song_from_project( walk the sequencer sounds a song through, read as register values instead of audio. The project states the rate the driver re-clocks those ticks by. + A row plays a sample the project already holds, so the samples themselves seed the dictionary + and every row naming one reaches the stream as a token naming that entry. + Args: project: The project whose song is played. tuning: Where concert pitch sits, which decides the timer each pitch sounds at. @@ -173,11 +191,13 @@ def song_from_project( TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ - return Song( + return Song.from_streams( streams=streams_from_instructions( song_instructions(project), get_timer_table(tuning), ), + pitches=PitchTable.from_tuning(tuning), schedule=PlaySchedule.from_parameters(project.settings.nes_frequency), loop_tick=loop_tick, + seeds=phrases_from_project(project, tuning), ) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index 0f47db2f2..652092333 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -112,6 +112,7 @@ def _settle( def encode_planes( planes: SongPlanes, seeds: Sequence[Phrase], + *, options: CodecOptions, boundaries: FrozenSet[int], ) -> CompressedPlanes: diff --git a/src/sampletones_player/compression/options.py b/src/sampletones_player/compression/options.py index 002951b8f..be3e971ad 100644 --- a/src/sampletones_player/compression/options.py +++ b/src/sampletones_player/compression/options.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Final @dataclass(frozen=True) @@ -21,3 +22,11 @@ class CodecOptions: phrases: bool transposition: bool search: bool + + +EVERY_LAYER: Final[CodecOptions] = CodecOptions( + holds=True, + phrases=True, + transposition=True, + search=True, +) diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py index 584b7ac88..d79cc21c6 100644 --- a/src/sampletones_player/compression/planes/separate.py +++ b/src/sampletones_player/compression/planes/separate.py @@ -14,16 +14,28 @@ SECOND_VALUE_INDEX: Final[int] = 2 +def _pitch_indices( + registers: Sequence[ChannelRegisters], + indices: Dict[int, int], +) -> bytes: + try: + return bytes( + indices[tick.values[FIRST_VALUE_INDEX] | (tick.values[SECOND_VALUE_INDEX] << TIMER_HIGH_SHIFT)] + for tick in registers + ) + except KeyError as error: + raise ValueError(f"a channel sounds timer {error.args[0]}, which no pitch of the table sounds") from error + + def _tone_planes( registers: Sequence[ChannelRegisters], indices: Dict[int, int], ) -> ChannelPlanes: control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers) - value = bytes( - indices[tick.values[FIRST_VALUE_INDEX] | (tick.values[SECOND_VALUE_INDEX] << TIMER_HIGH_SHIFT)] - for tick in registers + return ChannelPlanes( + control=control, + value=_pitch_indices(registers, indices), ) - return ChannelPlanes(control=control, value=value) def _noise_planes(registers: Sequence[ChannelRegisters]) -> ChannelPlanes: @@ -46,6 +58,9 @@ def channel_planes( Returns: ChannelPlanes: The channel's control and value planes. + + Raises: + ValueError: If a tone channel sounds a timer the pitch table states no index for. """ if channel in TONE_CHANNELS: return _tone_planes(registers, pitches.indices) @@ -53,7 +68,10 @@ def channel_planes( return _noise_planes(registers) -def planes_from_streams(streams: ChannelStreams, pitches: PitchTable) -> SongPlanes: +def planes_from_streams( + streams: ChannelStreams, + pitches: PitchTable, +) -> SongPlanes: """Separates a song's four streams into the eight planes the codec compresses. Every channel is carried to the song's full length first, so the eight planes cover the same @@ -65,6 +83,9 @@ def planes_from_streams(streams: ChannelStreams, pitches: PitchTable) -> SongPla Returns: SongPlanes: The eight planes, two per channel. + + Raises: + ValueError: If a tone channel sounds a timer the pitch table states no index for. """ indices = pitches.indices pulse1, pulse2, triangle, noise = streams.padded diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py new file mode 100644 index 000000000..6e09e6572 --- /dev/null +++ b/src/sampletones_player/compression/song.py @@ -0,0 +1,71 @@ +from typing import FrozenSet, Optional, Sequence + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.encode import encode_planes +from sampletones_player.compression.options import EVERY_LAYER +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.rebuild import streams_from_planes +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.registers.streams import ChannelStreams + + +def _entries(loop_tick: Optional[int]) -> FrozenSet[int]: + if loop_tick is None: + return frozenset() + + return frozenset({loop_tick}) + + +def compress_song( + streams: ChannelStreams, + pitches: PitchTable, + *, + seeds: Sequence[Phrase], + loop_tick: Optional[int] = None, +) -> CompressedPlanes: + """Compresses a song's four register streams into the dictionary and streams a file carries. + + A song that repeats re-enters its streams partway through, so the tick it returns to holds a + token of its own on every plane: what the driver needs to resume there is a source pointer, + and a token that leans on the value a plane already reached would need the run that led to it. + + Args: + streams: The per-tick register values every channel plays. + pitches: The timer each pitch sounds at, which is what turns a timer into an index. + seeds: The phrases the song's instruments offer the dictionary. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + + Returns: + CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. + + Raises: + ValueError: If a stream sounds a timer the pitch table states no index for. + """ + return encode_planes( + planes_from_streams(streams, pitches), + seeds, + options=EVERY_LAYER, + boundaries=_entries(loop_tick), + ) + + +def decompress_song( + planes: CompressedPlanes, + pitches: PitchTable, +) -> ChannelStreams: + """Plays a song's token streams back into the register values every channel writes. + + This is the reading the driver performs, stated where it is testable: the trace the assembly + is held against is taken from it, so what the console plays and what the file holds are the + same values. + + Args: + planes: The dictionary, the eight token streams and the ticks the song lasts. + pitches: The timer each pitch sounds at, which is what turns an index back into a timer. + + Returns: + ChannelStreams: The per-tick register values every channel plays. + """ + return streams_from_planes(decode_planes(planes), pitches) diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index 8cebc8f90..535ce63d4 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -1,28 +1,79 @@ from __future__ import annotations -from typing import Optional +from functools import cached_property +from typing import Optional, Sequence from pydantic import BaseModel, ConfigDict, model_validator from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.song import compress_song, decompress_song from sampletones_player.registers.streams import ChannelStreams class Song(BaseModel): - """A reconstruction as the player holds it: the four streams, the clock, and where it repeats. + """A song as the console holds it: compressed channel planes, a timer table and a clock. + + A file carries a song as eight token streams over one dictionary, and this is that song, so + what the player holds and what the console reads are the same value. The register values each + channel writes are read back out of the streams, the timer table turning a plane's pitch index + into the divider the hardware takes. Attributes: - streams: The per-tick register values every channel plays. + planes: The dictionary and the eight token streams the channels play. + pitches: The timer each pitch sounds at. schedule: The engine ticks each play call advances the streams by. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. """ model_config = ConfigDict(extra="forbid", frozen=True) - streams: ChannelStreams + planes: CompressedPlanes + pitches: PitchTable schedule: PlaySchedule loop_tick: Optional[int] + @classmethod + def from_streams( + cls, + streams: ChannelStreams, + pitches: PitchTable, + *, + schedule: PlaySchedule, + loop_tick: Optional[int], + seeds: Sequence[Phrase], + ) -> Song: + """Compresses the register values a song plays into the song the console holds. + + Args: + streams: The per-tick register values every channel plays. + pitches: The timer each pitch sounds at. + schedule: The engine ticks each play call advances the streams by. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops + there. + seeds: The phrases the song's instruments offer the dictionary. + + Returns: + Song: The song as the console holds it. + + Raises: + ValueError: If ``loop_tick`` lies outside the song's ticks, or a channel sounds a + timer the pitch table states no index for. + """ + return cls( + planes=compress_song( + streams, + pitches, + seeds=seeds, + loop_tick=loop_tick, + ), + pitches=pitches, + schedule=schedule, + loop_tick=loop_tick, + ) + @model_validator(mode="after") def _validate_the_loop_lies_within_the_song(self) -> Song: if self.loop_tick is not None and not 0 <= self.loop_tick < self.ticks: @@ -33,7 +84,16 @@ def _validate_the_loop_lies_within_the_song(self) -> Song: @property def ticks(self) -> int: """The ticks the song lasts.""" - return self.streams.ticks + return self.planes.ticks + + @cached_property + def streams(self) -> ChannelStreams: + """The per-tick register values every channel plays, read back out of the token streams. + + Every channel is carried to the song's full length, so one whose own instructions ran out + first holds the silent values it stopped on through the ticks that remain. + """ + return decompress_song(self.planes, self.pitches) def tick_at(self, play_call: int) -> Optional[int]: """The tick the call at ``play_call`` leaves the streams on. diff --git a/tests/benchmarks/test_compression.py b/tests/benchmarks/test_compression.py index 4494215c0..bc6a1d0db 100644 --- a/tests/benchmarks/test_compression.py +++ b/tests/benchmarks/test_compression.py @@ -42,5 +42,10 @@ def test_a_three_minute_song_encodes_within_the_budget( """The bound stands where an export would keep the user waiting, against five seconds today.""" planes = long_arrangement.planes started = process_time() - encode_planes(planes, long_arrangement.seeds, EVERY_LAYER, frozenset()) + encode_planes( + planes, + long_arrangement.seeds, + options=EVERY_LAYER, + boundaries=frozenset(), + ) assert process_time() - started < MAX_ENCODER_SECONDS diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py index 85863d0e5..45cc4bf71 100644 --- a/tests/integration/nsf/test_compression_report.py +++ b/tests/integration/nsf/test_compression_report.py @@ -99,7 +99,15 @@ def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int: matcher = PhraseMatcher(phrase_table(())) entries = frozenset({STREAM_START}) - return sum(parse_plane(PlaneIndex.from_plane(plane), matcher, options, entries).size for plane in planes) + return sum( + parse_plane( + PlaneIndex.from_plane(plane), + matcher, + options, + entries, + ).size + for plane in planes + ) @pytest.fixture(scope="module") @@ -119,7 +127,12 @@ def encodings(corpus: Tuple[CorpusEntry, ...]) -> Tuple[Encoding, ...]: planes = entry.planes for variant, options in PLANE_VARIANTS: started = process_time() - compressed = encode_planes(planes, entry.seeds, options, frozenset()) + compressed = encode_planes( + planes, + entry.seeds, + options=options, + boundaries=frozenset(), + ) encoded.append( Encoding( entry=entry, diff --git a/tests/suite/player.py b/tests/suite/player.py index 4af2daf58..2b564c11b 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -6,13 +6,13 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH from sampletones_core.exporters import Features from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.reconstructions import Reconstruction -from sampletones_core.timers.arithmetic import frequency_to_timer +from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.compression.pitch import PitchTable from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.streams import ChannelStreams @@ -28,12 +28,18 @@ TRIANGLE_SILENT_RELOAD, TRIANGLE_SOUNDING_RELOAD, ) +from sampletones_shared.constants.music import OCTAVE_SEMITONES from sampletones_shared.music import Tuning -from sampletones_shared.utils.frequencies import pitch_to_frequency from tests.suite.stems import single_entry_stems_data -PLAYER_REFERENCE_TIMER: Final[int] = 0x154 -PLAYER_OCTAVE_UP_TIMER: Final[int] = PLAYER_REFERENCE_TIMER // 2 +PLAYER_TUNING: Final[Tuning] = Tuning() +PLAYER_PITCHES: Final[PitchTable] = PitchTable.from_tuning(PLAYER_TUNING) +PLAYER_TIMER_TABLE: Final[Dict[int, int]] = get_timer_table(PLAYER_TUNING) + +PLAYER_REFERENCE_PITCH: Final[int] = 57 +PLAYER_OCTAVE_UP_PITCH: Final[int] = PLAYER_REFERENCE_PITCH + OCTAVE_SEMITONES +PLAYER_REFERENCE_TIMER: Final[int] = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] +PLAYER_OCTAVE_UP_TIMER: Final[int] = PLAYER_TIMER_TABLE[PLAYER_OCTAVE_UP_PITCH] PLAYER_REFERENCE_PERIOD: Final[int] = 0x0A PLAYER_FULL_VOLUME: Final[int] = 15 PLAYER_SILENT_VOLUME: Final[int] = 0 @@ -103,17 +109,16 @@ def player_song( nes_frequency: int, loop_tick: Optional[int], ) -> Song: - return Song( + """A song the console plays those streams as, compressed the way an exported one is.""" + return Song.from_streams( streams=streams, + pitches=PLAYER_PITCHES, schedule=PlaySchedule.from_parameters(nes_frequency), loop_tick=loop_tick, + seeds=(), ) -PLAYER_TIMER_TABLE: Final[Dict[int, int]] = { - pitch: frequency_to_timer(pitch_to_frequency(pitch)) for pitch in range(MIN_PITCH, MAX_PITCH + 1) -} -PLAYER_REFERENCE_PITCH: Final[int] = 69 PLAYER_PULSE_TIMER_MUTE_FLOOR: Final[int] = 8 @@ -158,9 +163,6 @@ def player_reconstruction( ) -PLAYER_TUNING: Final[Tuning] = Tuning() - - def player_features( frames: int, pitch: int, diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index 19767d592..f426620a8 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -70,13 +70,23 @@ class TestEncodingASong: def test_a_song_plays_back_as_the_planes_it_was_written_from(self) -> None: planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) - compressed = encode_planes(planes, (), EVERY_LAYER, NO_BOUNDARIES) + compressed = encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + ) assert decode_planes(compressed) == planes def test_the_figure_the_song_repeats_reaches_the_dictionary(self) -> None: """The search states the figure at whatever length pays best, the motif being its unit.""" planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS) - compressed = encode_planes(planes, (), EVERY_LAYER, NO_BOUNDARIES) + compressed = encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + ) assert compressed.phrases.phrases for phrase in compressed.phrases.phrases: assert phrase.body == MOTIF * (len(phrase.body) // len(MOTIF)) @@ -84,22 +94,42 @@ def test_the_figure_the_song_repeats_reaches_the_dictionary(self) -> None: def test_a_seed_the_song_never_leans_on_leaves_the_dictionary(self) -> None: """A phrase earns its entry by sparing more than the entry costs.""" planes = song_planes(bytes((0x30,)) * len(MOTIF), MOTIF) - compressed = encode_planes(planes, (Phrase(body=MOTIF),), SEEDED, NO_BOUNDARIES) + compressed = encode_planes( + planes, + (Phrase(body=MOTIF),), + options=SEEDED, + boundaries=NO_BOUNDARIES, + ) assert compressed.phrases.phrases == () def test_the_figure_played_most_takes_the_cheapest_id(self) -> None: planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS) seeds: Tuple[Phrase, ...] = (Phrase(body=bytes((0x30,)) * 8), Phrase(body=MOTIF)) - compressed = encode_planes(planes, seeds, SEEDED, NO_BOUNDARIES) + compressed = encode_planes( + planes, + seeds, + options=SEEDED, + boundaries=NO_BOUNDARIES, + ) assert compressed.phrases[0] == Phrase(body=MOTIF) def test_a_song_re_entered_at_a_boundary_still_plays_back_whole(self) -> None: planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) - compressed = encode_planes(planes, (), EVERY_LAYER, frozenset({len(MOTIF) * 3})) + compressed = encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=frozenset({len(MOTIF) * 3}), + ) assert decode_planes(compressed) == planes def test_every_plane_of_the_song_carries_a_stream(self) -> None: planes = song_planes(bytes((0x30,)) * 4, MOTIF) - compressed = encode_planes(planes, (), EVERY_LAYER, NO_BOUNDARIES) + compressed = encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + ) assert len(compressed.streams) == len(planes.planes) assert compressed.ticks == planes.ticks diff --git a/tests/unit/sampletones_player/compression/test_song.py b/tests/unit/sampletones_player/compression/test_song.py new file mode 100644 index 000000000..133055fda --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_song.py @@ -0,0 +1,96 @@ +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.song import compress_song, decompress_song +from sampletones_player.registers.streams import ChannelStreams +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_PITCHES, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + pulse_tick, + resting_streams, +) + +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + +HELD_TICKS: Final[int] = 12 +MIDDLE_TICK: Final[int] = 6 +UNSOUNDED_TIMER: Final[int] = 0x154 +NO_SEEDS: Final[Tuple[Phrase, ...]] = () + + +def played( + streams: ChannelStreams, + loop_tick: Optional[int] = None, +) -> ChannelStreams: + return decompress_song( + compress_song( + streams, + PLAYER_PITCHES, + seeds=NO_SEEDS, + loop_tick=loop_tick, + ), + PLAYER_PITCHES, + ) + + +class TestTheSongPlaysBackTheRegistersItWasCompressedFrom: + """What the codec is held to at the song level: every channel writes what it was given.""" + + def test_every_tick_writes_the_registers_it_was_given(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + rebuilt = played(streams) + assert [rebuilt.at(tick) for tick in range(streams.ticks)] == [ + streams.at(tick) for tick in range(streams.ticks) + ] + + def test_a_channel_running_out_early_is_carried_to_the_songs_length(self) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + rebuilt = played(streams) + assert len(rebuilt.noise) == streams.ticks + assert set(rebuilt.noise) == {streams.noise[0]} + + +class TestASongThatRepeatsReEntersItsStreams: + """A loop returns partway through, so the tick it returns to starts a token of its own.""" + + def test_the_tick_a_song_returns_to_costs_the_streams_a_token(self) -> None: + """Splitting a run at the loop tick is what leaves the driver a token to resume on.""" + streams = resting_streams((SOUNDING,) * HELD_TICKS) + looped = compress_song( + streams, + PLAYER_PITCHES, + seeds=NO_SEEDS, + loop_tick=MIDDLE_TICK, + ) + played_once = compress_song( + streams, + PLAYER_PITCHES, + seeds=NO_SEEDS, + loop_tick=None, + ) + assert looped.size > played_once.size + + def test_the_song_writes_the_same_registers_either_way(self) -> None: + streams = resting_streams((SOUNDING,) * HELD_TICKS) + assert played(streams, MIDDLE_TICK) == played(streams) + + +class TestAPlaneNamesPitchesTheTableSounds: + """A tone channel reaches a plane as pitch indices, so its timers are the table's own.""" + + def test_a_timer_no_pitch_sounds_is_refused(self) -> None: + streams = resting_streams((pulse_tick(PLAYER_FULL_VOLUME, 0, UNSOUNDED_TIMER),)) + with pytest.raises(ValueError, match=str(UNSOUNDED_TIMER)): + compress_song( + streams, + PLAYER_PITCHES, + seeds=NO_SEEDS, + loop_tick=None, + ) diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 0bf3acdf1..1d6358bc6 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -65,9 +65,9 @@ class TestSongBytes: b"\x02\x00" b"\xff\xff" b"\x0f\x00\x15\x00\x1b\x00\x21\x00" - b"\x3f\x54\x01\x30\x54\x01" - b"\x30\x54\x01\x30\x54\x01" - b"\x80\x54\x01\x80\x54\x01" + b"\x3f\xfb\x01\x30\xfb\x01" + b"\x30\xfb\x01\x30\xfb\x01" + b"\x80\xfb\x01\x80\xfb\x01" b"\x30\x0a\x30\x0a" ) diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index 5b6ea6427..97a3e5e1e 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -24,6 +24,7 @@ streams_from_instructions, ) from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.registers.channel import channel_registers from sampletones_player.specification.registers import ( TRIANGLE_COUNTER_CONTROL, TRIANGLE_SOUNDING_RELOAD, @@ -209,7 +210,7 @@ def test_every_slice_sounds_on_the_channel_it_was_reconstructed_for(self) -> Non ) assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 assert song.streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD - assert len(song.streams.pulse2) == 1 + assert set(song.streams.pulse2) == {channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)[0]} def test_the_schedule_follows_the_rate_the_request_states(self) -> None: song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=HALF_RATE_FREQUENCY)) From eec02aec784c95a81a86e3db580f3be5f888e5b0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 17:21:47 +0200 Subject: [PATCH 055/142] Refined: the stems list margins --- docs/development/architecture.md | 4 +- docs/guide/interface.md | 19 +-- .../categories/elements/global_.py | 4 +- .../coordinators/tabs/main.py | 4 +- .../layout/general/stems.py | 3 +- src/sampletones_application/tags/general.py | 19 ++- .../ui/elements/layout/well.py | 26 ++-- .../ui/elements/stems/list.py | 128 +++++++++--------- .../ui/panels/main/explorer.py | 23 +++- .../utils/gui/tooltip.py | 13 +- src/sampletones_config/lang/en.yaml | 12 +- .../layout/general/stems.yaml | 5 +- src/sampletones_config/theme/converter.yaml | 23 ---- .../theme/stems/drop_strip.yaml | 15 ++ src/sampletones_config/theme/stems/row.yaml | 9 ++ .../theme/stems/row_inert.yaml | 9 ++ .../coordinators/tabs/test_main.py | 6 +- .../sampletones_application/test_startup.py | 6 +- .../ui/elements/stems/test_list.py | 42 ++++-- .../ui/panels/main/test_explorer_controls.py | 61 +++++++++ 20 files changed, 285 insertions(+), 146 deletions(-) delete mode 100644 src/sampletones_config/theme/converter.yaml create mode 100644 src/sampletones_config/theme/stems/drop_strip.yaml create mode 100644 src/sampletones_config/theme/stems/row.yaml create mode 100644 src/sampletones_config/theme/stems/row_inert.yaml diff --git a/docs/development/architecture.md b/docs/development/architecture.md index a0b3a3df9..16f6eb834 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -205,7 +205,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m - A panel creates its entire widget tree in one call to `create_panel(parent)`, rooting its subtree at `self.tag` inside the coordinator-injected `parent`, and calls DPG afterwards only in `update_view()`, `update_*` methods, and event callbacks wired by DPG itself. - Panels hold only visual state: their tag, their child widget references, and layout dimensions. Domain objects stay in logic; panels receive projections of them. - A panel never encodes its own placement: it does not compose a column tag (`SUF_PANEL_*`) as its parent, and it never hosts a sibling panel. Tab layout is the coordinator's (see the Coordinators reference). Where a section is a card, one card is one panel is one module; the coordinator declares which cards a tab contains and how they are arranged. -- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — the `card()` context manager binds SURFACE to a card, and the `well()` context manager binds recessed GROUND to a region sunk inside one, so a list reads as one body rather than as content loose on its card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE. +- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — the `card()` context manager binds SURFACE to a card, and `well()` binds recessed GROUND to a padded region sunk inside one, so a list reads as one body rather than as content loose on its card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE. - Every mutation from outside goes through `update_view(view_model)` or through a direct DPG call (`dpg_configure_item`, `dpg_set_value`) triggered by an `update_*` method. - Callback wiring from coordinators sets public `on_x` attributes *after* construction; panels must therefore tolerate `None` hooks until wiring is complete. - A widget whose rendering needs synchronous per-item queries declares a consumer-owned `Protocol` of exactly that surface (e.g. `TreeLogicProtocol`, through which the file trees query per-node favorite and playability state); the owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. Hooks and view models remain the default — the Protocol is the exception for query-heavy widgets where projecting a whole tree per repaint would be disproportionate. @@ -216,7 +216,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m | Path | Role | |------|------| | `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar | -| `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold) and the `card()` and `well()` context managers, driven declaratively by tab coordinators | +| `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold), the `card()` context manager and the `well()` inset region, driven declaratively by tab coordinators | | `ui/panels/` | Domain-level composite panels, organised by feature area | | `ui/themes/` | DPG themes and per-widget style helpers | | `ui/resources/` | Icons and image resources loaded at startup | diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9fab46ab8..2f3fac9d8 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -28,9 +28,9 @@ them offers **Open** instead. **Cancel** stops a run, and only one runs at a tim **Stems mode** turns the card into a list of the recordings mixed into one reconstruction. Tick it and click each recording in the browser, or right-click one and choose **Add as stem** — that starts a stems conversion from a classic one in a -single step. **Add folder as stems** offers everything in a folder, as does -Ctrl-clicking it while you are gathering; where a folder holds more recordings than -the list has room for, you pick which ones. +single step. Ctrl-clicking a recording does the same, and Ctrl-clicking a folder +offers everything in it, as **Add folder as stems** does; where a folder holds more +recordings than the list has room for, you pick which ones. Each row names its recording and carries a checkbox per channel that recording may use. Untick them all and the row greys out: that recording takes no part in the @@ -38,12 +38,13 @@ conversion, and its row stays listed so you can bring it back. The rows sit under **level** bands, and a level is a turn to choose: every recording on level 1 picks its channels before any on level 2, so a lead can take what it needs -before a pad does. Drag a row by its handle onto another row to share that row's -level, or onto the gap between two levels to give it a level of its own. -Right-clicking a row names the same moves in words, alongside the recording's own actions — its name or path to the clipboard, and the file shown in your file manager. **Order** decides how the levels -take turns — round by round, or one level filled before the next picks — and **x** -takes a row out. Untick **Stems mode** and the first recording stays as your single -selection. +before a pad does. Drag a row onto another row to share that row's level, or onto +the gap between two levels to give it a level of its own. +Right-clicking a row names the same moves in words, alongside the recording's own +actions — its name or path to the clipboard, and the file shown in your file +manager. **Order** decides how the levels take turns — round by round, or one level +filled before the next picks — and **x** takes a row out. Untick **Stems mode** and +the first recording stays as your single selection. **Channels per source** caps how many channels one recording may hold in a single frame, and it applies to every conversion — one file, a whole folder, or a stems diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index de42507c8..0fe8d4e8d 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -59,14 +59,12 @@ class StemsElements(AbstractElement): """The vocabulary of a stems list, shared by every card that draws one.""" LEVEL_CAPTION = "level_caption" - HANDLE = "handle" - HANDLE_TOOLTIP = "handle_tooltip" REMOVE = "remove" + DRAG_TOOLTIP = "drag_tooltip" INERT_TOOLTIP = "inert_tooltip" STATUS_ROW = "status_row" STATUS_CHANNEL = "status_channel" STATUS_REMOVE = "status_remove" - STATUS_HANDLE = "status_handle" class NodeDetailElements(AbstractElement): diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index c96daeb11..2045a4916 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -383,8 +383,8 @@ def _request_stems_mode(self, stems_mode: bool) -> None: self._confirm_discarding_stems(lambda: self._converter_logic.set_stems_mode(False)) def _can_add_stems(self) -> bool: - """A stems list is being gathered and is free to take another recording.""" - return self._converter_logic.stems_mode and not self._is_operation_active() + """The converter is free to gather recordings into a stems conversion.""" + return not self._is_operation_active() def _on_file_add_requested(self, filepath: Path) -> None: """Gathers one recording into a stems conversion, opening one where none is being built.""" diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index ab8728d8d..7d8f22bc9 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -2,7 +2,8 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): - handle_width: int channel_column_width: int remove_button_width: int level_strip_height: int + well_padding: int + well_margin: int diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 77aa24852..627260f23 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -260,6 +260,24 @@ Widget.THEME, "plus_minus_buttons", ) +TAG_GLOBAL_THEME_STEMS_DROP_STRIP = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_drop_strip", +) +TAG_GLOBAL_THEME_STEMS_ROW = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_row", +) +TAG_GLOBAL_THEME_STEMS_ROW_INERT = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_row_inert", +) TAG_GLOBAL_THEME_TOOLTIP = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -717,7 +735,6 @@ SUF_CHECKBOX = "checkbox" SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites") SUF_STRIP = "strip" -SUF_HANDLE = "handle" SUF_TABLE = "table" SUF_TOOLTIP = "tooltip" SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail") diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index ae2e78408..c83d88c68 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -1,27 +1,34 @@ -from contextlib import contextmanager -from typing import Iterator - import dearpygui.dearpygui as dpg -from sampletones_application.tags.general import TAG_GLOBAL_THEME_PANEL_GROUND +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_GROUP, + TAG_GLOBAL_THEME_PANEL_GROUND, +) from sampletones_application.ui.themes.registry import ThemeRegistry -@contextmanager def well( parent: str, tag: str, *, + padding: int, + margin: int, height: int = 0, show: bool = True, -) -> Iterator[str]: - """Open a recessed region inside a card and bind its depth theme. +) -> str: + """Sink a recessed region into a card and bind its depth theme. A well sinks a list below the card it sits on, the way a column of cards sits below the tab around it, so a run of rows reads as one body rather than as content loose on the card. Alongside ``card()`` this is where the recessed depth theme is bound; the region sizes itself to its rows unless ``height`` reserves a footprint. + + Returns the inset body group content is added to, which keeps ``padding`` clear at the + sides. ``margin`` opens the gap above the first row and below the last, which the row + spacing between the content and the spacers adds to. """ + body_tag = compose_tag(tag, SUF_GROUP) with dpg.child_window( tag=tag, parent=parent, @@ -32,6 +39,9 @@ def well( no_scrollbar=True, show=show, ): - yield tag + dpg.add_spacer(height=margin) + dpg.add_group(tag=body_tag, indent=padding, width=-padding) + dpg.add_spacer(height=margin) ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_GROUND).bind_to_item(tag) + return body_tag diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index e1f711961..ac9baef8e 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -11,7 +11,6 @@ SUF_CHANNELS, SUF_CHECKBOX, SUF_GROUP, - SUF_HANDLE, SUF_HANDLER_REGISTRY, SUF_LEVEL, SUF_PAYLOAD, @@ -19,8 +18,12 @@ SUF_STRIP, SUF_TABLE, SUF_TEXT, + SUF_TOOLTIP, SUF_WELL, TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_STEMS_DROP_STRIP, + TAG_GLOBAL_THEME_STEMS_ROW, + TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -43,7 +46,7 @@ from sampletones_shared.types.callback import MessageCallback, StringCallback from sampletones_shared.utils.callbacks import CallbackMixin -RowShape = Tuple[Tuple[str, ...], Tuple[Tuple[str, int, bool], ...]] +RowShape = Tuple[Tuple[str, ...], Tuple[Tuple[str, int], ...]] ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] KeyOffsetCallback = Callable[[str, int], None] @@ -55,10 +58,10 @@ class GUIStemsList(CallbackMixin): Both the converter's gathered recordings and a reconstruction's recorded assignment are the same list, so one definition draws them and each owner turns on the affordances it can - honour: ``draggable`` gives a row a handle and opens a drop strip between the bands, and - ``removable`` gives it the danger-toned button that takes it out. Rows are keyed by the - identity their owner reports gestures under, and every column lines up across the bands - because the table holds one fixed column per channel in play. + honour: ``draggable`` makes a row itself the thing you drag and opens a drop strip between + the bands, and ``removable`` gives it the danger-toned button that takes it out. Rows are + keyed by the identity their owner reports gestures under, and every column lines up across + the bands because the table holds one fixed column per channel in play. """ def __init__( @@ -79,18 +82,16 @@ def __init__( self._removable = removable self._level_template = language_manager["global.stems.template.level_caption"] - self._lbl_handle = language_manager["global.stems.label.handle"] self._lbl_remove = language_manager["global.stems.label.remove"] - self._msg_handle = language_manager["global.stems.message.handle_tooltip"] + self._msg_drag = language_manager["global.stems.message.drag_tooltip"] self._msg_inert = language_manager["global.stems.message.inert_tooltip"] self._payload = compose_tag(prefix, SUF_PAYLOAD) self._well_tag = compose_tag(prefix, SUF_WELL) - self._body_tag = compose_tag(prefix, SUF_GROUP) + self._body_tag = compose_tag(self._well_tag, SUF_GROUP) self._name_handler_tag = compose_tag(prefix, SUF_TEXT, SUF_HANDLER_REGISTRY) self._channel_handler_tag = compose_tag(prefix, SUF_CHANNELS, SUF_HANDLER_REGISTRY) self._button_handler_tag = compose_tag(prefix, SUF_BUTTON, SUF_HANDLER_REGISTRY) - self._handle_handler_tag = compose_tag(prefix, SUF_HANDLE, SUF_HANDLER_REGISTRY) self._rows: Dict[str, StemRowViewModel] = {} self._channels_in_play: Tuple[ChannelName, ...] = () @@ -106,8 +107,7 @@ def __init__( @property def _handler_tags(self) -> Tuple[str, ...]: """The registries the rows bind to, one per widget kind the list draws.""" - shared = (self._name_handler_tag, self._channel_handler_tag, self._button_handler_tag) - return shared + (self._handle_handler_tag,) if self._draggable else shared + return (self._name_handler_tag, self._channel_handler_tag, self._button_handler_tag) @property def tag(self) -> str: @@ -117,18 +117,19 @@ def tag(self) -> str: def create(self, parent: str, *, show: bool = True) -> None: """Build the list's recessed region and the handlers its rows share.""" self._create_handlers() - with well(parent, self._well_tag, show=show): - dpg.add_group(tag=self._body_tag) + well( + parent, + self._well_tag, + padding=self._layout.well_padding, + margin=self._layout.well_margin, + show=show, + ) def update_view(self, view_model: StemsListViewModel) -> None: self._rows = {row.key: row for row in view_model.rows} self._channels_in_play = view_model.channels_in_play self._live = view_model.live self._sync_rows(view_model) - if self._draggable: - for level_index in range(view_model.level_count + 1): - dpg_set_value(self.level_tag(level_index, SUF_STRIP), False) - for row in view_model.rows: self._render_row(row) @@ -156,18 +157,19 @@ def _create_handlers(self) -> None: with dpg.item_handler_registry(tag=self._button_handler_tag): dpg.add_item_hover_handler(callback=self._hover_callback(self._remove_message)) - if self._draggable: - with dpg.item_handler_registry(tag=self._handle_handler_tag): - dpg.add_item_hover_handler(callback=self._hover_callback(self._handle_message)) - def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender, int], None]: """Route a hovered row widget's explanation to the status bar. An item hover handler names the hovered item, whose user data is the row it belongs to, - so one callback per widget kind explains every row of that kind. + so one callback per widget kind explains every row of that kind. The hover is reported a + frame after it happened, by which time a rebuilt list may have taken the widget away, so + the callback answers for the widgets still standing. """ def hover_callback(_sender: Sender, app_data: int) -> None: + if not dpg.does_item_exist(app_data): + return + self._status_bar.set(message_function, user_data=dpg.get_item_user_data(app_data)) return hover_callback @@ -190,18 +192,26 @@ def _sync_rows(self, view_model: StemsListViewModel) -> None: @staticmethod def _row_shape(view_model: StemsListViewModel) -> RowShape: - """What the bands are built from: the channels in play, and where each row stands.""" + """What the bands are built from: the channels in play, and where each row stands. + + Which channels a row holds is drawn onto the widgets already standing, so a tick keeps + the bands as they are and the pointer keeps whatever it was over. + """ return ( tuple(str(channel_name) for channel_name in view_model.channels_in_play), - tuple((row.key, row.level, row.takes_part) for row in view_model.rows), + tuple((row.key, row.level) for row in view_model.rows), ) def _create_level_strip(self, position: int) -> None: - """The gap a level is broken at: a recording dropped here takes a level of its own.""" + """The gap a level is broken at: a recording dropped here takes a level of its own. + + The strip reads as the gap it is and lights up only while a payload hovers it, so a + band separator stays a separator to everything but a drag. + """ if not self._draggable: return - dpg.add_selectable( + strip = dpg.add_button( label="", tag=self.level_tag(position, SUF_STRIP), parent=self._body_tag, @@ -210,6 +220,7 @@ def _create_level_strip(self, position: int) -> None: payload_type=self._payload, drop_callback=self._on_dropped_on_level, ) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_DROP_STRIP).bind_to_item(strip) def _create_level_caption(self, level_index: int) -> None: caption = dpg.add_text( @@ -227,9 +238,6 @@ def _create_level_table(self, level_index: int, view_model: StemsListViewModel) policy=dpg.mvTable_SizingFixedFit, resizable=False, ): - if self._draggable: - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.handle_width) - dpg.add_table_column(width_stretch=True) for _channel_name in view_model.channels_in_play: dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width) @@ -243,9 +251,6 @@ def _create_level_table(self, level_index: int, view_model: StemsListViewModel) def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: with dpg.table_row(tag=self.row_tag(row.key, SUF_GROUP)): - if self._draggable: - self._create_handle(row) - self._create_name(row) for channel_name in view_model.channels_in_play: self._create_channel(row, channel_name) @@ -253,22 +258,8 @@ def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> if self._removable: self._create_remove(row) - def _create_handle(self, row: StemRowViewModel) -> None: - handle = dpg.add_button( - label=self._lbl_handle, - tag=self.row_tag(row.key, SUF_HANDLE), - width=self._layout.handle_width, - user_data=row.key, - payload_type=self._payload, - drop_callback=self._on_dropped_on_row, - ) - with dpg.drag_payload(parent=handle, drag_data=row.key, payload_type=self._payload): - dpg.add_text(row.name) - - dpg.bind_item_handler_registry(handle, self._handle_handler_tag) - show_tooltip(handle, self._msg_handle) - def _create_name(self, row: StemRowViewModel) -> None: + """The row itself: what names the recording, what you drag it by, and what you drop onto.""" name = dpg.add_selectable( label=row.name, tag=self.row_tag(row.key, SUF_TEXT), @@ -276,9 +267,13 @@ def _create_name(self, row: StemRowViewModel) -> None: payload_type=self._payload, drop_callback=self._on_dropped_on_row, ) + if self._draggable: + with dpg.drag_payload(parent=name, drag_data=row.key, payload_type=self._payload): + dpg.add_text(row.name) + FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) dpg.bind_item_handler_registry(name, self._name_handler_tag) - show_tooltip(name, self._row_explanation(row)) + show_tooltip(name, self._row_explanation(row), text_tag=self.row_tag(row.key, SUF_TOOLTIP)) def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: checkbox_tag = self.channel_tag(row.key, channel_name) @@ -300,10 +295,16 @@ def _create_remove(self, row: StemRowViewModel) -> None: user_data=row.key, callback=self._on_remove_requested, ) + FontRegistry.bind_to_item(remove, Font.MONO_SMALL) ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove) dpg.bind_item_handler_registry(remove, self._button_handler_tag) def _render_row(self, row: StemRowViewModel) -> None: + """Draw what the row currently holds onto the widgets it already stands as. + + A row holding no channel greys through its theme rather than through ``enabled``, so it + answers a drag and a right-click as readily as one that takes part. + """ for channel_name in self._channels_in_play: tag = self.channel_tag(row.key, channel_name) dpg_configure_item(tag, enabled=self._live) @@ -311,20 +312,24 @@ def _render_row(self, row: StemRowViewModel) -> None: name_tag = self.row_tag(row.key, SUF_TEXT) dpg_set_value(name_tag, False) - dpg_configure_item(name_tag, enabled=row.takes_part and self._live) - if self._draggable: - dpg_configure_item(self.row_tag(row.key, SUF_HANDLE), enabled=self._live) - + dpg_configure_item(name_tag, enabled=self._live) + dpg_set_value(self.row_tag(row.key, SUF_TOOLTIP), self._row_explanation(row)) + row_theme = TAG_GLOBAL_THEME_STEMS_ROW if row.takes_part else TAG_GLOBAL_THEME_STEMS_ROW_INERT + ThemeRegistry.get(row_theme).bind_to_item(name_tag) if self._removable: dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live) def _row_explanation(self, row: StemRowViewModel) -> str: - """What the row's hover states: where the recording is, and where it holds no channel, why - it is greyed out.""" - if row.takes_part: - return str(row.path) + """What the row's hover states: where the recording is, how it moves, and where it holds + no channel, why it is greyed out.""" + lines = [str(row.path)] + if not row.takes_part: + lines.append(self._msg_inert) - return f"{row.path}\n{self._msg_inert}" + if self._draggable: + lines.append(self._msg_drag) + + return "\n".join(lines) def _name_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: row = self._rows.get(user_data) @@ -356,13 +361,6 @@ def _remove_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: return self._language_manager["global.stems.message.status_remove"].format(name=row.name) - def _handle_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: - row = self._rows.get(user_data) - if row is None: - return "" - - return self._language_manager["global.stems.message.status_handle"].format(name=row.name) - def _on_channels_changed( self, _sender: Sender, diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 81251d3c2..3de65cdcc 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -275,14 +275,27 @@ def _on_file_node_clicked( case extensions.EXT_FILE_RECONSTRUCTION: return self._logic.request_autoplay(node) case suffix if suffix in extensions.EXT_FILES_AUDIO: - self.call(self.on_wave_file_clicked, node.filepath) - return self._logic.request_autoplay(node) + return self._audio_node_clicked(node) if mouse_button == dpg.mvMouseButton_Right: return self._show_file_context_menu(node) return None + def _audio_node_clicked(self, node: FileSystemNode) -> None: + """Answers a click on a recording: Ctrl gathers it as a stem, else it becomes the selection. + + Ctrl is the gathering gesture throughout the browser, so it reaches a recording the same + way it reaches a folder and does what **Add as stem** does, opening a stems conversion + where none is being built. A plain click hands the recording to the converter and plays it. + """ + if Modifier.CTRL in capture_modifiers() and self.query(self.can_add_stems, default=False): + self.call(self.on_file_add_requested, node.filepath) + return + + self.call(self.on_wave_file_clicked, node.filepath) + self._logic.request_autoplay(node) + def _on_file_node_double_clicked( self, _sender: Sender, @@ -335,10 +348,10 @@ def _directory_node_clicked( node: FileSystemNode, node_tag: str, ) -> None: - """Answers a click on a folder: Ctrl offers its recordings to a stems list, else it opens. + """Answers a click on a folder: Ctrl offers its recordings as stems, else it opens. - The modifier reaches the stems list only while one is being gathered, so a Ctrl-click with - nothing to gather into opens the folder the way a plain click does. + Ctrl does what **Add folder as stems** does, opening a stems conversion where none is + being built. While the converter is busy the folder opens the way a plain click opens it. """ has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: diff --git a/src/sampletones_application/utils/gui/tooltip.py b/src/sampletones_application/utils/gui/tooltip.py index 97170df2b..ddd051393 100644 --- a/src/sampletones_application/utils/gui/tooltip.py +++ b/src/sampletones_application/utils/gui/tooltip.py @@ -18,15 +18,24 @@ def show_tooltip( message: str, *, tag: Optional[str] = None, + text_tag: Optional[str] = None, ) -> Sender: - """Attaches a hover explanation to ``parent``, named by its tag or by the id it was created with.""" + """Attaches a hover explanation to ``parent``, named by its tag or by the id it was created with. + + ``text_tag`` names the message itself, which is what a caller gives it where the explanation + changes while the widget it explains stands. + """ tooltip_kwargs: SerializedData = {"hide_on_activity": True} if tag is not None: tooltip_kwargs["tag"] = tag + text_kwargs: SerializedData = {} + if text_tag is not None: + text_kwargs["tag"] = text_tag + with dpg.tooltip(parent, **tooltip_kwargs) as tooltip: ThemeRegistry.get(TAG_GLOBAL_THEME_TOOLTIP).bind_to_item(tooltip) - tooltip_text: Sender = dpg.add_text(message) + tooltip_text: Sender = dpg.add_text(message, **text_kwargs) FontRegistry.bind_to_item(tooltip_text, Font.REGULAR_SMALL) return tooltip_text diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 47cf35af9..6bc911d89 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -259,14 +259,12 @@ global.status.message.retuning_samples: "Retuning samples..." # Global — Stems # ============================================================================= global.stems.template.level_caption: "Level {}" -global.stems.label.handle: "::" global.stems.label.remove: "x" -global.stems.message.handle_tooltip: "Drag onto another recording to share its level, or onto a gap to open a new one." -global.stems.message.inert_tooltip: "This recording holds no channel, so it takes no part." -global.stems.message.status_row: "{name} — right-click for the actions this recording answers." -global.stems.message.status_channel: "Give {name} the {channel} channel, or take it back." -global.stems.message.status_remove: "Take {name} out." -global.stems.message.status_handle: "Drag {name} onto a row to share its level, or onto a gap for a level of its own." +global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level." +global.stems.message.inert_tooltip: "Tick a channel to use this recording." +global.stems.message.status_row: "Drag {name} onto another row or a gap to move it, or right-click for more actions." +global.stems.message.status_channel: "Turn the {channel} channel on or off for {name}." +global.stems.message.status_remove: "Remove {name} from the list." # ============================================================================= # Global — Player diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index c22b344f0..a7a1c4a15 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -1,4 +1,5 @@ -handle_width: 24 channel_column_width: 90 -remove_button_width: 24 +remove_button_width: 30 level_strip_height: 6 +well_padding: 8 +well_margin: 4 diff --git a/src/sampletones_config/theme/converter.yaml b/src/sampletones_config/theme/converter.yaml deleted file mode 100644 index f036c38bf..000000000 --- a/src/sampletones_config/theme/converter.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: converter -tag: global.theme.converter - -components: - - item_type: All - entries: - - type: color - key: Text - value: .contrast - - type: color - key: TextDisabled - value: .text_muted - - type: color - key: WindowBg - value: .surface - - type: color - key: ChildBg - value: .surface - - item_type: Button - entries: - - type: color - key: Button - value: .button diff --git a/src/sampletones_config/theme/stems/drop_strip.yaml b/src/sampletones_config/theme/stems/drop_strip.yaml new file mode 100644 index 000000000..9f8cf9934 --- /dev/null +++ b/src/sampletones_config/theme/stems/drop_strip.yaml @@ -0,0 +1,15 @@ +name: stems_drop_strip +tag: global.theme.stems_drop_strip + +components: + - item_type: Button + entries: + - type: color + key: Button + value: .transparent + - type: color + key: ButtonHovered + value: .transparent + - type: color + key: ButtonActive + value: .transparent diff --git a/src/sampletones_config/theme/stems/row.yaml b/src/sampletones_config/theme/stems/row.yaml new file mode 100644 index 000000000..a4c2c0188 --- /dev/null +++ b/src/sampletones_config/theme/stems/row.yaml @@ -0,0 +1,9 @@ +name: stems_row +tag: global.theme.stems_row + +components: + - item_type: Selectable + entries: + - type: color + key: Text + value: .text diff --git a/src/sampletones_config/theme/stems/row_inert.yaml b/src/sampletones_config/theme/stems/row_inert.yaml new file mode 100644 index 000000000..2a85e847d --- /dev/null +++ b/src/sampletones_config/theme/stems/row_inert.yaml @@ -0,0 +1,9 @@ +name: stems_row_inert +tag: global.theme.stems_row_inert + +components: + - item_type: Selectable + entries: + - type: color + key: Text + value: .text_muted diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index d9f5a71a2..f0f164a7f 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -323,13 +323,13 @@ def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: class TestModifierAddAvailability: - """The modifier click reaches a stems list only while one stands ready to take a recording.""" + """The modifier click gathers a recording whenever the converter is free to take one.""" def test_a_gathered_list_takes_the_click(self) -> None: assert _stems_coordinator()._can_add_stems() is True - def test_a_classic_conversion_leaves_the_click_alone(self) -> None: - assert _stems_coordinator(stems_mode=False)._can_add_stems() is False + def test_a_classic_conversion_takes_the_click_and_opens_a_list(self) -> None: + assert _stems_coordinator(stems_mode=False)._can_add_stems() is True def test_a_busy_application_leaves_the_click_alone(self) -> None: assert _stems_coordinator(operation_active=True)._can_add_stems() is False diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index afcf97d11..37e9c9fda 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -15,7 +15,6 @@ from sampletones_application.tags.general import ( SUF_BUTTON, SUF_GROUP, - SUF_HANDLE, SUF_STRIP, SUF_TABLE, SUF_TEXT, @@ -40,6 +39,7 @@ from sampletones_core.reconstructions import Reconstruction REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} +DRAG_PAYLOAD_SLOT: Final[int] = 3 _DPG_DISPLAY_FUNCTIONS = [ "create_context", @@ -503,10 +503,10 @@ def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> N 1, SUF_TABLE ) - def test_a_row_carries_a_handle_to_drag_it_by(self, app: Application, tmp_path: Path) -> None: + def test_a_row_is_the_thing_you_drag_it_by(self, app: Application, tmp_path: Path) -> None: path = self._gather(app, tmp_path, ["a.wav"])[0] - assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_HANDLE)) + assert dpg.get_item_children(stems_list(app).row_tag(str(path), SUF_TEXT), DRAG_PAYLOAD_SLOT) def test_dropping_a_recording_on_a_row_joins_that_rows_level(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index a92f98ba7..3e4e50460 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import FrozenSet, Iterator, List, Tuple +from typing import Final, FrozenSet, Iterator, List, Tuple import dearpygui.dearpygui as dpg import pytest @@ -19,11 +19,12 @@ SUF_BUTTON, SUF_CHANNELS, SUF_CHECKBOX, - SUF_HANDLE, SUF_LEVEL, SUF_ROW, SUF_STRIP, SUF_TEXT, + TAG_GLOBAL_THEME_STEMS_ROW, + TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.status import GUIStatusBar @@ -41,6 +42,7 @@ ROOT_TAG = "test_root" PREFIX = "test.stems" CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +DRAG_PAYLOAD_SLOT: Final[int] = 3 @pytest.fixture @@ -136,13 +138,27 @@ def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_ assert dpg.get_value(channel_tag(bass, ChannelName.PULSE1)) assert not dpg.get_value(channel_tag(bass, ChannelName.TRIANGLE)) - def test_a_row_holding_no_channel_greys_out(self, dpg_context: None, layout_config) -> None: + def test_a_row_holding_no_channel_greys_out_and_still_answers( + self, + dpg_context: None, + layout_config, + ) -> None: stems_list = build(layout_config) bass = row("bass", channels=frozenset()) stems_list.update_view(view(bass)) - assert not dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) + name_tag = row_tag(bass, SUF_TEXT) + assert dpg.get_item_alias(dpg.get_item_theme(name_tag)) == TAG_GLOBAL_THEME_STEMS_ROW_INERT + assert dpg.is_item_enabled(name_tag) + + def test_a_row_taking_part_reads_in_full(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert dpg.get_item_alias(dpg.get_item_theme(row_tag(bass, SUF_TEXT))) == TAG_GLOBAL_THEME_STEMS_ROW def test_rows_follow_a_changed_list(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) @@ -193,21 +209,29 @@ def test_a_draggable_list_opens_a_strip_above_each_level_and_below_the_last( class TestAffordances: - def test_a_draggable_list_gives_each_row_a_handle(self, dpg_context: None, layout_config) -> None: + def test_a_draggable_list_makes_the_row_itself_the_thing_you_drag( + self, + dpg_context: None, + layout_config, + ) -> None: stems_list = build(layout_config, draggable=True) bass = row("bass") stems_list.update_view(view(bass)) - assert dpg.does_item_exist(row_tag(bass, SUF_HANDLE)) + assert dpg.get_item_children(row_tag(bass, SUF_TEXT), DRAG_PAYLOAD_SLOT) - def test_a_list_without_dragging_gives_no_handle_and_no_strip(self, dpg_context: None, layout_config) -> None: + def test_a_list_without_dragging_carries_no_payload_and_no_strip( + self, + dpg_context: None, + layout_config, + ) -> None: stems_list = build(layout_config, draggable=False) bass = row("bass") stems_list.update_view(view(bass)) - assert not dpg.does_item_exist(row_tag(bass, SUF_HANDLE)) + assert not dpg.get_item_children(row_tag(bass, SUF_TEXT), DRAG_PAYLOAD_SLOT) assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_STRIP)) def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layout_config) -> None: @@ -262,7 +286,6 @@ def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_contex stems_list.update_view(view(bass, live=False)) assert not dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) - assert not dpg.is_item_enabled(row_tag(bass, SUF_HANDLE)) assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) for channel_name in CHANNELS: assert not dpg.is_item_enabled(channel_tag(bass, channel_name)) @@ -275,5 +298,4 @@ def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> No stems_list.update_view(view(bass, live=True)) assert dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) - assert dpg.is_item_enabled(row_tag(bass, SUF_HANDLE)) assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index ddaf46487..6eb4b96b3 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -196,3 +196,64 @@ def test_the_folders_the_model_held_are_dropped_afterwards( root = tree.get_root() assert root is not None assert [str(node.name) for node in root.descendants] == [str(ROOT)] + + +class FakeAutoplayLogic: + """Answers the panel's request to preview a recording, recording what it was handed.""" + + def __init__(self) -> None: + self.played: List[FileSystemNode] = [] + + def request_autoplay(self, node: FileSystemNode) -> None: + self.played.append(node) + + +class RecordingClick: + """A panel wired to record where a click on a recording went.""" + + def __init__(self, *, can_add_stems: bool) -> None: + tree = explorer_tree() + self.panel = build_panel(tree) + self.node = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC / "song.wav")[0] + self.autoplay = FakeAutoplayLogic() + self.gathered: List[Path] = [] + self.selected: List[Path] = [] + self.panel._logic = self.autoplay # type: ignore[assignment] + self.panel.can_add_stems = lambda: can_add_stems + self.panel.on_file_add_requested = self.gathered.append + self.panel.on_wave_file_clicked = self.selected.append + + def click(self, monkeypatch: pytest.MonkeyPatch, *, holding_ctrl: bool) -> None: + held = {explorer_module.Modifier.CTRL} if holding_ctrl else set() + monkeypatch.setattr(explorer_module, "capture_modifiers", lambda: frozenset(held)) + self.panel._audio_node_clicked(self.node) + + +class TestClickingARecording: + """Ctrl gathers a recording as a stem; a plain click hands it to the converter and plays it.""" + + def test_a_plain_click_selects_the_recording_and_plays_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + clicked = RecordingClick(can_add_stems=True) + + clicked.click(monkeypatch, holding_ctrl=False) + + assert clicked.selected == [MUSIC / "song.wav"] + assert clicked.autoplay.played == [clicked.node] + assert clicked.gathered == [] + + def test_holding_ctrl_gathers_the_recording_as_a_stem(self, monkeypatch: pytest.MonkeyPatch) -> None: + clicked = RecordingClick(can_add_stems=True) + + clicked.click(monkeypatch, holding_ctrl=True) + + assert clicked.gathered == [MUSIC / "song.wav"] + assert clicked.selected == [] + assert clicked.autoplay.played == [] + + def test_a_busy_converter_leaves_ctrl_the_plain_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + clicked = RecordingClick(can_add_stems=False) + + clicked.click(monkeypatch, holding_ctrl=True) + + assert clicked.selected == [MUSIC / "song.wav"] + assert clicked.gathered == [] From d86beb11e98ca54523be76640deb29c45e6c56e6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 18:14:45 +0200 Subject: [PATCH 056/142] Fixed: the stems row hover, margins and dragging --- .../ui/elements/stems/list.py | 5 +++ .../sampletones_application/test_startup.py | 5 ++- .../ui/elements/stems/test_list.py | 37 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index ac9baef8e..67483af94 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -264,6 +264,7 @@ def _create_name(self, row: StemRowViewModel) -> None: label=row.name, tag=self.row_tag(row.key, SUF_TEXT), user_data=row.key, + callback=self._on_name_clicked_off, payload_type=self._payload, drop_callback=self._on_dropped_on_row, ) @@ -378,6 +379,10 @@ def _on_channels_changed( def _on_remove_requested(self, _sender: Sender, _app_data: Any, user_data: str) -> None: self.call(self.on_remove_requested, user_data) + def _on_name_clicked_off(self, sender: Sender, _value: bool, _user_data: str) -> None: + """Let go of a clicked row: the list names recordings and moves them, it selects none.""" + dpg_set_value(sender, False) + def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: mouse_button, clicked_item = app_data if mouse_button != dpg.mvMouseButton_Right: diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 37e9c9fda..441200c1e 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -18,6 +18,7 @@ SUF_STRIP, SUF_TABLE, SUF_TEXT, + TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, @@ -545,5 +546,7 @@ def test_a_recording_holding_no_channel_greys_out_but_stays_listed( app._main_tab._converter_logic.set_source_channels(path, frozenset()) + name_tag = stems_list(app).row_tag(str(path), SUF_TEXT) assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP)) - assert dpg.get_item_configuration(stems_list(app).row_tag(str(path), SUF_TEXT))["enabled"] is False + assert dpg.get_item_alias(dpg.get_item_theme(name_tag)) == TAG_GLOBAL_THEME_STEMS_ROW_INERT + assert dpg.get_item_configuration(name_tag)["enabled"] is True diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 3e4e50460..5361c877e 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -19,6 +19,7 @@ SUF_BUTTON, SUF_CHANNELS, SUF_CHECKBOX, + SUF_HANDLER_REGISTRY, SUF_LEVEL, SUF_ROW, SUF_STRIP, @@ -38,6 +39,7 @@ StemsListViewModel, ) from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import Callback ROOT_TAG = "test_root" PREFIX = "test.stems" @@ -118,6 +120,12 @@ def channel_tag(entry: StemRowViewModel, channel_name: ChannelName) -> str: return compose_tag(PREFIX, SUF_ROW, entry.key, SUF_CHANNELS, compose_tag(channel_name, SUF_CHECKBOX)) +def hover_handler(suffix: str) -> Callback: + """The hover callback a row widget of that kind shares, as DearPyGui would call it.""" + registry = compose_tag(PREFIX, suffix, SUF_HANDLER_REGISTRY) + return dpg.get_item_callback(dpg.get_item_children(registry, 1)[-1]) + + class TestRows: def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) @@ -299,3 +307,32 @@ def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> No assert dpg.is_item_enabled(row_tag(bass, SUF_TEXT)) assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + + +class TestVanishedWidgets: + """DearPyGui reports a hover a frame after it happened, by which time the row may have gone.""" + + def test_a_hover_naming_a_row_that_went_is_let_be(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + stems_list.update_view(view(bass)) + hovered = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + + stems_list.update_view(view()) + + hover_handler(SUF_TEXT)(0, hovered) + + def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( + self, + dpg_context: None, + layout_config, + ) -> None: + """Greying a row is drawn onto the widgets it stands as, so the pointer keeps its box.""" + stems_list = build(layout_config) + bass = row("bass") + stems_list.update_view(view(bass)) + standing = dpg.get_alias_id(channel_tag(bass, ChannelName.PULSE1)) + + stems_list.update_view(view(row("bass", channels=frozenset()))) + + assert dpg.get_alias_id(channel_tag(bass, ChannelName.PULSE1)) == standing From 2634f7c1cb7351619a2cfe25b9a04abcf117dc10 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 18:40:08 +0200 Subject: [PATCH 057/142] Sped: the codec by reusing what each parse already measured --- .../compression/dictionary/phrase.py | 27 +++- src/sampletones_player/compression/encode.py | 15 +-- .../compression/matches/cache.py | 118 ++++++++++++++++++ .../compression/matches/matcher.py | 54 ++++---- .../compression/matches/reading.py | 32 +++++ .../compression/parse/plane.py | 6 +- .../compression/parse/song.py | 49 +++++++- src/sampletones_player/compression/search.py | 49 +++++--- tests/benchmarks/test_compression.py | 34 +++-- tests/integration/nsf/corpus.py | 100 ++++++++++++++- .../nsf/test_compression_report.py | 9 +- .../compression/matches/test_matcher.py | 9 +- .../compression/parse/test_plane.py | 6 +- 13 files changed, 426 insertions(+), 82 deletions(-) create mode 100644 src/sampletones_player/compression/matches/cache.py create mode 100644 src/sampletones_player/compression/matches/reading.py diff --git a/src/sampletones_player/compression/dictionary/phrase.py b/src/sampletones_player/compression/dictionary/phrase.py index 8241b92a5..c12a9b480 100644 --- a/src/sampletones_player/compression/dictionary/phrase.py +++ b/src/sampletones_player/compression/dictionary/phrase.py @@ -1,5 +1,7 @@ from __future__ import annotations +from functools import cached_property + from pydantic import BaseModel, ConfigDict, model_validator from sampletones_player.specification.compression import ( @@ -10,6 +12,21 @@ ) +def phrase_entry_size(length: int) -> int: + """The bytes a phrase of ``length`` values takes in the song block, its table entry included. + + The search weighs a candidate against what its entry would cost before any phrase is built + from it, so the cost is stated over the length alone. + + Args: + length: The values the phrase plays, one per tick. + + Returns: + int: The bytes the phrase and its table entry take together. + """ + return PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + length + + class Phrase(BaseModel): """One entry of the dictionary: the values a plane plays when a token names it. @@ -36,12 +53,16 @@ def length(self) -> int: """The ticks the phrase's own values cover.""" return len(self.body) - @property + @cached_property def differences(self) -> bytes: - """The step from each value to the next, which is the shape a shift leaves alone.""" + """The step from each value to the next, which is the shape a shift leaves alone. + + A phrase is read for its shape every time a table is indexed, and a table is indexed once + per parse, so the shape is derived once and the phrase carries it thereafter. + """ return bytes((following - value) % BYTE_VALUES for value, following in zip(self.body, self.body[1:])) @property def size(self) -> int: """The bytes the phrase takes in the song block, its table entry included.""" - return PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + len(self.body) + return phrase_entry_size(len(self.body)) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index 652092333..e34c1a149 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -5,6 +5,7 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.prune import prune from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.result import Parse @@ -82,18 +83,18 @@ def _savings( def _settle( - indices: Sequence[PlaneIndex], + cache: MatchCache, table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], ) -> Tuple[PhraseTable, Tuple[Parse, ...]]: baseline = parse_planes( - indices, + cache, phrase_table(()), replace(options, phrases=False), boundaries, ) - parses = parse_planes(indices, table, options, boundaries) + parses = parse_planes(cache, table, options, boundaries) for _ in range(SETTLING_ROUNDS): pruned = prune( table, @@ -104,7 +105,7 @@ def _settle( break table = pruned - parses = parse_planes(indices, table, options, boundaries) + parses = parse_planes(cache, table, options, boundaries) return table, parses @@ -132,13 +133,13 @@ def encode_planes( Returns: CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. """ - indices = tuple(PlaneIndex.from_plane(plane) for plane in planes.planes) + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes.planes) entries = boundaries | {STREAM_START} table = phrase_table(seeds) if options.phrases else phrase_table(()) if options.phrases and options.search: - table = search_phrases(indices, table, options, entries) + table = search_phrases(cache, table, options, entries) - table, parses = _settle(indices, table, options, entries) + table, parses = _settle(cache, table, options, entries) return CompressedPlanes( phrases=table, streams=PlaneOrder.across(emit(parse.tokens) for parse in parses), diff --git a/src/sampletones_player/compression/matches/cache.py b/src/sampletones_player/compression/matches/cache.py new file mode 100644 index 000000000..95e3ed378 --- /dev/null +++ b/src/sampletones_player/compression/matches/cache.py @@ -0,0 +1,118 @@ +from array import array +from typing import Dict, Final, Iterable, List, Optional, Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.played import played_ticks +from sampletones_player.compression.matches.reading import PhraseReading +from sampletones_player.compression.matches.shift import translation +from sampletones_player.specification.compression import BYTE_VALUES, MAX_PHRASE_TICKS + +KEY_LENGTH: Final[int] = 2 +TICKS_TYPECODE: Final[str] = "H" +TICKS_ENTRY_SIZE: Final[int] = 2 +NO_MATCH: Final[int] = 0 +MIN_PHRASE_TICKS: Final[int] = 2 +NO_SHIFT: Final[int] = 0 + + +class MatchCache: + """What each phrase plays against each plane, measured once for a whole encoding. + + A phrase's match at a tick follows from the plane and the phrase alone, so it holds for every + parse the encoder runs: a search round adds one phrase and measures that one, while every + phrase already in the table answers from the reading taken when it arrived. This is what + turns the cost of encoding from the parses times the dictionary into the dictionary alone. + + Each reading is taken against the most ticks a token could ever cover from that position, and + a parse reading it under a shorter reach takes the smaller of the two — the same answer + measuring again would give, since a phrase matches for as long as the plane agrees with it + and a shorter reach only cuts that agreement short. + + Positions are offered to a phrase by its first steps, the way the shortlist offers them, so a + reading covers the ticks the phrase could begin at rather than every tick of the plane. + """ + + def __init__(self, indices: Iterable[PlaneIndex]) -> None: + self._indices: Tuple[PlaneIndex, ...] = tuple(indices) + self._offers: List[Optional[Dict[bytes, Tuple[int, ...]]]] = [None] * len(self._indices) + self._readings: Dict[Tuple[int, bytes], PhraseReading] = {} + + @property + def indices(self) -> Tuple[PlaneIndex, ...]: + """The planes the encoding covers, in song-block order.""" + return self._indices + + def index(self, plane: int) -> PlaneIndex: + """The plane at ``plane`` and the readings of it matching is decided against. + + Args: + plane: The plane's position in song-block order. + + Returns: + PlaneIndex: The plane, its steps and its runs. + """ + return self._indices[plane] + + def reading(self, plane: int, phrase: Phrase) -> PhraseReading: + """What ``phrase`` plays against ``plane``, measured on first ask and kept thereafter. + + Args: + plane: The plane's position in song-block order. + phrase: The phrase the plane is read against. + + Returns: + PhraseReading: The ticks played from each position, and whether the plane plays the + phrase at all. + """ + entry = (plane, phrase.body) + measured = self._readings.get(entry) + if measured is None: + measured = self._measure(plane, phrase) + self._readings[entry] = measured + + return measured + + def _measure(self, plane: int, phrase: Phrase) -> PhraseReading: + index = self._indices[plane] + body = phrase.body + origin = body[0] + measured = array(TICKS_TYPECODE, bytes(TICKS_ENTRY_SIZE * index.ticks)) + shifted = False + unshifted = False + for position in self._offered(plane, phrase): + transpose = (index.plane[position] - origin) % BYTE_VALUES + ticks = played_ticks( + index, + position, + body.translate(translation(transpose)), + min(MAX_PHRASE_TICKS, index.ticks - position), + ) + measured[position] = ticks + if ticks >= MIN_PHRASE_TICKS: + shifted = True + unshifted = unshifted or transpose == NO_SHIFT + + return PhraseReading(ticks=measured, shifted=shifted, unshifted=unshifted) + + def _offered(self, plane: int, phrase: Phrase) -> Iterable[int]: + differences = phrase.differences + if len(differences) < KEY_LENGTH: + return range(self._indices[plane].ticks) + + return self._offers_by_key(plane).get(differences[:KEY_LENGTH], ()) + + def _offers_by_key(self, plane: int) -> Dict[bytes, Tuple[int, ...]]: + cached = self._offers[plane] + if cached is not None: + return cached + + index = self._indices[plane] + differences = index.differences + gathered: Dict[bytes, List[int]] = {} + for position in range(index.ticks): + gathered.setdefault(differences[position : position + KEY_LENGTH], []).append(position) + + offers = {key: tuple(positions) for key, positions in gathered.items()} + self._offers[plane] = offers + return offers diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py index dfee53d7a..693680797 100644 --- a/src/sampletones_player/compression/matches/matcher.py +++ b/src/sampletones_player/compression/matches/matcher.py @@ -1,32 +1,39 @@ from itertools import chain -from typing import Dict, Final, Iterator, List, Tuple +from typing import Dict, Iterator, List, Sequence, Tuple from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.matches.cache import KEY_LENGTH, MIN_PHRASE_TICKS, MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.match import PhraseMatch -from sampletones_player.compression.matches.played import played_ticks -from sampletones_player.compression.matches.shift import translation from sampletones_player.specification.compression import BYTE_VALUES -KEY_LENGTH: Final[int] = 2 -MIN_PHRASE_TICKS: Final[int] = 2 - class PhraseMatcher: - """Answers which phrases a plane plays at a position, and for how many ticks. + """Answers which phrases one plane plays at a tick, and for how many ticks. Phrases are held under the first steps of their shape, so a position offers a handful of - candidates to confirm rather than the whole dictionary. A candidate confirms at the shift its - first value asks for, which is the one shift that can possibly match there. + candidates rather than the whole dictionary. A candidate plays at the shift its first value + asks for, which is the one shift that can possibly match there, and the ticks it plays for + are read from the cache the whole encoding shares. A phrase is offered where the plane plays enough of it for those steps to tell it apart, which is a tick longer than the shortlist's key. A note cut shorter than that is spelled out, where a phrase token and a literal cost the same anyway. + + A matcher answers for the one plane it was built against, so the plane and the dictionary it + is read under arrive together and stay together. """ - def __init__(self, table: PhraseTable) -> None: - self._bodies: Tuple[bytes, ...] = tuple(phrase.body for phrase in table.phrases) - self._keyed: Dict[bytes, Tuple[int, ...]] = {} + def __init__( + self, + table: PhraseTable, + plane: int, + cache: MatchCache, + ) -> None: + self._index: PlaneIndex = cache.index(plane) + self._origins: Tuple[int, ...] = tuple(phrase.body[0] for phrase in table.phrases) + self._played: Tuple[Sequence[int], ...] = tuple(cache.reading(plane, phrase).ticks for phrase in table.phrases) + keyed: Dict[bytes, List[int]] = {} short: List[int] = [] for phrase_id, phrase in enumerate(table.phrases): differences = phrase.differences @@ -34,14 +41,18 @@ def __init__(self, table: PhraseTable) -> None: short.append(phrase_id) continue - key = differences[:KEY_LENGTH] - self._keyed[key] = self._keyed.get(key, ()) + (phrase_id,) + keyed.setdefault(differences[:KEY_LENGTH], []).append(phrase_id) + self._keyed: Dict[bytes, Tuple[int, ...]] = {key: tuple(ids) for key, ids in keyed.items()} self._short: Tuple[int, ...] = tuple(short) + @property + def index(self) -> PlaneIndex: + """The plane the matcher answers for, and the readings of it matching is decided against.""" + return self._index + def matches( self, - index: PlaneIndex, position: int, limit: int, *, @@ -50,7 +61,6 @@ def matches( """Every phrase the plane plays from ``position``, with the ticks and shift it plays at. Args: - index: The plane and the two readings of it matching is decided against. position: The tick the phrase would start at. limit: The most ticks a token may cover from there. transposition: Whether a phrase may play at a shift. @@ -61,21 +71,15 @@ def matches( if limit < MIN_PHRASE_TICKS: return + index = self._index origin = index.plane[position] key = index.differences[position : position + KEY_LENGTH] for phrase_id in chain(self._keyed.get(key, ()), self._short): - body = self._bodies[phrase_id] - transpose = (origin - body[0]) % BYTE_VALUES + transpose = (origin - self._origins[phrase_id]) % BYTE_VALUES if transpose and not transposition: continue - expected = body.translate(translation(transpose)) - ticks = played_ticks( - index, - position, - expected, - limit, - ) + ticks = min(self._played[phrase_id][position], limit) if ticks >= MIN_PHRASE_TICKS: yield PhraseMatch( phrase_id=phrase_id, diff --git a/src/sampletones_player/compression/matches/reading.py b/src/sampletones_player/compression/matches/reading.py new file mode 100644 index 000000000..cc5263024 --- /dev/null +++ b/src/sampletones_player/compression/matches/reading.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Sequence + + +@dataclass(frozen=True) +class PhraseReading: + """What one phrase plays against one plane, read once for a whole encoding. + + The ticks answer a parse asking what a token from a position would cover. The reach answers + the search asking whether a plane needs reading again at all: a phrase the plane never plays + leaves that plane's tokens exactly as they were. + + Attributes: + ticks: The ticks the phrase plays for from each tick of the plane. + shifted: Whether the plane plays the phrase anywhere, at whatever shift it asks for. + unshifted: Whether the plane plays the phrase anywhere at the pitch it was stored at. + """ + + ticks: Sequence[int] + shifted: bool + unshifted: bool + + def reaches(self, *, transposition: bool) -> bool: + """Whether the plane plays the phrase under the layers the encoding is built from. + + Args: + transposition: Whether a phrase may play at a shift. + + Returns: + bool: Whether the phrase reaches the plane at all. + """ + return self.shifted if transposition else self.unshifted diff --git a/src/sampletones_player/compression/parse/plane.py b/src/sampletones_player/compression/parse/plane.py index c57cb0610..53ce7146e 100644 --- a/src/sampletones_player/compression/parse/plane.py +++ b/src/sampletones_player/compression/parse/plane.py @@ -62,7 +62,6 @@ def _relax_forward( return for phrase_id, ticks, transpose in matcher.matches( - index, position, min(MAX_PHRASE_TICKS, reach), transposition=options.transposition, @@ -76,7 +75,6 @@ def _relax_forward( def parse_plane( - index: PlaneIndex, matcher: PhraseMatcher, options: CodecOptions, boundaries: FrozenSet[int], @@ -92,14 +90,14 @@ def parse_plane( elsewhere. Args: - index: The plane and the two readings of it matching is decided against. - matcher: The phrases the plane may play. + matcher: The plane, alongside the phrases it may play. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on. Returns: Parse: The tokens the plane is written as, and what each of its prefixes costs. """ + index = matcher.index plane = index.plane ticks = index.ticks entries = Boundaries.across(ticks, boundaries) diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py index a683d8670..c48a3c245 100644 --- a/src/sampletones_player/compression/parse/song.py +++ b/src/sampletones_player/compression/parse/song.py @@ -1,7 +1,8 @@ from typing import FrozenSet, Sequence, Tuple +from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import PhraseTable -from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.plane import parse_plane @@ -9,7 +10,7 @@ def parse_planes( - indices: Sequence[PlaneIndex], + cache: MatchCache, table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], @@ -17,7 +18,7 @@ def parse_planes( """Reads every plane of a song against one dictionary. Args: - indices: The planes and the readings of them matching is decided against. + cache: The planes the song covers, alongside what each phrase plays against them. table: The phrases the planes may play. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on. @@ -25,5 +26,43 @@ def parse_planes( Returns: Tuple[Parse, ...]: One parse per plane, in the order the planes were given. """ - matcher = PhraseMatcher(table) - return tuple(parse_plane(index, matcher, options, boundaries) for index in indices) + return tuple( + parse_plane(PhraseMatcher(table, plane, cache), options, boundaries) for plane in range(len(cache.indices)) + ) + + +def parse_planes_offered( + cache: MatchCache, + table: PhraseTable, + options: CodecOptions, + boundaries: FrozenSet[int], + *, + parses: Sequence[Parse], + offered: Phrase, +) -> Tuple[Parse, ...]: + """Reads again the planes ``offered`` reaches, carrying every other parse forward. + + A phrase the plane never plays leaves that plane's tokens exactly as they were: an entry + appended to the table takes an id after every phrase already in it, so the ids the plane's + own tokens name are unchanged and the cheapest path across it is the one already found. + This is what lets the search weigh a candidate for the cost of the planes it touches. + + Args: + cache: The planes the song covers, alongside what each phrase plays against them. + table: The phrases the planes may play, ``offered`` among them. + options: Which of the codec's layers the encoding is built from. + boundaries: The ticks a token starts on. + parses: The parse each plane reached under the table before ``offered`` joined it. + offered: The phrase the table gained. + + Returns: + Tuple[Parse, ...]: One parse per plane, in the order the planes were given. + """ + return tuple( + ( + parse_plane(PhraseMatcher(table, plane, cache), options, boundaries) + if cache.reading(plane, offered).reaches(transposition=options.transposition) + else parses[plane] + ) + for plane in range(len(cache.indices)) + ) diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py index e3055c5bc..a3a4f07bc 100644 --- a/src/sampletones_player/compression/search.py +++ b/src/sampletones_player/compression/search.py @@ -1,11 +1,12 @@ from typing import Dict, Final, FrozenSet, List, NamedTuple, Sequence -from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.phrase import Phrase, phrase_entry_size from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.result import Parse -from sampletones_player.compression.parse.song import parse_planes +from sampletones_player.compression.parse.song import parse_planes, parse_planes_offered from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.compression.tokens.sizes import phrase_size from sampletones_player.specification.compression import MAX_PHRASE_IDS @@ -15,6 +16,8 @@ MAX_CANDIDATE_ENTRIES: Final[int] = 200_000 MAX_SEARCH_ROUNDS: Final[int] = 64 CONFIRMED_CANDIDATES: Final[int] = 3 +MIN_OCCURRENCES: Final[int] = 2 +UNSHIFTED_OCCURRENCE_TRANSPOSE: Final[int] = 0 SHIFTED_OCCURRENCE_TRANSPOSE: Final[int] = 1 @@ -50,20 +53,21 @@ def _candidates( parses: Sequence[Parse], ) -> Dict[bytes, List[_Occurrence]]: found: Dict[bytes, List[_Occurrence]] = {} + gather = found.setdefault entries = 0 for plane, (index, parse) in enumerate(zip(indices, parses)): + differences = index.differences for span in _residue_spans(parse): if entries > MAX_CANDIDATE_ENTRIES: return found for position in range(span.start, span.end): longest = min(MAX_CANDIDATE_LENGTH, span.end - position) + occurrence = _Occurrence(plane=plane, position=position) for length in range(MIN_CANDIDATE_LENGTH, longest + 1): - key = index.differences[position : position + length - 1] - found.setdefault(key, []).append( - _Occurrence(plane=plane, position=position), - ) - entries += 1 + gather(differences[position : position + length - 1], []).append(occurrence) + + entries += max(0, longest - MIN_CANDIDATE_LENGTH + 1) return found @@ -91,13 +95,13 @@ def _gain( phrase_id: int, ) -> int: parsed = 0 - tokens = 0 - for order, occurrence in enumerate(occurrences): + for occurrence in occurrences: costs = parses[occurrence.plane].costs parsed += costs[occurrence.position + length] - costs[occurrence.position] - tokens += phrase_size(phrase_id, 0 if order == 0 else SHIFTED_OCCURRENCE_TRANSPOSE) - return parsed - tokens + stated = phrase_size(phrase_id, UNSHIFTED_OCCURRENCE_TRANSPOSE) + shifted = phrase_size(phrase_id, SHIFTED_OCCURRENCE_TRANSPOSE) + return parsed - stated - shifted * (len(occurrences) - 1) def _ranked( @@ -107,14 +111,17 @@ def _ranked( ) -> List[_Candidate]: ranked: List[_Candidate] = [] for key, occurrences in _candidates(indices, parses).items(): + if len(occurrences) < MIN_OCCURRENCES: + continue + length = len(key) + 1 spread = _spread(occurrences, length) - if len(spread) < 2: + if len(spread) < MIN_OCCURRENCES: continue first = spread[0] body = indices[first.plane].plane[first.position : first.position + length] - gain = _gain(spread, length, parses, phrase_id) - Phrase(body=body).size + gain = _gain(spread, length, parses, phrase_id) - phrase_entry_size(length) if gain > 0: ranked.append(_Candidate(gain=gain, body=body)) @@ -127,7 +134,7 @@ def _total(table: PhraseTable, parses: Sequence[Parse]) -> int: def search_phrases( - indices: Sequence[PlaneIndex], + cache: MatchCache, table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], @@ -144,7 +151,7 @@ def search_phrases( pitches is one candidate seen five times. Args: - indices: The planes and the readings of them matching is decided against. + cache: The planes the song covers, alongside what each phrase plays against them. table: The phrases the instruments seeded. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on. @@ -152,7 +159,8 @@ def search_phrases( Returns: PhraseTable: The seeded phrases alongside the ones the search earned. """ - parses = parse_planes(indices, table, options, boundaries) + indices = cache.indices + parses = parse_planes(cache, table, options, boundaries) total = _total(table, parses) for _ in range(MAX_SEARCH_ROUNDS): if len(table) == MAX_PHRASE_IDS: @@ -160,12 +168,15 @@ def search_phrases( settled = False for candidate in _ranked(indices, parses, len(table)): - enlarged = phrase_table(table.phrases + (Phrase(body=candidate.body),)) - trial = parse_planes( - indices, + offered = Phrase(body=candidate.body) + enlarged = phrase_table(table.phrases + (offered,)) + trial = parse_planes_offered( + cache, enlarged, options, boundaries, + parses=parses, + offered=offered, ) if _total(enlarged, trial) < total: table = enlarged diff --git a/tests/benchmarks/test_compression.py b/tests/benchmarks/test_compression.py index bc6a1d0db..083074ef8 100644 --- a/tests/benchmarks/test_compression.py +++ b/tests/benchmarks/test_compression.py @@ -5,21 +5,18 @@ from sampletones_core.project.project import Project from sampletones_player.compression.encode import encode_planes -from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.options import EVERY_LAYER from tests.integration.nsf.corpus import ( LONG_ARRANGEMENT, + RECONSTRUCTION, + RECONSTRUCTION_SECONDS, TARGET_SECONDS, CorpusEntry, arrangement_entry, lengthened_arrangement, + reconstruction_entry, ) -EVERY_LAYER: Final[CodecOptions] = CodecOptions( - holds=True, - phrases=True, - transposition=True, - search=True, -) MAX_ENCODER_SECONDS: Final[float] = 30.0 @@ -32,6 +29,12 @@ def long_arrangement(integration_project: Project) -> CorpusEntry: ) +@pytest.fixture(scope="module") +def dense_reconstruction() -> CorpusEntry: + """The minute of reconstructed audio the encoder works hardest on.""" + return reconstruction_entry(RECONSTRUCTION, RECONSTRUCTION_SECONDS) + + class TestTheEncoderKeepsWithinWhatAnExportAllows: """The codec runs while the user waits for the file, so its cost is held to a bound.""" @@ -39,7 +42,7 @@ def test_a_three_minute_song_encodes_within_the_budget( self, long_arrangement: CorpusEntry, ) -> None: - """The bound stands where an export would keep the user waiting, against five seconds today.""" + """The bound stands where an export would keep the user waiting, well above today's reading.""" planes = long_arrangement.planes started = process_time() encode_planes( @@ -49,3 +52,18 @@ def test_a_three_minute_song_encodes_within_the_budget( boundaries=frozenset(), ) assert process_time() - started < MAX_ENCODER_SECONDS + + def test_a_reconstruction_encodes_within_the_budget( + self, + dense_reconstruction: CorpusEntry, + ) -> None: + """A song offering no phrases leans wholly on the search, which is where the cost is.""" + planes = dense_reconstruction.planes + started = process_time() + encode_planes( + planes, + dense_reconstruction.seeds, + options=EVERY_LAYER, + boundaries=frozenset(), + ) + assert process_time() - started < MAX_ENCODER_SECONDS diff --git a/tests/integration/nsf/corpus.py b/tests/integration/nsf/corpus.py index 661075243..e0e5d088a 100644 --- a/tests/integration/nsf/corpus.py +++ b/tests/integration/nsf/corpus.py @@ -1,12 +1,27 @@ from dataclasses import dataclass from math import ceil +from random import Random from typing import Dict, Final, List, Tuple +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timers.utils import get_timer_table from sampletones_core.timing import SongTiming -from sampletones_player.builder import song_from_project, song_from_reconstruction +from sampletones_player.builder import ( + song_from_project, + song_from_reconstruction, + streams_from_instructions, +) +from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.separate import planes_from_streams @@ -19,6 +34,15 @@ ARRANGEMENT: Final[str] = "arrangement" LONG_ARRANGEMENT: Final[str] = "arrangement, three minutes" TARGET_SECONDS: Final[int] = 180 +RECONSTRUCTION: Final[str] = "reconstruction, one minute" +RECONSTRUCTION_SECONDS: Final[int] = 60 +RECONSTRUCTION_FREQUENCY: Final[int] = 60 +RECONSTRUCTION_SEED: Final[int] = 20260822 +LOWEST_SOUNDED_PITCH: Final[int] = 40 +HIGHEST_SOUNDED_PITCH: Final[int] = 100 +WIDEST_PITCH_STEP: Final[int] = 2 +SOUNDING_SHARE: Final[float] = 0.8 +NO_SEEDS: Final[Tuple[Phrase, ...]] = () @dataclass(frozen=True) @@ -126,6 +150,80 @@ def lengthened_arrangement( return lengthened(project, frames) +def _sounded_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]: + random = Random(RECONSTRUCTION_SEED) + pitch = LOWEST_SOUNDED_PITCH + instructions: Dict[ChannelName, List[InstructionUnion]] = {} + for channel in (ChannelName.PULSE1, ChannelName.PULSE2): + sounded: List[InstructionUnion] = [] + for _ in range(ticks): + pitch = _walked_pitch(random, pitch) + sounded.append( + PulseInstruction( + on=True, + pitch=pitch, + volume=random.randint(0, MAX_VOLUME), + duty_cycle=random.randint(0, MAX_DUTY_CYCLE), + ) + ) + + instructions[channel] = sounded + + triangle: List[InstructionUnion] = [] + for _ in range(ticks): + pitch = _walked_pitch(random, pitch) + triangle.append(TriangleInstruction(on=random.random() < SOUNDING_SHARE, pitch=pitch)) + + instructions[ChannelName.TRIANGLE] = triangle + instructions[ChannelName.NOISE] = [ + NoiseInstruction( + on=True, + period=random.randint(0, MAX_PERIOD), + volume=random.randint(0, MAX_VOLUME), + short=False, + ) + for _ in range(ticks) + ] + return instructions + + +def _walked_pitch(random: Random, pitch: int) -> int: + stepped = pitch + random.randint(-WIDEST_PITCH_STEP, WIDEST_PITCH_STEP) + return max(LOWEST_SOUNDED_PITCH, min(HIGHEST_SOUNDED_PITCH, stepped)) + + +def reconstruction_entry(name: str, seconds: int) -> CorpusEntry: + """A song whose channels turn over at nearly every tick, as a reconstruction of audio does. + + An exported reconstruction offers the dictionary nothing, so the search fills it alone, and + its planes change under every tick rather than resting between rows. That is the shape the + encoder works hardest on, and the one a budget is worth stating against. + + Args: + name: What the entry is called in a report. + seconds: How long the song is to last, at the console's own frame rate. + + Returns: + CorpusEntry: The song, offering no phrases of its own. + """ + tuning = Tuning() + return CorpusEntry( + name=name, + song=Song.from_streams( + streams=streams_from_instructions( + _sounded_instructions(seconds * RECONSTRUCTION_FREQUENCY), + get_timer_table(tuning), + ), + pitches=PitchTable.from_tuning(tuning), + schedule=PlaySchedule.from_parameters(RECONSTRUCTION_FREQUENCY), + loop_tick=None, + seeds=NO_SEEDS, + ), + seeds=NO_SEEDS, + tuning=tuning, + ) + + def build_corpus( instrument_catalog: Dict[str, Sample], integration_project: Project, diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py index 45cc4bf71..5ed273b42 100644 --- a/tests/integration/nsf/test_compression_report.py +++ b/tests/integration/nsf/test_compression_report.py @@ -12,6 +12,7 @@ from sampletones_player.compression.decode import decode_planes from sampletones_player.compression.dictionary.table import phrase_table from sampletones_player.compression.encode import STREAM_START, encode_planes +from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.options import CodecOptions @@ -97,16 +98,16 @@ def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int: - matcher = PhraseMatcher(phrase_table(())) + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes) + table = phrase_table(()) entries = frozenset({STREAM_START}) return sum( parse_plane( - PlaneIndex.from_plane(plane), - matcher, + PhraseMatcher(table, plane, cache), options, entries, ).size - for plane in planes + for plane in range(len(planes)) ) diff --git a/tests/unit/sampletones_player/compression/matches/test_matcher.py b/tests/unit/sampletones_player/compression/matches/test_matcher.py index ed5f6c428..f35ca0764 100644 --- a/tests/unit/sampletones_player/compression/matches/test_matcher.py +++ b/tests/unit/sampletones_player/compression/matches/test_matcher.py @@ -2,20 +2,21 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.matches.cache import KEY_LENGTH, MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.match import PhraseMatch -from sampletones_player.compression.matches.matcher import KEY_LENGTH, PhraseMatcher +from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.specification.compression import MAX_PHRASE_TICKS MOTIF: Final[bytes] = bytes((40, 44, 47)) +SINGLE_PLANE: Final[int] = 0 def found(plane: bytes, phrases: Tuple[Phrase, ...], position: int) -> List[PhraseMatch]: - index = PlaneIndex.from_plane(plane) - matcher = PhraseMatcher(phrase_table(phrases)) + cache = MatchCache((PlaneIndex.from_plane(plane),)) + matcher = PhraseMatcher(phrase_table(phrases), SINGLE_PLANE, cache) return list( matcher.matches( - index, position, min(MAX_PHRASE_TICKS, len(plane) - position), transposition=True, diff --git a/tests/unit/sampletones_player/compression/parse/test_plane.py b/tests/unit/sampletones_player/compression/parse/test_plane.py index be9866932..ebbadd425 100644 --- a/tests/unit/sampletones_player/compression/parse/test_plane.py +++ b/tests/unit/sampletones_player/compression/parse/test_plane.py @@ -2,6 +2,7 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.matches.cache import MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.matcher import PhraseMatcher from sampletones_player.compression.options import CodecOptions @@ -27,6 +28,7 @@ ) START: Final[FrozenSet[int]] = frozenset({0}) MOTIF: Final[bytes] = bytes((40, 44, 47, 44)) +SINGLE_PLANE: Final[int] = 0 def parsed( @@ -35,9 +37,9 @@ def parsed( options: CodecOptions = EVERY_LAYER, boundaries: FrozenSet[int] = START, ) -> Parse: + cache = MatchCache((PlaneIndex.from_plane(plane),)) return parse_plane( - PlaneIndex.from_plane(plane), - PhraseMatcher(phrase_table(phrases)), + PhraseMatcher(phrase_table(phrases), SINGLE_PLANE, cache), options, boundaries, ) From d587a545c37be6d75e902601cd14c0266c813cf7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 19:45:23 +0200 Subject: [PATCH 058/142] Taught: the export to report its progress and answer a cancel --- docs/development/packages.md | 2 +- src/sampletones_config/boundaries/graphs.yaml | 2 +- src/sampletones_core/exports/backend.py | 14 ++++ .../exports/implementation/bitphase.py | 32 +++++++- .../exports/implementation/famitracker.py | 23 +++++- src/sampletones_core/exports/progress.py | 53 ++++++++++++++ src/sampletones_core/exports/stage.py | 15 ++++ src/sampletones_player/builder.py | 20 ++++- src/sampletones_player/compression/encode.py | 23 ++++-- .../compression/parse/song.py | 43 ++++++++--- .../compression/progress/__init__.py | 0 .../compression/progress/monitor.py | 53 ++++++++++++++ .../compression/progress/report.py | 27 +++++++ src/sampletones_player/compression/search.py | 18 ++++- src/sampletones_player/compression/song.py | 5 ++ src/sampletones_player/export.py | 55 +++++++++++++- src/sampletones_player/song.py | 6 ++ src/sampletones_shared/exceptions/__init__.py | 2 + .../exceptions/operation.py | 10 +++ tests/suite/progress.py | 48 ++++++++++++ .../exports/test_famitracker.py | 50 +++++++++++++ .../sampletones_core/exports/test_progress.py | 47 ++++++++++++ .../compression/progress/__init__.py | 0 .../compression/progress/test_monitor.py | 61 ++++++++++++++++ .../compression/test_encode.py | 64 ++++++++++++++++ tests/unit/sampletones_player/test_export.py | 73 ++++++++++++++++++- 26 files changed, 714 insertions(+), 32 deletions(-) create mode 100644 src/sampletones_core/exports/progress.py create mode 100644 src/sampletones_core/exports/stage.py create mode 100644 src/sampletones_player/compression/progress/__init__.py create mode 100644 src/sampletones_player/compression/progress/monitor.py create mode 100644 src/sampletones_player/compression/progress/report.py create mode 100644 src/sampletones_shared/exceptions/operation.py create mode 100644 tests/suite/progress.py create mode 100644 tests/unit/sampletones_core/exports/test_progress.py create mode 100644 tests/unit/sampletones_player/compression/progress/__init__.py create mode 100644 tests/unit/sampletones_player/compression/progress/test_monitor.py diff --git a/docs/development/packages.md b/docs/development/packages.md index fd4e07f9e..81934dd5e 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -91,7 +91,7 @@ them. | `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | | `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` | -| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries | `builder.py`, `nsf/`, `driver/` | +| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries and saying which stage a run is in | `builder.py`, `nsf/`, `driver/`, `compression/` | ### The build toolchain is a developer tool diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml index 9436b7e01..46eb3c3a9 100644 --- a/src/sampletones_config/boundaries/graphs.yaml +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -22,6 +22,6 @@ player: builder.py: [song.py, registers, clock, compression] trace: [song.py, specification] nsf: [song.py, registers, specification, driver] - export.py: [builder.py, nsf, driver] + export.py: [builder.py, nsf, driver, compression] driver: [specification] driver/assembler: [driver, specification] diff --git a/src/sampletones_core/exports/backend.py b/src/sampletones_core/exports/backend.py index ee8bafc61..a4c1856b2 100644 --- a/src/sampletones_core/exports/backend.py +++ b/src/sampletones_core/exports/backend.py @@ -3,6 +3,7 @@ from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import SILENT_REPORTER, ExportReporter from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, @@ -18,6 +19,10 @@ class ExportBackend(Protocol): disk, so a format that gathers a whole reconstruction into one document writes one where another writes a file per instrument. Every scope is written to a file path the caller chooses, and :meth:`extension` names the extension it carries. + + A write reports itself as it runs and asks its reporter whether the answer is still + wanted, which is what lets a caller watch a long format and withdraw one. A caller with + nothing to tell passes :data:`SILENT_REPORTER` and hears back the file alone. """ @property @@ -42,17 +47,20 @@ def write_instrument( self, destination: Path, request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Writes one channel slice. Args: destination: The file to write. request: The slice to write. + report: Hears each stage of the write, and answers whether it goes on. Returns: ExportArtifact: The paths written and what the format's limits left out. Raises: + OperationCancelled: If ``report`` withdraws the write. OSError: If the destination cannot be written. """ @@ -60,6 +68,7 @@ def write_sample( self, destination: Path, request: SampleExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Writes every channel slice of one reconstruction. @@ -68,11 +77,13 @@ def write_sample( instrument per file writes its slices beside it, each named after the instrument it carries. request: The reconstruction's slices. + report: Hears each stage of the write, and answers whether it goes on. Returns: ExportArtifact: The paths written and what the format's limits left out. Raises: + OperationCancelled: If ``report`` withdraws the write. OSError: If the destination cannot be written. """ @@ -80,17 +91,20 @@ def write_project( self, destination: Path, request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Writes a whole composition. Args: destination: The file to write. request: The project to write. + report: Hears each stage of the write, and answers whether it goes on. Returns: ExportArtifact: The paths written and what the format's limits left out. Raises: + OperationCancelled: If ``report`` withdraws the write. OSError: If the destination cannot be written. ValueError: If the project holds more than the format has room for. """ diff --git a/src/sampletones_core/exports/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py index a267234df..6a01f18ff 100644 --- a/src/sampletones_core/exports/implementation/bitphase.py +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -1,14 +1,16 @@ from pathlib import Path -from typing import FrozenSet, List +from typing import Final, FrozenSet, List from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import SILENT_REPORTER, ExportReporter, announce from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) from sampletones_core.exports.scope import ExportScope +from sampletones_core.exports.stage import ExportStage from sampletones_core.formats.bitphase.btp import write_btp from sampletones_core.formats.bitphase.builder import ( instrument_to_bitphase, @@ -23,6 +25,8 @@ PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) WHOLE_ENVELOPE: None = None +NOTHING_WRITTEN: Final[int] = 0 +ONE_FILE: Final[int] = 1 class BitphaseBackend: @@ -49,24 +53,36 @@ def write_instrument( self, destination: Path, request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_btp(destination, instrument_to_bitphase(request)) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) def write_sample( self, destination: Path, request: SampleExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_btp(destination, sample_to_bitphase(request)) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) def write_project( self, destination: Path, request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_btp(destination, project_to_bitphase(request.project)) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) @@ -94,26 +110,35 @@ def write_instrument( self, destination: Path, request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_preset(destination, instrument_to_preset(request)) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) def write_sample( self, destination: Path, request: SampleExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: destination.parent.mkdir(parents=True, exist_ok=True) + written = len(request.instruments) + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, written) + paths: List[Path] = [] - for instrument in request.instruments: + for index, instrument in enumerate(request.instruments, start=ONE_FILE): filepath = destination.with_name( get_filename( instrument.name, EXT_FILE_JSON, ) ) - paths.extend(self.write_instrument(filepath, instrument).paths) + paths.extend(self.write_instrument(filepath, instrument, SILENT_REPORTER).paths) + announce(report, ExportStage.WRITING, index, written) return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) @@ -121,6 +146,7 @@ def write_project( self, destination: Path, request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Reports that a preset holds one instrument. diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index bf6a86010..205922c8e 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -1,15 +1,17 @@ from pathlib import Path -from typing import FrozenSet, List, Optional +from typing import Final, FrozenSet, List, Optional from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import SILENT_REPORTER, ExportReporter, announce from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) from sampletones_core.exports.scope import ExportScope +from sampletones_core.exports.stage import ExportStage from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.export import write_ftm from sampletones_core.formats.famitracker.instrument import write_fti @@ -24,6 +26,9 @@ SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) +NOTHING_WRITTEN: Final[int] = 0 +ONE_FILE: Final[int] = 1 + class FamiTrackerBackend: """Writes FamiTracker's ``.fti`` instruments and ``.ftm`` modules. @@ -48,7 +53,9 @@ def write_instrument( self, destination: Path, request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) instrument = build_instrument( STANDALONE_INSTRUMENT_INDEX, request.name, @@ -56,6 +63,7 @@ def write_instrument( loop=request.loop, ) write_fti(destination, instrument) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) return ExportArtifact( paths=(destination,), @@ -69,21 +77,26 @@ def write_sample( self, destination: Path, request: SampleExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: destination.parent.mkdir(parents=True, exist_ok=True) + written = len(request.instruments) + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, written) + paths: List[Path] = [] truncations: List[Optional[EnvelopeTruncation]] = [] - for instrument in request.instruments: + for index, instrument in enumerate(request.instruments, start=ONE_FILE): filepath = destination.with_name( get_filename( instrument.name, EXT_FILE_INSTRUMENT, ) ) - artifact = self.write_instrument(filepath, instrument) + artifact = self.write_instrument(filepath, instrument, SILENT_REPORTER) paths.extend(artifact.paths) truncations.append(artifact.truncation) + announce(report, ExportStage.WRITING, index, written) return ExportArtifact( paths=tuple(paths), @@ -94,6 +107,10 @@ def write_project( self, destination: Path, request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_ftm(destination, request.project) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=None) diff --git a/src/sampletones_core/exports/progress.py b/src/sampletones_core/exports/progress.py new file mode 100644 index 000000000..f0159edbd --- /dev/null +++ b/src/sampletones_core/exports/progress.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Callable, Final, Optional + +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.exceptions import OperationCancelled + + +@dataclass(frozen=True) +class ExportProgress: + """How far one stage of an export run has come. + + Attributes: + stage: The work the run is in the middle of, which names the unit the counts are in. + completed: What the stage has reached so far. + total: What the stage counts up to, and ``None`` where the stage runs to a length only + the data it reads decides. + """ + + stage: ExportStage + completed: int + total: Optional[int] + + +ExportReporter = Callable[[ExportProgress], bool] + + +def _carry_on(progress: ExportProgress) -> bool: # pylint: disable=unused-argument + """Answers that the run goes on, which is what a caller watching nothing asks of a stage.""" + return True + + +SILENT_REPORTER: Final[ExportReporter] = _carry_on + + +def announce( + report: ExportReporter, + stage: ExportStage, + completed: int, + total: Optional[int], +) -> None: + """Tells a reporter how far a stage has come, and unwinds the run it withdraws. + + Args: + report: Hears the stage and answers whether the run goes on. + stage: The work the run is in the middle of. + completed: What the stage has reached so far. + total: What the stage counts up to, and ``None`` where only the data decides. + + Raises: + OperationCancelled: If the run is no longer wanted. + """ + if not report(ExportProgress(stage=stage, completed=completed, total=total)): + raise OperationCancelled(f"the export was withdrawn while {stage}") diff --git a/src/sampletones_core/exports/stage.py b/src/sampletones_core/exports/stage.py new file mode 100644 index 000000000..7db1eba0d --- /dev/null +++ b/src/sampletones_core/exports/stage.py @@ -0,0 +1,15 @@ +from enum import StrEnum + + +class ExportStage(StrEnum): + """The work an export run is in the middle of, as the progress it reports names it. + + A format writes its file the moment it is handed one, and a format carrying its own player + reaches that point through two longer passes: the song is played out tick by tick, and the + result is compressed to what the console has room for. Each stage counts in its own unit, so + what a report means is read from the stage it names. + """ + + WALKING = "walking" + COMPRESSING = "compressing" + WRITING = "writing" diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index c08e297d8..a6e30b27f 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -11,6 +11,7 @@ from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers from sampletones_player.registers.streams import ChannelStreams @@ -51,6 +52,7 @@ def streams_from_instructions( def song_from_reconstruction( reconstruction: Reconstruction, loop_tick: Optional[int], + report: CodecReporter = SILENT_REPORTER, ) -> Song: """Builds the song the console plays a reconstruction as. @@ -64,11 +66,14 @@ def song_from_reconstruction( Args: reconstruction: The reconstruction to play. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. Returns: Song: The streams, the clock and the loop point as the player holds them. Raises: + OperationCancelled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ @@ -82,6 +87,7 @@ def song_from_reconstruction( schedule=PlaySchedule.from_parameters(reconstruction.config.nes_frequency), loop_tick=loop_tick, seeds=NO_SEEDS, + report=report, ) @@ -132,7 +138,10 @@ def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optio return None -def song_from_sample(request: SampleExport) -> Song: +def song_from_sample( + request: SampleExport, + report: CodecReporter = SILENT_REPORTER, +) -> Song: """Builds the song the console plays an export request as. Every slice sounds at once on the channel it was reconstructed for, and the request states @@ -144,11 +153,14 @@ def song_from_sample(request: SampleExport) -> Song: Args: request: The slices to play together. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. Returns: Song: The streams, the clock and the loop point as the player holds them. Raises: + OperationCancelled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If two slices name the same channel. """ @@ -161,6 +173,7 @@ def song_from_sample(request: SampleExport) -> Song: schedule=PlaySchedule.from_parameters(request.nes_frequency), loop_tick=loop_tick_from_instruments(request.instruments), seeds=NO_SEEDS, + report=report, ) @@ -168,6 +181,7 @@ def song_from_project( project: Project, tuning: Tuning, loop_tick: Optional[int], + report: CodecReporter = SILENT_REPORTER, ) -> Song: """Builds the song the console plays a whole project as. @@ -183,11 +197,14 @@ def song_from_project( project: The project whose song is played. tuning: Where concert pitch sits, which decides the timer each pitch sounds at. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. Returns: Song: The streams, the clock and the loop point as the player holds them. Raises: + OperationCancelled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ @@ -200,4 +217,5 @@ def song_from_project( schedule=PlaySchedule.from_parameters(project.settings.nes_frequency), loop_tick=loop_tick, seeds=phrases_from_project(project, tuning), + report=report, ) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index e34c1a149..a6749fc27 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -12,6 +12,8 @@ from sampletones_player.compression.parse.song import parse_planes from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.progress.monitor import CodecMonitor +from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter from sampletones_player.compression.search import search_phrases from sampletones_player.compression.tokens.hold import HoldToken from sampletones_player.compression.tokens.literal import LiteralToken @@ -87,14 +89,16 @@ def _settle( table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], + monitor: CodecMonitor, ) -> Tuple[PhraseTable, Tuple[Parse, ...]]: baseline = parse_planes( cache, phrase_table(()), replace(options, phrases=False), boundaries, + monitor, ) - parses = parse_planes(cache, table, options, boundaries) + parses = parse_planes(cache, table, options, boundaries, monitor) for _ in range(SETTLING_ROUNDS): pruned = prune( table, @@ -105,7 +109,8 @@ def _settle( break table = pruned - parses = parse_planes(cache, table, options, boundaries) + parses = parse_planes(cache, table, options, boundaries, monitor) + monitor.reached(len(table), table.size + sum(parse.size for parse in parses)) return table, parses @@ -116,6 +121,7 @@ def encode_planes( *, options: CodecOptions, boundaries: FrozenSet[int], + report: CodecReporter = SILENT_REPORTER, ) -> CompressedPlanes: """Compresses a song's eight planes into the dictionary and streams the driver reads. @@ -129,19 +135,26 @@ def encode_planes( seeds: The phrases the song's instruments offer. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on, beyond the first tick of the song. + report: Hears what the run holds each time it looks up, and answers whether it goes on. Returns: CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. + + Raises: + OperationCancelled: If ``report`` withdraws the run. """ cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes.planes) + monitor = CodecMonitor(report) entries = boundaries | {STREAM_START} table = phrase_table(seeds) if options.phrases else phrase_table(()) if options.phrases and options.search: - table = search_phrases(cache, table, options, entries) + table = search_phrases(cache, table, options, entries, monitor) - table, parses = _settle(cache, table, options, entries) - return CompressedPlanes( + table, parses = _settle(cache, table, options, entries, monitor) + compressed = CompressedPlanes( phrases=table, streams=PlaneOrder.across(emit(parse.tokens) for parse in parses), ticks=planes.ticks, ) + monitor.reached(len(table), compressed.size) + return compressed diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py index c48a3c245..e9dbfee00 100644 --- a/src/sampletones_player/compression/parse/song.py +++ b/src/sampletones_player/compression/parse/song.py @@ -1,4 +1,4 @@ -from typing import FrozenSet, Sequence, Tuple +from typing import FrozenSet, List, Sequence, Tuple from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.table import PhraseTable @@ -7,6 +7,7 @@ from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.plane import parse_plane from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.progress.monitor import CodecMonitor def parse_planes( @@ -14,21 +15,32 @@ def parse_planes( table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], + monitor: CodecMonitor, ) -> Tuple[Parse, ...]: """Reads every plane of a song against one dictionary. + A plane is where the run looks up: reading one is the longest stretch the codec spends + without a natural pause, so the monitor hears from it eight times over. + Args: cache: The planes the song covers, alongside what each phrase plays against them. table: The phrases the planes may play. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on. + monitor: Carries the run's reckoning of itself onward. Returns: Tuple[Parse, ...]: One parse per plane, in the order the planes were given. + + Raises: + OperationCancelled: If the run is no longer wanted. """ - return tuple( - parse_plane(PhraseMatcher(table, plane, cache), options, boundaries) for plane in range(len(cache.indices)) - ) + parses: List[Parse] = [] + for plane in range(len(cache.indices)): + parses.append(parse_plane(PhraseMatcher(table, plane, cache), options, boundaries)) + monitor.poll() + + return tuple(parses) def parse_planes_offered( @@ -36,6 +48,7 @@ def parse_planes_offered( table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], + monitor: CodecMonitor, *, parses: Sequence[Parse], offered: Phrase, @@ -52,17 +65,23 @@ def parse_planes_offered( table: The phrases the planes may play, ``offered`` among them. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on. + monitor: Carries the run's reckoning of itself onward. parses: The parse each plane reached under the table before ``offered`` joined it. offered: The phrase the table gained. Returns: Tuple[Parse, ...]: One parse per plane, in the order the planes were given. + + Raises: + OperationCancelled: If the run is no longer wanted. """ - return tuple( - ( - parse_plane(PhraseMatcher(table, plane, cache), options, boundaries) - if cache.reading(plane, offered).reaches(transposition=options.transposition) - else parses[plane] - ) - for plane in range(len(cache.indices)) - ) + trial: List[Parse] = [] + for plane in range(len(cache.indices)): + if not cache.reading(plane, offered).reaches(transposition=options.transposition): + trial.append(parses[plane]) + continue + + trial.append(parse_plane(PhraseMatcher(table, plane, cache), options, boundaries)) + monitor.poll() + + return tuple(trial) diff --git a/src/sampletones_player/compression/progress/__init__.py b/src/sampletones_player/compression/progress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/compression/progress/monitor.py b/src/sampletones_player/compression/progress/monitor.py new file mode 100644 index 000000000..10ce80f01 --- /dev/null +++ b/src/sampletones_player/compression/progress/monitor.py @@ -0,0 +1,53 @@ +from typing import Final + +from sampletones_player.compression.progress.report import CodecProgress, CodecReporter +from sampletones_shared.exceptions import OperationCancelled + +NOTHING_FOUND: Final[int] = 0 +NOTHING_LAID_DOWN: Final[int] = 0 + + +class CodecMonitor: + """Carries an encoding run's reckoning of itself to whoever asked for the run. + + Compressing a song of minutes takes seconds, and how many is decided by the song rather than + by anything the caller can work out beforehand, so the run looks up at the points where it + has something to say: after each plane it reads, after each phrase the search earns, and + after each round the table settles through. The monitor keeps what the run last reached, so a + stretch that has yet to reach a new figure still reports a true one and still asks whether the + answer is wanted. + """ + + def __init__(self, report: CodecReporter) -> None: + self._report = report + self._progress = CodecProgress(phrases=NOTHING_FOUND, size=NOTHING_LAID_DOWN) + + @property + def progress(self) -> CodecProgress: + """What the run last reached.""" + return self._progress + + def reached(self, phrases: int, size: int) -> None: + """Records a reading of the whole song and offers it onward. + + Args: + phrases: The entries the dictionary now holds. + size: The bytes the dictionary and the eight streams now take together. + + Raises: + OperationCancelled: If the run is no longer wanted. + """ + self._progress = CodecProgress(phrases=phrases, size=size) + self.poll() + + def poll(self) -> None: + """Offers what the run last reached, which is how a long stretch answers a withdrawal. + + Raises: + OperationCancelled: If the run is no longer wanted. + """ + if not self._report(self._progress): + raise OperationCancelled( + f"the encoding was withdrawn holding {self._progress.phrases} phrases " + f"and {self._progress.size} bytes" + ) diff --git a/src/sampletones_player/compression/progress/report.py b/src/sampletones_player/compression/progress/report.py new file mode 100644 index 000000000..b71a2ef7a --- /dev/null +++ b/src/sampletones_player/compression/progress/report.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Callable, Final + + +@dataclass(frozen=True) +class CodecProgress: + """What an encoding run holds at the moment it looks up from its work. + + Attributes: + phrases: The entries the dictionary has gathered. + size: The bytes the dictionary and the eight token streams take together, as of the last + reading of the whole song; a run that has yet to read one reports nothing laid down. + """ + + phrases: int + size: int + + +CodecReporter = Callable[[CodecProgress], bool] + + +def _carry_on(progress: CodecProgress) -> bool: # pylint: disable=unused-argument + """Answers that the run goes on, which is what a caller watching nothing asks of it.""" + return True + + +SILENT_REPORTER: Final[CodecReporter] = _carry_on diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py index a3a4f07bc..a30167f70 100644 --- a/src/sampletones_player/compression/search.py +++ b/src/sampletones_player/compression/search.py @@ -7,6 +7,7 @@ from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.parse.result import Parse from sampletones_player.compression.parse.song import parse_planes, parse_planes_offered +from sampletones_player.compression.progress.monitor import CodecMonitor from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.compression.tokens.sizes import phrase_size from sampletones_player.specification.compression import MAX_PHRASE_IDS @@ -51,11 +52,13 @@ def _residue_spans(parse: Parse) -> List[_Span]: def _candidates( indices: Sequence[PlaneIndex], parses: Sequence[Parse], + monitor: CodecMonitor, ) -> Dict[bytes, List[_Occurrence]]: found: Dict[bytes, List[_Occurrence]] = {} gather = found.setdefault entries = 0 for plane, (index, parse) in enumerate(zip(indices, parses)): + monitor.poll() differences = index.differences for span in _residue_spans(parse): if entries > MAX_CANDIDATE_ENTRIES: @@ -108,9 +111,10 @@ def _ranked( indices: Sequence[PlaneIndex], parses: Sequence[Parse], phrase_id: int, + monitor: CodecMonitor, ) -> List[_Candidate]: ranked: List[_Candidate] = [] - for key, occurrences in _candidates(indices, parses).items(): + for key, occurrences in _candidates(indices, parses, monitor).items(): if len(occurrences) < MIN_OCCURRENCES: continue @@ -138,6 +142,7 @@ def search_phrases( table: PhraseTable, options: CodecOptions, boundaries: FrozenSet[int], + monitor: CodecMonitor, ) -> PhraseTable: """Fills the dictionary with the phrases the song's own planes repeat. @@ -155,19 +160,24 @@ def search_phrases( table: The phrases the instruments seeded. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on. + monitor: Carries the run's reckoning of itself onward. Returns: PhraseTable: The seeded phrases alongside the ones the search earned. + + Raises: + OperationCancelled: If the run is no longer wanted. """ indices = cache.indices - parses = parse_planes(cache, table, options, boundaries) + parses = parse_planes(cache, table, options, boundaries, monitor) total = _total(table, parses) + monitor.reached(len(table), total) for _ in range(MAX_SEARCH_ROUNDS): if len(table) == MAX_PHRASE_IDS: return table settled = False - for candidate in _ranked(indices, parses, len(table)): + for candidate in _ranked(indices, parses, len(table), monitor): offered = Phrase(body=candidate.body) enlarged = phrase_table(table.phrases + (offered,)) trial = parse_planes_offered( @@ -175,6 +185,7 @@ def search_phrases( enlarged, options, boundaries, + monitor, parses=parses, offered=offered, ) @@ -182,6 +193,7 @@ def search_phrases( table = enlarged parses = trial total = _total(enlarged, trial) + monitor.reached(len(table), total) settled = True break diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py index 6e09e6572..673099605 100644 --- a/src/sampletones_player/compression/song.py +++ b/src/sampletones_player/compression/song.py @@ -8,6 +8,7 @@ from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.rebuild import streams_from_planes from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter from sampletones_player.registers.streams import ChannelStreams @@ -24,6 +25,7 @@ def compress_song( *, seeds: Sequence[Phrase], loop_tick: Optional[int] = None, + report: CodecReporter = SILENT_REPORTER, ) -> CompressedPlanes: """Compresses a song's four register streams into the dictionary and streams a file carries. @@ -36,11 +38,13 @@ def compress_song( pitches: The timer each pitch sounds at, which is what turns a timer into an index. seeds: The phrases the song's instruments offer the dictionary. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + report: Hears what the codec holds each time it looks up, and answers whether it goes on. Returns: CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. Raises: + OperationCancelled: If ``report`` withdraws the run. ValueError: If a stream sounds a timer the pitch table states no index for. """ return encode_planes( @@ -48,6 +52,7 @@ def compress_song( seeds, options=EVERY_LAYER, boundaries=_entries(loop_tick), + report=report, ) diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py index e4203a6cd..61f07d5b7 100644 --- a/src/sampletones_player/export.py +++ b/src/sampletones_player/export.py @@ -3,13 +3,21 @@ from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ( + SILENT_REPORTER, + ExportProgress, + ExportReporter, + announce, +) from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) from sampletones_core.exports.scope import ExportScope +from sampletones_core.exports.stage import ExportStage from sampletones_player.builder import song_from_sample +from sampletones_player.compression.progress.report import CodecProgress, CodecReporter from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.file import write_nsf from sampletones_player.nsf.information import NSFInformation @@ -24,6 +32,35 @@ NO_ARTIST: Final[str] = "" WHOLE_ENVELOPE: None = None +NOTHING_DONE: Final[int] = 0 +ONE_FILE: Final[int] = 1 +UNMEASURED: None = None + + +def _compressing(report: ExportReporter) -> CodecReporter: + """The codec's own reckoning, said in the words an export reports itself in. + + A codec run ends when no further phrase pays for itself, which the song decides rather than + the caller, so what it offers is the bytes it has laid down so far and no length to measure + them against. + + Args: + report: Hears each stage of the export, and answers whether it goes on. + + Returns: + CodecReporter: What the compression tells the export about itself. + """ + + def reached(progress: CodecProgress) -> bool: + return report( + ExportProgress( + stage=ExportStage.COMPRESSING, + completed=progress.size, + total=UNMEASURED, + ) + ) + + return reached class NSFBackend: @@ -68,10 +105,12 @@ def write_instrument( self, destination: Path, request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Writes a program playing one channel slice. Raises: + OperationCancelled: If ``report`` withdraws the write. SongTooLargeError: If the slice runs longer than the program area holds. OSError: If the destination cannot be written. """ @@ -81,29 +120,40 @@ def write_instrument( nes_frequency=request.nes_frequency, tuning=request.tuning, ) - return self.write_sample(destination, sample) + return self.write_sample(destination, sample, report) def write_sample( self, destination: Path, request: SampleExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Writes a program playing every channel slice of one reconstruction together. + The slices are sounded out tick by tick, the ticks are compressed to what the console + has room for, and the file is written; each of those says so as it starts, so a run of + seconds reads as the work it is doing. + Raises: + OperationCancelled: If ``report`` withdraws the write. SongTooLargeError: If the reconstruction runs longer than the program area holds. OSError: If the destination cannot be written. """ destination.parent.mkdir(parents=True, exist_ok=True) + announce(report, ExportStage.WALKING, NOTHING_DONE, UNMEASURED) + song = song_from_sample(request, _compressing(report)) + + announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) write_nsf( destination, - song_from_sample(request), + song, NSFInformation( title=request.name, artist=NO_ARTIST, ), self._image, ) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) @@ -111,6 +161,7 @@ def write_project( self, destination: Path, request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: """Reports that a program plays one reconstruction. diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index 535ce63d4..eed2f374c 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -9,6 +9,7 @@ from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter from sampletones_player.compression.song import compress_song, decompress_song from sampletones_player.registers.streams import ChannelStreams @@ -44,6 +45,7 @@ def from_streams( schedule: PlaySchedule, loop_tick: Optional[int], seeds: Sequence[Phrase], + report: CodecReporter = SILENT_REPORTER, ) -> Song: """Compresses the register values a song plays into the song the console holds. @@ -54,11 +56,14 @@ def from_streams( loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. seeds: The phrases the song's instruments offer the dictionary. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. Returns: Song: The song as the console holds it. Raises: + OperationCancelled: If ``report`` withdraws the compression. ValueError: If ``loop_tick`` lies outside the song's ticks, or a channel sounds a timer the pitch table states no index for. """ @@ -68,6 +73,7 @@ def from_streams( pitches, seeds=seeds, loop_tick=loop_tick, + report=report, ), pitches=pitches, schedule=schedule, diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 3183e4ad7..d4be86cca 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -15,6 +15,7 @@ NoLibraryDataError, UnhandledLibraryError, ) +from .operation import OperationCancelled from .player import ( DriverBuildError, PlayerError, @@ -78,6 +79,7 @@ "NoFilesToProcessError", "NoLibraryDataError", "NotAValidArchiveError", + "OperationCancelled", "PlaybackError", "PlayerError", "ReconstructionError", diff --git a/src/sampletones_shared/exceptions/operation.py b/src/sampletones_shared/exceptions/operation.py new file mode 100644 index 000000000..6d8918e0b --- /dev/null +++ b/src/sampletones_shared/exceptions/operation.py @@ -0,0 +1,10 @@ +from .base import SampleToNESError + + +class OperationCancelled(SampleToNESError): + """Raised when work in progress is withdrawn by whoever asked for it. + + Long operations look up between the steps they are made of and ask the caller whether the + answer is still wanted. A caller that says no leaves the work unwound at that point, so the + boundary that started it reports a cancelled run rather than a finished or failed one. + """ diff --git a/tests/suite/progress.py b/tests/suite/progress.py new file mode 100644 index 000000000..ae903efce --- /dev/null +++ b/tests/suite/progress.py @@ -0,0 +1,48 @@ +from typing import Final, Generic, List, Optional, Sequence, TypeVar + +from sampletones_core.exports.progress import ExportProgress +from sampletones_core.exports.stage import ExportStage + +ProgressT = TypeVar("ProgressT") + +NEVER_WITHDRAWN: Final[Optional[int]] = None +FIRST_REPORT: Final[int] = 1 + + +class RecordingReporter(Generic[ProgressT]): + """Keeps every report a run offers, and withdraws the run at a chosen one. + + A run reports itself so a caller can watch it and decide whether it goes on, so a test of + one asks two things: what the run said about itself, and what it did once told to stop. + Both are answered here, the second by counting the reports and refusing at the one named. + """ + + def __init__(self, withdraw_at: Optional[int] = NEVER_WITHDRAWN) -> None: + self.reports: List[ProgressT] = [] + self._withdraw_at = withdraw_at + + def __call__(self, progress: ProgressT) -> bool: + self.reports.append(progress) + return len(self.reports) != self._withdraw_at + + @property + def last(self) -> ProgressT: + """The report the run finished on.""" + return self.reports[-1] + + +def reported_stages(reports: Sequence[ExportProgress]) -> List[ExportStage]: + """The stages a run reached, in order, a stretch spent in one counted once. + + Args: + reports: What the run said about itself, in the order it said it. + + Returns: + List[ExportStage]: The stages, each entry a stage the run moved into. + """ + reached: List[ExportStage] = [] + for report in reports: + if not reached or reached[-1] != report.stage: + reached.append(report.stage) + + return reached diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index bf05fb85e..c7c3e82bd 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -9,15 +9,22 @@ from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend +from sampletones_core.exports.progress import ExportProgress from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.exports.scope import ExportScope +from sampletones_core.exports.stage import ExportStage from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) +from sampletones_shared.exceptions import OperationCancelled from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from tests.suite.progress import RecordingReporter NES_FREQUENCY: Final[int] = 60 +ENVELOPE_FRAMES: Final[int] = 4 +AFTER_THE_FIRST_FILE: Final[int] = 2 +ONE_FILE: Final[int] = 1 def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: @@ -168,3 +175,46 @@ def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, t artifact = backend.write_sample(tmp_path / "Kick", request) assert artifact.truncation is None + + +class TestWhatABatchSaysAboutItself: + """A reconstruction lands as a file per slice, so the run counts them as it writes them.""" + + def test_the_run_counts_every_slice_it_writes(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + sample = build_sample( + "kit", + build_instrument("lead", ENVELOPE_FRAMES), + build_instrument("bass", ENVELOPE_FRAMES), + ) + backend.write_sample(tmp_path / f"kit{EXT_FILE_INSTRUMENT}", sample, reporter) + assert reporter.last == ExportProgress( + stage=ExportStage.WRITING, + completed=len(sample.instruments), + total=len(sample.instruments), + ) + + def test_a_withdrawn_batch_leaves_the_slices_it_had_not_reached( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=AFTER_THE_FIRST_FILE) + sample = build_sample( + "kit", + build_instrument("lead", ENVELOPE_FRAMES), + build_instrument("bass", ENVELOPE_FRAMES), + ) + with pytest.raises(OperationCancelled): + backend.write_sample(tmp_path / f"kit{EXT_FILE_INSTRUMENT}", sample, reporter) + + assert not (tmp_path / f"bass{EXT_FILE_INSTRUMENT}").exists() + + def test_a_module_reports_the_one_file_it_writes(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + backend.write_instrument( + tmp_path / f"lead{EXT_FILE_INSTRUMENT}", + build_instrument("lead", ENVELOPE_FRAMES), + reporter, + ) + assert reporter.last == ExportProgress(stage=ExportStage.WRITING, completed=ONE_FILE, total=ONE_FILE) diff --git a/tests/unit/sampletones_core/exports/test_progress.py b/tests/unit/sampletones_core/exports/test_progress.py new file mode 100644 index 000000000..a39838f64 --- /dev/null +++ b/tests/unit/sampletones_core/exports/test_progress.py @@ -0,0 +1,47 @@ +from typing import Final + +import pytest + +from sampletones_core.exports.progress import ( + SILENT_REPORTER, + ExportProgress, + announce, +) +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.exceptions import OperationCancelled +from tests.suite.progress import FIRST_REPORT, RecordingReporter + +WRITTEN: Final[int] = 3 +TO_WRITE: Final[int] = 8 +UNMEASURED: None = None + + +class TestWhatACallerHearsFromAnExport: + """A run says which stage it is in and how far that stage has come.""" + + def test_a_stage_reaches_the_caller_with_what_it_has_covered(self) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) + assert reporter.last == ExportProgress(stage=ExportStage.WRITING, completed=WRITTEN, total=TO_WRITE) + + def test_a_stage_only_the_data_ends_states_no_length(self) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + announce(reporter, ExportStage.COMPRESSING, WRITTEN, UNMEASURED) + assert reporter.last.total is None + + def test_a_caller_watching_nothing_lets_every_stage_through(self) -> None: + announce(SILENT_REPORTER, ExportStage.WALKING, WRITTEN, TO_WRITE) + + +class TestWithdrawingARun: + """A caller that stops wanting the answer stops the run producing it.""" + + def test_a_withdrawn_run_unwinds_where_it_was_told(self) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + with pytest.raises(OperationCancelled): + announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) + + def test_a_withdrawal_names_the_stage_it_landed_on(self) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + with pytest.raises(OperationCancelled, match=ExportStage.WRITING.value): + announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) diff --git a/tests/unit/sampletones_player/compression/progress/__init__.py b/tests/unit/sampletones_player/compression/progress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/compression/progress/test_monitor.py b/tests/unit/sampletones_player/compression/progress/test_monitor.py new file mode 100644 index 000000000..10ab84170 --- /dev/null +++ b/tests/unit/sampletones_player/compression/progress/test_monitor.py @@ -0,0 +1,61 @@ +from typing import Final + +import pytest + +from sampletones_player.compression.progress.monitor import CodecMonitor +from sampletones_player.compression.progress.report import ( + SILENT_REPORTER, + CodecProgress, +) +from sampletones_shared.exceptions import OperationCancelled +from tests.suite.progress import FIRST_REPORT, RecordingReporter + +PHRASES_FOUND: Final[int] = 4 +BYTES_LAID_DOWN: Final[int] = 812 +NOTHING: Final[int] = 0 + + +class TestWhatARunSaysAboutItself: + """The monitor carries the codec's own reckoning out to whoever asked for the run.""" + + def test_a_reading_of_the_whole_song_reaches_the_caller(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter() + CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) + assert reporter.last == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN) + + def test_a_run_that_has_read_nothing_yet_says_so(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter() + CodecMonitor(reporter).poll() + assert reporter.last == CodecProgress(phrases=NOTHING, size=NOTHING) + + def test_a_stretch_between_readings_repeats_the_last_one(self) -> None: + """Reading a plane says nothing new about the song, and still has to be heard.""" + reporter: RecordingReporter[CodecProgress] = RecordingReporter() + monitor = CodecMonitor(reporter) + monitor.reached(PHRASES_FOUND, BYTES_LAID_DOWN) + monitor.poll() + assert reporter.last == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN) + + def test_what_the_run_last_reached_is_its_own_to_read(self) -> None: + monitor = CodecMonitor(SILENT_REPORTER) + monitor.reached(PHRASES_FOUND, BYTES_LAID_DOWN) + assert monitor.progress == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN) + + +class TestWithdrawingARun: + """A caller that stops wanting the compression stops the compression.""" + + def test_a_withdrawn_reading_unwinds_the_run(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + with pytest.raises(OperationCancelled): + CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) + + def test_a_withdrawn_poll_unwinds_the_run(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + with pytest.raises(OperationCancelled): + CodecMonitor(reporter).poll() + + def test_a_withdrawal_names_what_the_run_was_holding(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + with pytest.raises(OperationCancelled, match=str(BYTES_LAID_DOWN)): + CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index f426620a8..3e1d8bc4d 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -1,11 +1,14 @@ from typing import Final, FrozenSet, Tuple +import pytest + from sampletones_player.compression.decode import decode_planes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.encode import emit, encode_planes from sampletones_player.compression.options import CodecOptions from sampletones_player.compression.planes.channel import ChannelPlanes from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.progress.report import CodecProgress from sampletones_player.compression.tokens.hold import HoldToken from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.compression.tokens.phrase import PhraseToken @@ -14,6 +17,8 @@ PHRASE_ID_ESCAPE, TokenTag, ) +from sampletones_shared.exceptions import OperationCancelled +from tests.suite.progress import FIRST_REPORT, RecordingReporter EVERY_LAYER: Final[CodecOptions] = CodecOptions( holds=True, @@ -133,3 +138,62 @@ def test_every_plane_of_the_song_carries_a_stream(self) -> None: ) assert len(compressed.streams) == len(planes.planes) assert compressed.ticks == planes.ticks + + +class TestWhatAnEncodingSaysAboutItself: + """Compressing a song takes as long as the song decides, so it reports as it runs.""" + + def test_the_last_word_is_what_the_run_answered_with(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter() + planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) + compressed = encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + report=reporter, + ) + assert reporter.last == CodecProgress(phrases=len(compressed.phrases), size=compressed.size) + + def test_a_run_looks_up_often_enough_to_be_stopped(self) -> None: + """The stretch between two reports is one plane, so a withdrawal lands within one.""" + reporter: RecordingReporter[CodecProgress] = RecordingReporter() + planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) + encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + report=reporter, + ) + assert len(reporter.reports) > len(planes.planes) + + +class TestWithdrawingAnEncoding: + """A caller that stops wanting the song stops the work producing it.""" + + def test_a_withdrawn_run_unwinds(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) + with pytest.raises(OperationCancelled): + encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + report=reporter, + ) + + def test_a_withdrawn_run_stops_where_it_was_told(self) -> None: + reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) + with pytest.raises(OperationCancelled): + encode_planes( + planes, + (), + options=EVERY_LAYER, + boundaries=NO_BOUNDARIES, + report=reporter, + ) + + assert len(reporter.reports) == FIRST_REPORT diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py index 69811b2b2..66ee37993 100644 --- a/tests/unit/sampletones_player/test_export.py +++ b/tests/unit/sampletones_player/test_export.py @@ -6,8 +6,10 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ExportProgress from sampletones_core.exports.request import InstrumentExport, ProjectExport from sampletones_core.exports.scope import ExportScope +from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_player.export import NSFBackend @@ -18,7 +20,7 @@ STRING_FIELD_SIZE, TITLE_OFFSET, ) -from sampletones_shared.exceptions import SongTooLargeError +from sampletones_shared.exceptions import OperationCancelled, SongTooLargeError from sampletones_shared.paths.extensions import EXT_FILE_NSF from tests.suite.player import ( PLAYER_REFERENCE_PITCH, @@ -26,6 +28,7 @@ player_instrument, player_sample, ) +from tests.suite.progress import RecordingReporter, reported_stages NTSC_FREQUENCY: Final[int] = 60 SOUNDING_TICKS: Final[int] = 8 @@ -34,6 +37,8 @@ FILENAME: Final[str] = "reconstruction.nsf" SAMPLE_NAME: Final[str] = "Amen" PROJECT_TITLE: Final[str] = "Demo" +WITHDRAWN_WHILE_WALKING: Final[int] = 1 +WITHDRAWN_WHILE_COMPRESSING: Final[int] = 2 def lead_slice(name: str, frames: int) -> InstrumentExport: @@ -172,3 +177,69 @@ def test_a_project_reaches_the_console_later(self, backend: NSFBackend, tmp_path project = Project.create(title=PROJECT_TITLE, settings=ProjectSettings()) with pytest.raises(NotImplementedError): backend.write_project(tmp_path / FILENAME, ProjectExport(project=project)) + + +class TestWhatARunSaysAboutItself: + """A program takes seconds to build, so the run names the work it is doing as it does it.""" + + def test_the_run_names_each_stage_in_the_order_it_reaches_it( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + backend.write_sample(tmp_path / FILENAME, request, reporter) + assert reported_stages(reporter.reports) == [ + ExportStage.WALKING, + ExportStage.COMPRESSING, + ExportStage.WRITING, + ] + + def test_the_compression_reports_the_bytes_it_has_laid_down( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + """A search ends where the song runs out of phrases that pay, so it counts bytes alone.""" + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + backend.write_sample(tmp_path / FILENAME, request, reporter) + compressing = [report for report in reporter.reports if report.stage == ExportStage.COMPRESSING] + assert compressing and all(report.total is None for report in compressing) + + def test_a_slice_reports_the_program_its_reconstruction_would( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + alone: RecordingReporter[ExportProgress] = RecordingReporter() + backend.write_instrument(tmp_path / FILENAME, lead_slice("lead", SOUNDING_TICKS), alone) + assert reported_stages(alone.reports)[0] == ExportStage.WALKING + + +class TestWithdrawingARun: + """A caller that stops wanting the program is left with no file to open.""" + + def test_a_withdrawn_run_writes_nothing(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + with pytest.raises(OperationCancelled): + backend.write_sample(destination, request, reporter) + + assert not destination.exists() + + def test_a_run_withdrawn_mid_compression_writes_nothing( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_COMPRESSING) + request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) + with pytest.raises(OperationCancelled): + backend.write_sample(destination, request, reporter) + + assert reporter.last.stage == ExportStage.COMPRESSING + assert not destination.exists() From 2340dbbb96c50b5d519f292fbdda169bd40ebe94 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 20:10:16 +0200 Subject: [PATCH 059/142] Rebuilt: the stems card on the shared list --- docs/concepts/stems.md | 83 ++++--- docs/guide/interface.md | 18 +- .../categories/elements/global_.py | 7 +- .../categories/elements/reconstructions.py | 4 +- .../coordinators/tabs/reconstruction.py | 4 +- .../layout/general/stems.py | 1 + .../logic/main/converter.py | 5 +- .../logic/reconstruction/data.py | 29 +-- .../logic/reconstruction/reconstruction.py | 147 ++++++++---- .../parameters/reconstruction.py | 3 + src/sampletones_application/tags/general.py | 6 + .../tags/reconstructions.py | 26 +-- .../ui/elements/path.py | 11 +- .../ui/elements/stems/list.py | 194 +++++++++++++--- .../ui/panels/main/converter.py | 7 +- .../ui/panels/reconstruction/audio.py | 72 +----- .../ui/panels/reconstruction/stems.py | 186 ++++++++------- .../view_model/main/converter.py | 2 + .../view_model/reconstruction/stems.py | 26 +-- .../view_model/shared/stems.py | 24 +- src/sampletones_config/lang/en.yaml | 11 +- .../layout/general/stems.yaml | 1 + .../theme/channels/muted.yaml | 12 + .../reconstruction/stems/filter.py | 17 +- .../reconstruction/stems/selection.py | 35 +++ .../test_stems_reconstruction.py | 21 +- .../logic/reconstruction/test_data.py | 20 +- .../reconstruction/test_reconstruction.py | 75 +++++- .../ui/elements/stems/test_list.py | 188 ++++++++++++++- .../panels/reconstruction/test_stems_panel.py | 217 ++++++++++++------ .../view_model/main/test_converter.py | 2 + .../reconstruction/test_reconstruction.py | 15 +- .../reconstruction/test_stems_filter.py | 37 ++- 33 files changed, 1076 insertions(+), 430 deletions(-) create mode 100644 src/sampletones_config/theme/channels/muted.yaml create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/selection.py diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index b40b64e19..2f96d03d0 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -171,9 +171,11 @@ order: a single source names the document after the file's stem, several stems sharing one directory name it after that directory, and paths sharing no directory fall back to the `.stn` filename. -The reconstruction tab's Audio source panel shows one shortened path line per -stem, each line carrying its own full-path tooltip. Locating reveals every -recorded path according to the capability matrix in +The reconstruction tab names every recorded path on the Stems card, one row per +stem, each row carrying its own full-path tooltip and revealing its recording on +a click. The Audio source panel keeps the reconstruction's own file and the +choice between the two waveforms. Locating reveals every recorded path according +to the capability matrix in [Desktop capabilities](../development/desktop-capabilities.md): one file-manager window with every stem selected where the file manager supports it, one window per directory otherwise. @@ -181,42 +183,57 @@ per directory otherwise. ## The stems card The reconstruction tab's Stems card turns the recorded assignment into a -listener the user can steer. Each row carries one stem: a checkbox, the recorded -file name, and the channels the stem holds, in entry order. A setup line above -the rows names the assignment's hierarchy mode and channel cap. Checking a stem -admits its frames to everything the tab plays and exports; unchecking silences -them. +listener the user can steer. It draws the same list the converter's card draws: +each row carries one stem under the level it was picked on, named by its +recording, with a leading master box and a coloured box on every channel the +stem holds frames on. A setup line above the rows names the assignment's +hierarchy mode and channel cap, and a **Collapse levels** toggle draws every row +in one table where the banding is in the way. Ticking a box admits that stem's +frames on that channel to everything the tab plays and exports; unticking +silences them. ### Principles -1. **Selection filters what plays.** A checked set projects the document rather - than mutating it: the waveform shows the checked stems' frames alone, the - reconstruction toggle plays their frames mixed, original playback plays - their recordings mixed, and WAV export writes the same filtered projection. - Each answer derives from the recorded per-channel assignment, so a stem that - holds a frame owns its samples everywhere at once. -2. **Every stem starts checked.** A freshly opened stems reconstruction selects - every recorded stem, which answers the full waveform and the full original — - the unfiltered document. -3. **The selection follows the open document.** The card lives with the - reconstruction it describes: opening a document seeds the rows and the - checked set, a regenerated reconstruction keeps the checked stems and admits - the newly recorded ones, and closing the document empties the card. -4. **Listening choices stay out of the document.** The checked set is session +1. **Selection filters what plays.** A ticked set projects the document rather + than mutating it: the waveform shows the ticked frames alone, the + reconstruction toggle plays them mixed, original playback plays the + recordings heard anywhere mixed, and WAV export writes the same filtered + projection. Each answer derives from the recorded per-channel assignment, so + a stem heard on one channel keeps its samples there and stays quiet on the + next. +2. **A box stands where the choice reaches something.** A stem draws a box on a + channel exactly where the picker gave it a frame there, so every box the card + offers changes what is heard. A stem the picker never chose offers none, and + its row reads as holding no frames. +3. **Every stem starts heard everywhere it holds frames.** A freshly opened + stems reconstruction ticks every box, which answers the full waveform and the + full original — the unfiltered document. +4. **The global channel choice takes precedence.** A channel switched off for + the whole reconstruction mutes its column while leaving every value where the + reader put it, so switching the channel back on restores the per-stem choice + intact. The two compose by construction: the global choice filters the + partials, the stems choice the approximations. +5. **The selection follows the open document.** The card lives with the + reconstruction it describes: opening a document seeds the rows and the ticked + boxes, a regenerated reconstruction keeps what the reader chose and ticks the + channels a stem newly reaches, and closing the document empties the card. +6. **Listening choices stay out of the document.** The ticked set is session state, like every choice that shapes what is heard — see [Playback](../development/playback.md). Saving the reconstruction records - the assignment, never the selection. + the assignment, never the selection. So is the banding: collapsing the levels + changes how the card draws, never what it describes. ### Mechanics -`ReconstructionData.partials_for` and `ReconstructionData.waveform_data` take -the checked ids and zero the unselected stems' frames per channel before -mixing — `filter_approximations` in -`sampletones_core.reconstructions.reconstruction.stems` — keeping every array -at its unfiltered length, so a filtered mix aligns with the unfiltered one -sample for sample. `ReconstructionPanelLogic` holds the checked set and -re-answers the stems view model, the waveform, and the audio data whenever it -changes; the coordinator wires the card's `on_stems_changed` hook to that -handler. A reconstruction that records one source presents a single implicit -stem for its recording, and one that records no source shows the card's empty +`ReconstructionData.partials_for` and `ReconstructionData.waveform_data` take a +`StemSelection` — the stems each channel keeps — and zero the unselected frames +per channel before mixing (`filter_approximations` in +`sampletones_core.reconstructions.reconstruction.stems`), keeping every array at +its unfiltered length, so a filtered mix aligns with the unfiltered one sample +for sample. `original_mix_for` mixes the recordings of the stems heard on any +channel. `ReconstructionPanelLogic` holds the channels each stem is heard on and +re-answers the stems view model, the waveform, and the audio data whenever the +choice changes; the coordinator wires the card's `on_stem_channels_changed` hook +to that handler. A reconstruction that records one source presents a single row +for its recording, and one that records no source shows the card's empty state. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 2f3fac9d8..c075ccc8f 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -72,8 +72,8 @@ made with, and **By sample** gathers every version of one source audio together. If the current reconstruction has unsaved edits, you are asked whether to save it first. You can play it back and switch **Play audio source:** between **Reconstruction** and **Original audio** to -compare the two, and **Locate original audio** re-links the source file if it has -moved. +compare the two, and **Locate original audio** re-links the source files if they +have moved. To keep the reconstructions you return to within reach, right-click one — or a whole folder — and choose **Mark as favorite**, which highlights it in both views. @@ -90,10 +90,16 @@ click. Whatever you leave open is remembered, so the tree comes back the way you left it the next time you start the application. A reconstruction mixed from several recordings carries a **Stems** card listing -each of them with the channels it took. Untick one and its frames fall silent -everywhere at once — in the waveform, in playback, in the original audio, and in a -WAV export — so you can hear what each recording contributed. The ticks are yours -for the session; saving records the assignment, never the selection. +each of them under the level it was picked on, the way the converter's list showed +them while you were gathering. Every row offers a coloured box on each channel the +recording actually took, and the box at the front of the row moves all of them at +once. Untick one and those frames fall silent everywhere — in the waveform, in +playback, in the original audio, and in a WAV export — so you can hear what each +recording contributed, channel by channel. A channel you have switched off under +the waveform shows its column greyed while your ticks stay where you put them. +Click a row to show its recording in your file browser, and tick **Collapse +levels** to read the whole list as one table. The ticks are yours for the session; +saving records the assignment, never the selection. To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 0fe8d4e8d..582828340 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -62,8 +62,13 @@ class StemsElements(AbstractElement): REMOVE = "remove" DRAG_TOOLTIP = "drag_tooltip" INERT_TOOLTIP = "inert_tooltip" - STATUS_ROW = "status_row" + MISSING_TOOLTIP = "missing_tooltip" + UNOFFERED_TOOLTIP = "unoffered_tooltip" + STATUS_ROW_DRAG = "status_row_drag" + STATUS_ROW_REVEAL = "status_row_reveal" + STATUS_MASTER = "status_master" STATUS_CHANNEL = "status_channel" + STATUS_CHANNEL_MUTED = "status_channel_muted" STATUS_REMOVE = "status_remove" diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index 1a06f0e63..d06c70516 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -26,7 +26,6 @@ class ReconstructionPanelElements(AbstractElement): AUDIO_SOURCE_LABEL = "audio_source_label" AUTOSCALE_CHECKBOX = "autoscale_checkbox" RECONSTRUCTION_FILE_LABEL = "reconstruction_file_label" - ORIGINAL_AUDIO_LABEL = "original_audio_label" PATH_NOT_FOUND = "path_not_found" PATH_NOT_APPLICABLE = "path_not_applicable" ORIGINAL_AUDIO_RADIO = "original_audio_radio" @@ -41,6 +40,9 @@ class ReconstructionPanelElements(AbstractElement): STEMS_MODE_ROUND_ROBIN = "stems_mode_round_robin" STEMS_MODE_STRICT = "stems_mode_strict" STEMS_SETUP = "stems_setup" + COLLAPSE_LEVELS = "collapse_levels" + COLLAPSE_LEVELS_TOOLTIP = "collapse_levels_tooltip" + STATUS_COLLAPSE_LEVELS = "status_collapse_levels" class ReconstructionsInstrumentsElements(AbstractElement): diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index d2fa855a2..1fcfbac6f 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -201,7 +201,9 @@ def __init__( status_bar=status_bar, ) self._reconstruction_stems_panel: GUIReconstructionStemsPanel = GUIReconstructionStemsPanel( + stems_layout=layout.stems, language_manager=language_manager, + status_bar=status_bar, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS), ) self._reconstruction_audio_panel.set_collapse_handler(self._on_card_collapse_changed) @@ -236,7 +238,7 @@ def __init__( self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels - self._reconstruction_stems_panel.on_stems_changed = self._reconstruction_panel_logic.set_selected_stems + self._reconstruction_stems_panel.on_stem_channels_changed = self._reconstruction_panel_logic.set_stem_channels self._browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index 7d8f22bc9..46fa060ff 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -2,6 +2,7 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): + master_column_width: int channel_column_width: int remove_button_width: int level_strip_height: int diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index c8245dec7..688459137 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -497,7 +497,8 @@ def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: """The gathered recordings as the panel reads them, each stating where it stands. A gathered recording is named by its path, so the list reports every gesture under the - path it landed on. + path it landed on, and it offers a box on every channel the configuration enables. A + recording that has left the disk since it was gathered reports itself as missing. """ enabled = list(config.generation.channels) return tuple( @@ -505,6 +506,8 @@ def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: key=str(source.path), path=source.path, channels=frozenset(effective_channels(source, enabled)), + offered_channels=frozenset(enabled), + available=source.path.is_file(), level=level_index, position=position, level_size=len(level), diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 9b928eb94..71027aa9a 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, replace from functools import cached_property from pathlib import Path -from typing import AbstractSet, Dict, List, Optional, Self, Tuple +from typing import Dict, List, Optional, Self, Tuple import numpy as np @@ -16,6 +16,7 @@ from sampletones_core.reconstructions.reconstruction.stems.filter import ( filter_approximations, ) +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger @@ -171,8 +172,9 @@ def _stem_recording_indexes(self) -> Dict[int, int]: stems_data = self.reconstruction.stems_data return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} - def original_mix_for(self, selected_stem_ids: AbstractSet[int]) -> np.ndarray: - """The original audio of the selected stems, silence once none are selected.""" + def original_mix_for(self, selection: StemSelection) -> np.ndarray: + """The original audio of the stems heard anywhere, silence once none are.""" + selected_stem_ids = selection.any_channel() indexes = self._stem_recording_indexes recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids] if not recordings: @@ -182,18 +184,19 @@ def original_mix_for(self, selected_stem_ids: AbstractSet[int]) -> np.ndarray: def waveform_data( self, - selected_stem_ids: Optional[AbstractSet[int]] = None, + selection: Optional[StemSelection] = None, ) -> WaveformData: """Projects the slice of this data the waveform display renders. - With a stems selection, the projection carries the selected stems' frames alone - and their original mix, so the waveform answers exactly what plays. + With a stems selection, the projection carries the frames each channel's selected + stems own and the mix of the recordings heard anywhere, so the waveform answers + exactly what plays. """ - if selected_stem_ids is None: + if selection is None: return self._unfiltered_waveform() return self._filtered_waveform( - selected_stem_ids, + selection, self.reconstruction.stems_data, ) @@ -207,18 +210,18 @@ def _unfiltered_waveform(self) -> WaveformData: def _filtered_waveform( self, - selected_stem_ids: AbstractSet[int], + selection: StemSelection, stems_data: StemsData, ) -> WaveformData: """The selected stems' frames and their original mix, in the unfiltered shape.""" approximations = filter_approximations( stems_data, self.reconstruction.approximations, - selected_stem_ids, + selection, self.reconstruction.config.frame_length, ) return self._waveform_data( - self.original_mix_for(selected_stem_ids), + self.original_mix_for(selection), approximations, mix(list(approximations.values())), ) @@ -243,7 +246,7 @@ def get_partials(self, channel_names: List[ChannelName]) -> np.ndarray: def partials_for( self, channel_names: List[ChannelName], - selected_stem_ids: AbstractSet[int], + selection: StemSelection, ) -> np.ndarray: """Sums the selected channels with the unselected stems' frames silenced.""" - return self.waveform_data(selected_stem_ids).partials(channel_names) + return self.waveform_data(selection).partials(channel_names) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index f65a76e44..7ae0ebd98 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple +from typing import Callable, Dict, Final, FrozenSet, List, Optional, Protocol, Set, Tuple import numpy as np @@ -17,9 +17,12 @@ ) from sampletones_application.view_model.reconstruction.stems import ( ReconstructionStemsViewModel, - StemViewModel, ) from sampletones_application.view_model.shared.audio_data import AudioData +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.configs.library import InstructionsLibraryConfig from sampletones_core.constants.enums import AudioSourceType, ChannelName @@ -30,6 +33,7 @@ from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.exports.scope import ExportScope +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger from sampletones_shared.music import Tuning from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -40,6 +44,14 @@ open_path_in_explorer, ) +EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel( + rows=(), + channels_in_play=(), + muted_channels=frozenset(), + live=True, + collapse_levels=False, +) + class ExportServiceProtocol(Protocol): """The slice of the export service the reconstruction panel logic drives. @@ -86,8 +98,8 @@ def __init__( self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION self._playing_channels: FrozenSet[ChannelName] = frozenset() self._selected_channels: List[ChannelName] = [] - self._available_stems: FrozenSet[int] = frozenset() - self._selected_stems: FrozenSet[int] = frozenset() + self._offered_stem_channels: Dict[int, FrozenSet[ChannelName]] = {} + self._stem_channels: Dict[int, FrozenSet[ChannelName]] = {} self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None @@ -110,8 +122,8 @@ def display_reconstruction(self) -> None: self._playing_channels = frozenset(reconstruction_data.reconstruction.playing_channels) self._selected_channels = self._in_channel_order(self._playing_channels) - self._available_stems = self._all_stem_ids(reconstruction_data) - self._selected_stems = self._available_stems + self._offered_stem_channels = self._offered_channels(reconstruction_data) + self._stem_channels = dict(self._offered_stem_channels) view_model = self._build_view_model(reconstruction_data) if not view_model.audio_source_enabled: @@ -125,7 +137,7 @@ def display_reconstruction(self) -> None: self.call(self.on_waveform_source_changed, self._current_audio_source) self.call( self.on_waveform_load_changed, - reconstruction_data.waveform_data(self._selected_stems), + reconstruction_data.waveform_data(self._stem_selection), self._selected_channels, ) self._emit_audio_data() @@ -136,7 +148,7 @@ def update_reconstruction(self) -> None: return self._adopt_playing_channels(frozenset(reconstruction_data.reconstruction.playing_channels)) - self._adopt_selected_stems(self._all_stem_ids(reconstruction_data)) + self._adopt_stem_channels(self._offered_channels(reconstruction_data)) self.call(self.on_view_changed, self._build_view_model(reconstruction_data)) self.call( @@ -145,7 +157,7 @@ def update_reconstruction(self) -> None: ) self.call( self.on_waveform_update_changed, - reconstruction_data.waveform_data(self._selected_stems), + reconstruction_data.waveform_data(self._stem_selection), self._selected_channels, ) if self._current_audio_source != AudioSourceType.ORIGINAL: @@ -186,15 +198,15 @@ def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION self._playing_channels = frozenset() self._selected_channels = [] - self._available_stems = frozenset() - self._selected_stems = frozenset() + self._offered_stem_channels = {} + self._stem_channels = {} self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) self.call( self.on_stems_view_changed, ReconstructionStemsViewModel( reconstruction_loaded=False, - stems=(), + stems=EMPTY_STEMS_LIST, ), ) empty_path = ReconstructionPathViewModel( @@ -218,25 +230,30 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None: self.call(self.on_waveform_source_changed, audio_source) def set_selected_channels(self, channels: List[ChannelName]) -> None: + """Adopts the reader's channel choice, which the stems list reports as muted columns.""" self._selected_channels = channels reconstruction_data = self._reconstruction_data if not reconstruction_data: return + self.call( + self.on_stems_view_changed, + self._build_stems_view_model(reconstruction_data), + ) self.call( self.on_waveform_load_changed, - reconstruction_data.waveform_data(self._selected_stems), + reconstruction_data.waveform_data(self._stem_selection), channels, ) self._emit_audio_data() - def set_selected_stems(self, stem_ids: FrozenSet[int]) -> None: - """Adopts the reader's stem choice and re-answers playback and the waveform. + def set_stem_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> None: + """Adopts the channels one recording is heard on and re-answers playback and the waveform. The choice is listening state, so it filters what plays and what the waveform shows without touching the document. """ - self._selected_stems = stem_ids + self._stem_channels[stem_id] = channels reconstruction_data = self._reconstruction_data if not reconstruction_data: return @@ -247,55 +264,103 @@ def set_selected_stems(self, stem_ids: FrozenSet[int]) -> None: ) self.call( self.on_waveform_load_changed, - reconstruction_data.waveform_data(self._selected_stems), + reconstruction_data.waveform_data(self._stem_selection), self._selected_channels, ) self._emit_audio_data() - def _adopt_selected_stems(self, stem_ids: FrozenSet[int]) -> None: - """Carries the reader's stem choice across an edit. + def _adopt_stem_channels(self, offered: Dict[int, FrozenSet[ChannelName]]) -> None: + """Carries the reader's per-channel stem choice across an edit. + + A channel a stem keeps holding frames on keeps whatever the reader chose for it, and + one the stem reaches for the first time joins ticked, so a deliberate choice survives + while the new content is heard. A stem appearing for the first time offers everything + it holds, which is the same rule read against the nothing it offered before. + """ + self._stem_channels = { + stem_id: (self._stem_channels.get(stem_id, frozenset()) & channels) + | (channels - self._offered_stem_channels.get(stem_id, frozenset())) + for stem_id, channels in offered.items() + } + self._offered_stem_channels = offered + + @property + def _stem_selection(self) -> StemSelection: + """What the reader is listening to, read the way the filter asks for it. - A stem that keeps existing keeps whatever the reader chose for it, and one - appearing for the first time joins selected, so a deliberate choice survives - while the new stems' content is heard. + The card keeps the channels each recording is heard on; the filter asks each channel + which recordings it keeps, so the selection is that map turned around. """ - selected = (set(self._selected_stems) & stem_ids) | (stem_ids - self._available_stems) - self._available_stems = stem_ids - self._selected_stems = frozenset(selected) + channels: Dict[ChannelName, Set[int]] = {channel_name: set() for channel_name in ChannelName.items()} + for stem_id, stem_channels in self._stem_channels.items(): + for channel_name in stem_channels: + channels[channel_name].add(stem_id) + + return StemSelection( + channels={channel_name: frozenset(stem_ids) for channel_name, stem_ids in channels.items()} + ) @staticmethod - def _all_stem_ids( + def _offered_channels( reconstruction_data: ReconstructionData, - ) -> FrozenSet[int]: - return frozenset(entry.id for entry in reconstruction_data.reconstruction.stems_data.config.entries) + ) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each stem holds frames on, which is what its row offers a box for. + + A stem the picker never chose on a channel contributes nothing there whatever the + reader ticks, so the row draws a box exactly where the choice reaches something. + """ + stems_data = reconstruction_data.reconstruction.stems_data + offered: Dict[int, Set[ChannelName]] = {entry.id: set() for entry in stems_data.config.entries} + for channel_name, stem_ids in stems_data.assignments_by_channel.items(): + for stem_id in set(stem_ids): + if stem_id in offered: + offered[stem_id].add(channel_name) + + return {stem_id: frozenset(channels) for stem_id, channels in offered.items()} def _build_stems_view_model( self, reconstruction_data: ReconstructionData, ) -> ReconstructionStemsViewModel: + """The recorded assignment as the stems list draws it, banded by the picking levels.""" reconstruction = reconstruction_data.reconstruction stems_data = reconstruction.stems_data source_paths = reconstruction.audio_filepath if not source_paths: return ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=(), + stems=EMPTY_STEMS_LIST, ) - assigned_stem_ids = {stem_id for stem_ids in stems_data.assignments_by_channel.values() for stem_id in stem_ids} + recordings = {entry.id: source_paths[index] for index, entry in enumerate(stems_data.config.entries)} + levels = stems_data.config.hierarchy.levels rows = tuple( - StemViewModel( - stem_id=entry.id, - label=source_paths[index].name, - channels=tuple(entry.channels), - enabled=entry.id in assigned_stem_ids, - selected=entry.id in self._selected_stems, + StemRowViewModel( + key=str(stem_id), + path=recordings[stem_id], + channels=self._stem_channels.get(stem_id, frozenset()), + offered_channels=self._offered_stem_channels.get(stem_id, frozenset()), + available=recordings[stem_id].is_file(), + level=level_index, + position=position, + level_size=len(level), + level_count=len(levels), ) - for index, entry in enumerate(stems_data.config.entries) + for level_index, level in enumerate(levels) + for position, stem_id in enumerate(level) + ) + channels_in_play = self._in_channel_order( + frozenset(channel_name for row in rows for channel_name in row.offered_channels) ) return ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=rows, + stems=StemsListViewModel( + rows=rows, + channels_in_play=tuple(channels_in_play), + muted_channels=frozenset(channels_in_play) - frozenset(self._selected_channels), + live=True, + collapse_levels=False, + ), hierarchy_mode=stems_data.config.hierarchy.mode, channel_cap=stems_data.config.channel_cap, ) @@ -519,7 +584,7 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None: audio_snapshot = reconstruction_data.partials_for( self._selected_channels, - self._selected_stems, + self._stem_selection, ) sample_rate = reconstruction_data.reconstruction.config.sample_rate self._session_manager.set_audio_path(filepath) @@ -569,12 +634,12 @@ def _compute_audio_data(self) -> Optional[AudioData]: if reconstruction_data.original_audio is None: return None - selected_original_audio = reconstruction_data.original_mix_for(self._selected_stems) + selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection) return AudioData.from_array(selected_original_audio, sample_rate) partial_approximation = reconstruction_data.partials_for( self._selected_channels, - self._selected_stems, + self._stem_selection, ) return AudioData.from_array(partial_approximation, sample_rate) diff --git a/src/sampletones_application/parameters/reconstruction.py b/src/sampletones_application/parameters/reconstruction.py index 42adf0170..b4404ff26 100644 --- a/src/sampletones_application/parameters/reconstruction.py +++ b/src/sampletones_application/parameters/reconstruction.py @@ -6,6 +6,7 @@ from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.parameters.geometry import TabGeometry from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle @@ -32,6 +33,7 @@ class ReconstructionTabParameters: path_colors: PathColors path_status_color: BaseColor tree_colors: TreeColors + stems: StemsListLayout scheduling: SchedulingBehavior @classmethod @@ -51,5 +53,6 @@ def from_config(cls, config: LayoutConfig) -> ReconstructionTabParameters: general.colors, accent=general.colors.headers.reconstruction, ), + stems=general.stems, scheduling=config.behavior.scheduling, ) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 627260f23..e354a228b 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -260,6 +260,12 @@ Widget.THEME, "plus_minus_buttons", ) +TAG_GLOBAL_THEME_CHANNEL_MUTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_muted", +) TAG_GLOBAL_THEME_STEMS_DROP_STRIP = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 91ecfa178..8df30cf11 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -98,24 +98,12 @@ Widget.PATH, "reconstruction_file", ) -TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO = TagName( - Page.RECONSTRUCTIONS, - Panel.RECONSTRUCTION, - Widget.PATH, - "original_audio", -) TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, Widget.PANEL, "stems", ) -TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS = TagName( - Page.RECONSTRUCTIONS, - Panel.RECONSTRUCTION, - Widget.GROUP, - "stems", -) TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, @@ -128,6 +116,18 @@ Widget.TEXT, "stems_empty", ) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.CHECKBOX, + "collapse_levels", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_COLLAPSE_LEVELS = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TOOLTIP, + "collapse_levels", +) TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL = TagName( Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, @@ -154,7 +154,7 @@ ) PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel") -PRE_RECONSTRUCTION_STEM = compose_tag("reconstruction", "stem") +PRE_RECONSTRUCTION_STEMS = compose_tag("reconstruction", "stems") SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message" SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size" SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window" diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index 0c394378d..e751b6280 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -8,6 +8,7 @@ SUF_GROUP, SUF_HANDLER_REGISTRY, SUF_LABEL, + SUF_TOOLTIP, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -66,6 +67,7 @@ def __init__( self.label_tag = compose_tag(tag, SUF_LABEL) self.handler_tag = compose_tag(tag, SUF_HANDLER_REGISTRY) self.group_tag = compose_tag(tag, SUF_GROUP) + self.tooltip_tag = compose_tag(tag, SUF_TOOLTIP) self._create_text() self._create_handler() @@ -87,7 +89,7 @@ def _create_text(self) -> None: FontRegistry.bind_to_item(self.label_tag, self.font) FontRegistry.bind_to_item(self.tag, self.font) - self.tooltip = show_tooltip(self.tag, self.path_text) + self.tooltip = show_tooltip(self.tag, self.path_text, tag=self.tooltip_tag) @property def path_text(self) -> str: @@ -162,8 +164,15 @@ def get_path(self) -> Path: return self.path def destroy(self) -> None: + """Takes the whole widget away, the hover explanation among it. + + DearPyGui keeps a tooltip beside the item it explains rather than inside it, so a + tooltip outliving its path would go on triggering off whatever moved into that place. + """ dpg_delete_item(self.handler_tag) + dpg_delete_item(self.tooltip_tag) dpg_delete_item(self.tag) + dpg_delete_item(self.group_tag) class GUIDestinationPathText(GUIPathText): diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 67483af94..51e29d9ce 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Dict, FrozenSet, Optional, Tuple +from typing import Any, Callable, Dict, FrozenSet, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -20,6 +20,7 @@ SUF_TEXT, SUF_TOOLTIP, SUF_WELL, + TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_STEMS_DROP_STRIP, TAG_GLOBAL_THEME_STEMS_ROW, @@ -46,7 +47,7 @@ from sampletones_shared.types.callback import MessageCallback, StringCallback from sampletones_shared.utils.callbacks import CallbackMixin -RowShape = Tuple[Tuple[str, ...], Tuple[Tuple[str, int], ...]] +RowShape = Tuple[Tuple[str, ...], bool, Tuple[Tuple[str, int, Tuple[str, ...]], ...]] ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] KeyOffsetCallback = Callable[[str, int], None] @@ -59,9 +60,10 @@ class GUIStemsList(CallbackMixin): Both the converter's gathered recordings and a reconstruction's recorded assignment are the same list, so one definition draws them and each owner turns on the affordances it can honour: ``draggable`` makes a row itself the thing you drag and opens a drop strip between - the bands, and ``removable`` gives it the danger-toned button that takes it out. Rows are - keyed by the identity their owner reports gestures under, and every column lines up across - the bands because the table holds one fixed column per channel in play. + the bands, ``master_checkbox`` gives the row a leading box moving every channel at once, + and ``removable`` gives it the danger-toned button that takes it out. Rows are keyed by the + identity their owner reports gestures under, and every column lines up across the bands + because each table holds one fixed column per channel in play. """ def __init__( @@ -73,6 +75,7 @@ def __init__( status_bar: GUIStatusBar, draggable: bool, removable: bool, + master_checkbox: bool, ) -> None: self._prefix = prefix self._layout = layout @@ -80,40 +83,62 @@ def __init__( self._status_bar = status_bar self._draggable = draggable self._removable = removable + self._master_checkbox = master_checkbox self._level_template = language_manager["global.stems.template.level_caption"] self._lbl_remove = language_manager["global.stems.label.remove"] self._msg_drag = language_manager["global.stems.message.drag_tooltip"] self._msg_inert = language_manager["global.stems.message.inert_tooltip"] + self._msg_missing = language_manager["global.stems.message.missing_tooltip"] + self._msg_unoffered = language_manager["global.stems.message.unoffered_tooltip"] self._payload = compose_tag(prefix, SUF_PAYLOAD) self._well_tag = compose_tag(prefix, SUF_WELL) self._body_tag = compose_tag(self._well_tag, SUF_GROUP) + self._table_tag = compose_tag(prefix, SUF_TABLE) self._name_handler_tag = compose_tag(prefix, SUF_TEXT, SUF_HANDLER_REGISTRY) self._channel_handler_tag = compose_tag(prefix, SUF_CHANNELS, SUF_HANDLER_REGISTRY) + self._master_handler_tag = compose_tag(prefix, SUF_CHECKBOX, SUF_HANDLER_REGISTRY) self._button_handler_tag = compose_tag(prefix, SUF_BUTTON, SUF_HANDLER_REGISTRY) self._rows: Dict[str, StemRowViewModel] = {} self._channels_in_play: Tuple[ChannelName, ...] = () - self._shape: RowShape = ((), ()) + self._muted_channels: FrozenSet[ChannelName] = frozenset() + self._shape: RowShape = ((), False, ()) self._live = True self.on_channels_changed: Optional[ChannelsCallback] = None self.on_remove_requested: Optional[StringCallback] = None self.on_menu_requested: Optional[StringCallback] = None + self.on_row_activated: Optional[StringCallback] = None self.on_dropped_on_row: Optional[KeyPairCallback] = None self.on_dropped_on_level: Optional[KeyOffsetCallback] = None @property def _handler_tags(self) -> Tuple[str, ...]: """The registries the rows bind to, one per widget kind the list draws.""" - return (self._name_handler_tag, self._channel_handler_tag, self._button_handler_tag) + return ( + self._name_handler_tag, + self._channel_handler_tag, + self._master_handler_tag, + self._button_handler_tag, + ) @property def tag(self) -> str: """The recessed region the list is drawn in, which is what an owner shows and hides.""" return self._well_tag + @property + def activatable(self) -> bool: + """The owner answers a click on a row, so the list hands one on rather than absorbing it.""" + return self.on_row_activated is not None + + @property + def table_tag(self) -> str: + """The one table every row stands in while the levels are collapsed.""" + return self._table_tag + def create(self, parent: str, *, show: bool = True) -> None: """Build the list's recessed region and the handlers its rows share.""" self._create_handlers() @@ -128,6 +153,7 @@ def create(self, parent: str, *, show: bool = True) -> None: def update_view(self, view_model: StemsListViewModel) -> None: self._rows = {row.key: row for row in view_model.rows} self._channels_in_play = view_model.channels_in_play + self._muted_channels = view_model.muted_channels self._live = view_model.live self._sync_rows(view_model) for row in view_model.rows: @@ -154,6 +180,9 @@ def _create_handlers(self) -> None: with dpg.item_handler_registry(tag=self._channel_handler_tag): dpg.add_item_hover_handler(callback=self._hover_callback(self._channel_message)) + with dpg.item_handler_registry(tag=self._master_handler_tag): + dpg.add_item_hover_handler(callback=self._hover_callback(self._master_message)) + with dpg.item_handler_registry(tag=self._button_handler_tag): dpg.add_item_hover_handler(callback=self._hover_callback(self._remove_message)) @@ -175,13 +204,21 @@ def hover_callback(_sender: Sender, app_data: int) -> None: return hover_callback def _sync_rows(self, view_model: StemsListViewModel) -> None: - """Rebuild the bands when the recordings or the levels change, keep them otherwise.""" + """Rebuild the bands when the recordings, their levels or their boxes change. + + Collapsing the levels draws every recording in one table, which is the shape a list + takes where the bands are a record of the setup rather than somewhere to drop onto. + """ shape = self._row_shape(view_model) if shape == self._shape: return self._shape = shape dpg.delete_item(self._body_tag, children_only=True) + if view_model.collapse_levels: + self._create_table(self._table_tag, view_model, view_model.rows) + return + for level_index in range(view_model.level_count): self._create_level_strip(level_index) self._create_level_caption(level_index) @@ -192,14 +229,22 @@ def _sync_rows(self, view_model: StemsListViewModel) -> None: @staticmethod def _row_shape(view_model: StemsListViewModel) -> RowShape: - """What the bands are built from: the channels in play, and where each row stands. + """What the bands are built from: the columns, the banding, and where each row stands. Which channels a row holds is drawn onto the widgets already standing, so a tick keeps the bands as they are and the pointer keeps whatever it was over. """ return ( tuple(str(channel_name) for channel_name in view_model.channels_in_play), - tuple((row.key, row.level) for row in view_model.rows), + view_model.collapse_levels, + tuple( + ( + row.key, + row.level, + tuple(sorted(str(channel_name) for channel_name in row.offered_channels)), + ) + for row in view_model.rows + ), ) def _create_level_strip(self, position: int) -> None: @@ -231,13 +276,29 @@ def _create_level_caption(self, level_index: int) -> None: FontRegistry.bind_to_item(caption, Font.MONO_SMALL) def _create_level_table(self, level_index: int, view_model: StemsListViewModel) -> None: + self._create_table( + self.level_tag(level_index, SUF_TABLE), + view_model, + [row for row in view_model.rows if row.level == level_index], + ) + + def _create_table( + self, + tag: str, + view_model: StemsListViewModel, + rows: Sequence[StemRowViewModel], + ) -> None: + """One grid of rows: the master box, the name, a column per channel in play, the button.""" with dpg.table( - tag=self.level_tag(level_index, SUF_TABLE), + tag=tag, parent=self._body_tag, header_row=False, policy=dpg.mvTable_SizingFixedFit, resizable=False, ): + if self._master_checkbox: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.master_column_width) + dpg.add_table_column(width_stretch=True) for _channel_name in view_model.channels_in_play: dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width) @@ -245,12 +306,14 @@ def _create_level_table(self, level_index: int, view_model: StemsListViewModel) if self._removable: dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) - for row in view_model.rows: - if row.level == level_index: - self._create_row(row, view_model) + for row in rows: + self._create_row(row, view_model) def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: with dpg.table_row(tag=self.row_tag(row.key, SUF_GROUP)): + if self._master_checkbox: + self._create_master(row) + self._create_name(row) for channel_name in view_model.channels_in_play: self._create_channel(row, channel_name) @@ -258,6 +321,16 @@ def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> if self._removable: self._create_remove(row) + def _create_master(self, row: StemRowViewModel) -> None: + """The box moving every channel the row offers at once.""" + master = dpg.add_checkbox( + tag=self.row_tag(row.key, SUF_CHECKBOX), + default_value=row.takes_part, + user_data=row.key, + callback=self._on_master_changed, + ) + dpg.bind_item_handler_registry(master, self._master_handler_tag) + def _create_name(self, row: StemRowViewModel) -> None: """The row itself: what names the recording, what you drag it by, and what you drop onto.""" name = dpg.add_selectable( @@ -277,6 +350,15 @@ def _create_name(self, row: StemRowViewModel) -> None: show_tooltip(name, self._row_explanation(row), text_tag=self.row_tag(row.key, SUF_TOOLTIP)) def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: + """The box giving the recording a channel, where the recording holds frames on it. + + A recording holding none on this channel leaves the cell open, so the columns keep + lining up across the rows while only a reachable choice is drawn. + """ + if channel_name not in row.offered_channels: + dpg.add_spacer() + return + checkbox_tag = self.channel_tag(row.key, channel_name) dpg.add_checkbox( label=channel_label(self._language_manager, channel_name), @@ -285,7 +367,6 @@ def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> N user_data=(row.key, channel_name), callback=self._on_channels_changed, ) - ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(checkbox_tag) dpg.bind_item_handler_registry(checkbox_tag, self._channel_handler_tag) def _create_remove(self, row: StemRowViewModel) -> None: @@ -303,28 +384,50 @@ def _create_remove(self, row: StemRowViewModel) -> None: def _render_row(self, row: StemRowViewModel) -> None: """Draw what the row currently holds onto the widgets it already stands as. - A row holding no channel greys through its theme rather than through ``enabled``, so it - answers a drag and a right-click as readily as one that takes part. + A row contributing nothing greys through its theme rather than through ``enabled``, so + it answers a drag and a right-click as readily as one in play. A box on a channel + switched off elsewhere takes the muted tone and stays as clickable as any other. """ - for channel_name in self._channels_in_play: + for channel_name in self._row_boxes(row): tag = self.channel_tag(row.key, channel_name) dpg_configure_item(tag, enabled=self._live) dpg_set_value(tag, channel_name in row.channels) + ThemeRegistry.get(self._channel_theme(channel_name)).bind_to_item(tag) name_tag = self.row_tag(row.key, SUF_TEXT) dpg_set_value(name_tag, False) dpg_configure_item(name_tag, enabled=self._live) dpg_set_value(self.row_tag(row.key, SUF_TOOLTIP), self._row_explanation(row)) - row_theme = TAG_GLOBAL_THEME_STEMS_ROW if row.takes_part else TAG_GLOBAL_THEME_STEMS_ROW_INERT + row_theme = TAG_GLOBAL_THEME_STEMS_ROW if row.in_play else TAG_GLOBAL_THEME_STEMS_ROW_INERT ThemeRegistry.get(row_theme).bind_to_item(name_tag) + if self._master_checkbox: + master_tag = self.row_tag(row.key, SUF_CHECKBOX) + dpg_configure_item(master_tag, enabled=self._live and row.offers_channels) + dpg_set_value(master_tag, row.takes_part) + if self._removable: dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live) + def _row_boxes(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: + """The channels the row actually draws a box for, in the order the columns stand.""" + return tuple(channel_name for channel_name in self._channels_in_play if channel_name in row.offered_channels) + + def _channel_theme(self, channel_name: ChannelName) -> str: + """The tone a channel's boxes take: its own colour, muted where the channel is off.""" + if channel_name in self._muted_channels: + return TAG_GLOBAL_THEME_CHANNEL_MUTED + + return CHANNEL_THEME_TAGS[channel_name] + def _row_explanation(self, row: StemRowViewModel) -> str: - """What the row's hover states: where the recording is, how it moves, and where it holds - no channel, why it is greyed out.""" + """What the row's hover states: where the recording is, why it is greyed out where it + contributes nothing, and how it moves where the list lets it.""" lines = [str(row.path)] - if not row.takes_part: + if not row.available: + lines.append(self._msg_missing) + elif not row.offers_channels: + lines.append(self._msg_unoffered) + elif not row.takes_part: lines.append(self._msg_inert) if self._draggable: @@ -337,7 +440,13 @@ def _name_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: if row is None: return "" - return self._language_manager["global.stems.message.status_row"].format(name=row.name) + if self._draggable: + return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name) + + if self.activatable: + return self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name) + + return row.name def _channel_message( self, @@ -350,11 +459,25 @@ def _channel_message( if row is None: return "" + channel = channel_label(self._language_manager, channel_name) + if channel_name in self._muted_channels: + return self._language_manager["global.stems.message.status_channel_muted"].format( + channel=channel, + name=row.name, + ) + return self._language_manager["global.stems.message.status_channel"].format( - channel=channel_label(self._language_manager, channel_name), + channel=channel, name=row.name, ) + def _master_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._rows.get(user_data) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_master"].format(name=row.name) + def _remove_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: row = self._rows.get(user_data) if row is None: @@ -369,19 +492,32 @@ def _on_channels_changed( user_data: Tuple[str, ChannelName], ) -> None: key, _channel_name = user_data + row = self._rows.get(key) + if row is None: + return + channels = frozenset( - channel_name - for channel_name in self._channels_in_play - if dpg.get_value(self.channel_tag(key, channel_name)) + channel_name for channel_name in self._row_boxes(row) if dpg.get_value(self.channel_tag(key, channel_name)) ) self.call(self.on_channels_changed, key, channels) + def _on_master_changed(self, _sender: Sender, value: bool, user_data: str) -> None: + """The master box hands the row every channel it offers, or takes them all away.""" + row = self._rows.get(user_data) + if row is None: + return + + channels = frozenset(self._row_boxes(row)) if value else frozenset() + self.call(self.on_channels_changed, user_data, channels) + def _on_remove_requested(self, _sender: Sender, _app_data: Any, user_data: str) -> None: self.call(self.on_remove_requested, user_data) - def _on_name_clicked_off(self, sender: Sender, _value: bool, _user_data: str) -> None: - """Let go of a clicked row: the list names recordings and moves them, it selects none.""" + def _on_name_clicked_off(self, sender: Sender, _value: bool, user_data: str) -> None: + """Let go of a clicked row and hand it on: the list names recordings, it selects none.""" dpg_set_value(sender, False) + if self.activatable: + self.call(self.on_row_activated, user_data) def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: mouse_button, clicked_item = app_data diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 75ffa8beb..0532e6c15 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -84,9 +84,9 @@ class GUIConverterPanel(GUIPanel): """The card a conversion is set up on: what it converts, how, and what it is doing. In stems mode the card lists the recordings being gathered under the levels they pick on. - A row is dragged by its handle onto another row to share that row's level, or onto the gap - between two levels to open one of its own; the row's menu names the same moves in words and - offers the recording's own filesystem actions. + A row is dragged onto another row to share that row's level, or onto the gap between two + levels to open one of its own; the row's menu names the same moves in words and offers the + recording's own filesystem actions. """ def __init__( @@ -141,6 +141,7 @@ def __init__( status_bar=status_bar, draggable=True, removable=True, + master_checkbox=False, ) super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 564e7d365..26d2b5be7 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -1,14 +1,12 @@ -from typing import Callable, List, Optional, Tuple +from typing import Callable, Optional import dearpygui.dearpygui as dpg from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.path import PathColors -from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, - TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, ) @@ -33,6 +31,13 @@ class GUIReconstructionAudioPanel(GUIPanel): + """Where the reconstruction came from and which of the two waveforms plays. + + The card names the reconstruction's own file and offers the choice between the + reconstruction and the audio it was built from. The recordings behind that audio are named + by the stems card, one row each. + """ + def __init__( self, *, @@ -48,8 +53,6 @@ def __init__( self._path_status_color = path_status_color self._reconstruction_file_path: GUIPathText - self._original_audio_path: GUIPathText - self._original_audio_stem_paths: List[GUIPathText] = [] self.on_audio_source_changed: Optional[Callable[[AudioSourceType], None]] = None @@ -85,10 +88,8 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: self._reconstruction_file_path, view_model.reconstruction_file, ) - self._render_original_audio(view_model.original_audio) - dpg_configure_item( - TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, enabled=view_model.audio_source_enabled, ) if not view_model.audio_source_enabled: @@ -97,45 +98,6 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: self._lbl_reconstruction_radio, ) - def _render_original_audio(self, view_model: ReconstructionPathViewModel) -> None: - """Draws the original-audio location: one line per recorded stem, one line otherwise.""" - if view_model.state is ReconstructionPathState.MULTIPLE: - self._render_stem_paths(view_model.paths) - return - - self._clear_stem_paths() - self._render_path(self._original_audio_path, view_model) - - def _render_stem_paths(self, paths: Tuple[str, ...]) -> None: - while len(self._original_audio_stem_paths) > len(paths): - self._original_audio_stem_paths.pop().destroy() - - for index, path in enumerate(paths): - widget = ( - self._original_audio_stem_paths[index] - if index < len(self._original_audio_stem_paths) - else self._create_stem_path(index) - ) - widget.set_path(path) - - def _clear_stem_paths(self) -> None: - while len(self._original_audio_stem_paths) > 1: - self._original_audio_stem_paths.pop().destroy() - - def _create_stem_path(self, index: int) -> GUIPathText: - widget = GUIPathText( - tag=compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO, str(index)), - path=None, - parent=self._body_container, - color=self._path_colors.default, - hover_color=self._path_colors.hover, - status_message=self._msg_path_status, - font=Font.REGULAR_SMALL, - status_bar=self._status_bar, - ) - self._original_audio_stem_paths.append(widget) - return widget - def _render_path( self, path_widget: GUIPathText, @@ -169,21 +131,7 @@ def _create_path_display(self) -> None: font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) - self._original_audio_path = GUIPathText( - tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO, - path=None, - parent=self._body_container, - color=self._path_colors.default, - hover_color=self._path_colors.hover, - status_message=self._msg_path_status, - prefix=self._language_manager["reconstructions.reconstruction.label.original_audio_label"], - font=Font.REGULAR_SMALL, - status_bar=self._status_bar, - ) - self._original_audio_stem_paths.append(self._original_audio_path) - self._reconstruction_file_path.set_status("", self._path_status_color) - self._original_audio_path.set_status("", self._path_status_color) def _create_audio_source_radio_buttons(self) -> None: with dpg.group( @@ -206,7 +154,7 @@ def _create_audio_source_radio_buttons(self) -> None: ) dpg_configure_item( - TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, enabled=False, ) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index 9ceab0787..d2e4a0dca 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -1,56 +1,76 @@ -from typing import Any, Callable, FrozenSet, List, Optional, Tuple +from typing import Callable, FrozenSet, Optional import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_CHANNELS, SUF_GROUP +from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.tags.reconstructions import ( - PRE_RECONSTRUCTION_STEM, - TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS, + PRE_RECONSTRUCTION_STEMS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_COLLAPSE_LEVELS, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel -from sampletones_application.utils.gui.dpg import dpg_delete_item +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.stems import ( ReconstructionStemsViewModel, - StemViewModel, ) -from sampletones_core.constants.enums import HierarchyMode +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_shared.types.application import Sender +from sampletones_shared.utils.system.paths import open_path_in_explorer +MINIMUM_COLLAPSIBLE_LEVELS: int = 2 -class GUIReconstructionStemsPanel(GUIPanel): - """The stems of the loaded reconstruction, one checkbox row per stem. - A checked stem keeps its frames in what plays; unchecking silences them across the - waveform, playback, and WAV export. Rows mirror the channel checkboxes: a stem with - no assigned frames is shown disabled, a single-source reconstruction shows one row - the same way, and a loaded reconstruction recording no source shows the empty state. +class GUIReconstructionStemsPanel(GUIPanel): + """The recordings a loaded reconstruction was built from, as the stems list draws them. + + Each row names one recording under the level it was picked on and offers a box per channel + it holds frames on: ticking one keeps that channel's frames in what plays, and the leading + box moves every channel the recording offers at once. A channel switched off for the whole + reconstruction shows its boxes muted while they stay as clickable as any other, so the + reader's per-recording choice keeps standing. A click on a row shows the recording where it + sits on disk. """ def __init__( self, *, + stems_layout: StemsListLayout, language_manager: LanguageManager, + status_bar: GUIStatusBar, initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager self._lbl_stems = language_manager["reconstructions.reconstruction.label.stems"] self._lbl_empty = language_manager["reconstructions.reconstruction.label.stems_empty"] + self._lbl_collapse = language_manager["reconstructions.reconstruction.label.collapse_levels"] self._setup_template = language_manager["reconstructions.reconstruction.template.stems_setup"] self._mode_labels = { HierarchyMode.ROUND_ROBIN: language_manager["reconstructions.reconstruction.label.stems_mode_round_robin"], HierarchyMode.STRICT: language_manager["reconstructions.reconstruction.label.stems_mode_strict"], } - self._stem_rows: List[Tuple[int, str]] = [] + self._status_bar = status_bar + self._view_model: Optional[ReconstructionStemsViewModel] = None + self._stems_list = GUIStemsList( + prefix=PRE_RECONSTRUCTION_STEMS, + layout=stems_layout, + language_manager=language_manager, + status_bar=status_bar, + draggable=False, + removable=False, + master_checkbox=True, + ) - self.on_stems_changed: Optional[Callable[[FrozenSet[int]], None]] = None + self.on_stem_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None super().__init__(tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS) self._enable_vertical_collapse( @@ -58,6 +78,11 @@ def __init__( auto_height=True, ) + @property + def stems_list(self) -> GUIStemsList: + """The list the recordings are drawn in, which is what addresses their widgets.""" + return self._stems_list + def create_panel(self, parent: str) -> None: with self._collapsible_card( parent, @@ -78,20 +103,50 @@ def create_panel(self, parent: str) -> None: parent=self._body_container, show=False, ) - dpg.add_group( - tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS, - parent=self._body_container, - ) + self._create_collapse_toggle() + self._stems_list.create(self._body_container, show=False) + + self._stems_list.on_channels_changed = self._on_channels_changed + self._stems_list.on_row_activated = self._on_row_activated def update_view(self, view_model: ReconstructionStemsViewModel) -> None: - self._sync_rows(view_model.stems) + self._view_model = view_model self._render_setup_line(view_model) - dpg.configure_item( + dpg_configure_item( TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, show=view_model.show_empty_state, ) - for row in view_model.stems: - self._render_row(row) + dpg_configure_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, + show=view_model.stems.level_count >= MINIMUM_COLLAPSIBLE_LEVELS, + ) + dpg_configure_item(self._stems_list.tag, show=bool(view_model.stems.rows)) + self._stems_list.update_view(self._banded(view_model.stems)) + + def _create_collapse_toggle(self) -> None: + """The reader's choice of banding: one table, or a caption per picking level.""" + checkbox = dpg.add_checkbox( + label=self._lbl_collapse, + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, + parent=self._body_container, + callback=self._on_collapse_toggled, + show=False, + ) + FontRegistry.bind_to_item(checkbox, Font.REGULAR_SMALL) + show_tooltip( + checkbox, + self._language_manager["reconstructions.reconstruction.message.collapse_levels_tooltip"], + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_COLLAPSE_LEVELS, + ) + self._status_bar.bind_to_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, + self._language_manager["reconstructions.reconstruction.message.status_collapse_levels"], + ) + + def _banded(self, stems: StemsListViewModel) -> StemsListViewModel: + """The list as the card draws it, under the banding the reader last asked for.""" + collapsed = bool(dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS)) + return stems.model_copy(update={"collapse_levels": collapsed}) def _render_setup_line( self, @@ -99,7 +154,7 @@ def _render_setup_line( ) -> None: if view_model.show_setup_line: mode = "" if view_model.hierarchy_mode is None else self._mode_labels[view_model.hierarchy_mode] - dpg.set_value( + dpg_set_value( TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, self._setup_template.format( mode=mode, @@ -107,77 +162,20 @@ def _render_setup_line( ), ) - dpg.configure_item( + dpg_configure_item( TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, show=view_model.show_setup_line, ) - def _sync_rows(self, rows: Tuple[StemViewModel, ...]) -> None: - """Rebuilds the checkbox rows when the stem set changes, keeps them otherwise.""" - expected_ids = [row.stem_id for row in rows] - current_ids = [stem_id for stem_id, _tag in self._stem_rows] - if current_ids != expected_ids: - for _stem_id, tag in self._stem_rows: - dpg_delete_item(tag) - - self._stem_rows = [(row.stem_id, self._create_stem_row(row)) for row in rows] - - def _create_stem_row(self, row: StemViewModel) -> str: - group_tag = self._stem_group_tag(row.stem_id) - checkbox_tag = self._stem_checkbox_tag(row.stem_id) - with dpg.group( - horizontal=True, - tag=group_tag, - parent=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_STEMS, - ): - dpg.add_checkbox( - label=row.label, - tag=checkbox_tag, - callback=self._on_stem_changed, - ) - dpg.add_text( - self._channels_text(row), - tag=self._stem_channels_tag(row.stem_id), - ) - - FontRegistry.bind_to_item(checkbox_tag, Font.REGULAR_SMALL) - return group_tag - - def _render_row(self, row: StemViewModel) -> None: - checkbox_tag = self._stem_checkbox_tag(row.stem_id) - dpg.configure_item(checkbox_tag, label=row.label) - dpg.configure_item(checkbox_tag, enabled=row.enabled) - dpg.set_value(checkbox_tag, row.selected) - dpg.set_value( - self._stem_channels_tag(row.stem_id), - self._channels_text(row), - ) + def _on_collapse_toggled(self, _sender: Sender, _value: bool) -> None: + if self._view_model is not None: + self._stems_list.update_view(self._banded(self._view_model.stems)) - def _on_stem_changed(self, _sender: Sender, _app_data: Any) -> None: - selected = frozenset( - stem_id for stem_id, _tag in self._stem_rows if dpg.get_value(self._stem_checkbox_tag(stem_id)) - ) - self.call(self.on_stems_changed, selected) - - def _channels_text(self, row: StemViewModel) -> str: - return ", ".join(channel_label(self._language_manager, channel) for channel in row.channels) - - @staticmethod - def _stem_checkbox_tag(stem_id: int) -> str: - return compose_tag(PRE_RECONSTRUCTION_STEM, str(stem_id)) + def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: + self.call(self.on_stem_channels_changed, int(key), channels) - @staticmethod - def _stem_group_tag(stem_id: int) -> str: - return compose_tag( - PRE_RECONSTRUCTION_STEM, - str(stem_id), - SUF_GROUP, - ) - - @staticmethod - def _stem_channels_tag(stem_id: int) -> str: - return compose_tag( - PRE_RECONSTRUCTION_STEM, - str(stem_id), - SUF_CHANNELS, - ) + def _on_row_activated(self, key: str) -> None: + """A clicked row shows its recording where it sits on disk.""" + row = self._stems_list.row(key) + if row is not None and row.available: + open_path_in_explorer(row.path) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index ad190ed17..757e4b418 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -107,7 +107,9 @@ def stems_list(self) -> StemsListViewModel: return StemsListViewModel( rows=self.stem_sources, channels_in_play=self.channels_in_play, + muted_channels=frozenset(), live=not self.is_active, + collapse_levels=False, ) @property diff --git a/src/sampletones_application/view_model/reconstruction/stems.py b/src/sampletones_application/view_model/reconstruction/stems.py index b5a0f7c16..7a9a6e30c 100644 --- a/src/sampletones_application/view_model/reconstruction/stems.py +++ b/src/sampletones_application/view_model/reconstruction/stems.py @@ -1,25 +1,21 @@ -from typing import Optional, Tuple +from typing import Optional from pydantic import BaseModel -from sampletones_core.constants.enums import ChannelName, HierarchyMode - - -class StemViewModel(BaseModel, frozen=True): - """One stem row: its identity, source file, channels, and selection.""" - - stem_id: int - label: str - channels: Tuple[ChannelName, ...] - enabled: bool - selected: bool +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import HierarchyMode class ReconstructionStemsViewModel(BaseModel, frozen=True): - """What the stems card renders for the loaded reconstruction.""" + """What the stems card renders for the loaded reconstruction. + + The recorded assignment is a stems list like the converter's, so the card hands + :attr:`stems` straight to the shared element and keeps the setup line describing how the + levels were picked. + """ reconstruction_loaded: bool - stems: Tuple[StemViewModel, ...] + stems: StemsListViewModel hierarchy_mode: Optional[HierarchyMode] = None channel_cap: Optional[int] = None @@ -31,4 +27,4 @@ def show_setup_line(self) -> bool: @property def show_empty_state(self) -> bool: """The empty state explains a loaded reconstruction that records no source.""" - return self.reconstruction_loaded and not self.stems + return self.reconstruction_loaded and not self.stems.rows diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 070627f62..f8f0d3047 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -13,12 +13,15 @@ class StemRowViewModel(BaseModel, frozen=True): recordings sharing that level, and how many of each the list holds — so the moves a list offers grey themselves out from the row alone. ``key`` is the identity the list reports a gesture under: the recording's path where the list gathers files, the stem id where it - describes a recorded assignment. + describes a recorded assignment. ``offered_channels`` names the boxes the row draws and + ``channels`` the ones ticked among them. """ key: str path: Path channels: FrozenSet[ChannelName] + offered_channels: FrozenSet[ChannelName] + available: bool level: int position: int level_size: int @@ -34,6 +37,16 @@ def takes_part(self) -> bool: """The recording holds a channel, so the list counts it in.""" return bool(self.channels) + @property + def offers_channels(self) -> bool: + """The row draws at least one box, so there is a channel to give the recording.""" + return bool(self.offered_channels) + + @property + def in_play(self) -> bool: + """The recording is there to be read and holds a channel, so what it carries is heard.""" + return self.available and self.takes_part + @property def is_first_on_level(self) -> bool: return self.position == 0 @@ -56,11 +69,18 @@ def alone_on_level(self) -> bool: class StemsListViewModel(BaseModel, frozen=True): - """What a stems list renders: the rows, the columns they line up in, and whether they answer.""" + """What a stems list renders: the rows, the columns they line up in, and how they answer. + + ``muted_channels`` names the columns a choice made elsewhere has switched off, which the + boxes report while staying as clickable as any other. ``collapse_levels`` draws every row + in one table, leaving the levels to the reader's memory rather than to a caption. + """ rows: Tuple[StemRowViewModel, ...] channels_in_play: Tuple[ChannelName, ...] + muted_channels: FrozenSet[ChannelName] live: bool + collapse_levels: bool @property def row_count(self) -> int: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6bc911d89..abd9f545b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -262,8 +262,13 @@ global.stems.template.level_caption: "Level {}" global.stems.label.remove: "x" global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level." global.stems.message.inert_tooltip: "Tick a channel to use this recording." -global.stems.message.status_row: "Drag {name} onto another row or a gap to move it, or right-click for more actions." +global.stems.message.missing_tooltip: "This recording is missing from disk." +global.stems.message.unoffered_tooltip: "This recording holds no frames." +global.stems.message.status_row_drag: "Drag {name} onto another row or a gap to move it, or right-click for more actions." +global.stems.message.status_row_reveal: "Show {name} in the file browser." +global.stems.message.status_master: "Turn every channel on or off for {name}." global.stems.message.status_channel: "Turn the {channel} channel on or off for {name}." +global.stems.message.status_channel_muted: "The {channel} channel is off for everything, so {name} stays quiet on it." global.stems.message.status_remove: "Remove {name} from the list." # ============================================================================= @@ -438,7 +443,6 @@ reconstructions.browser.template.incompatible_version_template: "Incompatible re reconstructions.reconstruction.label.audio_source_label: "Source" reconstructions.reconstruction.label.autoscale_checkbox: "Autoscale" reconstructions.reconstruction.label.reconstruction_file_label: "Reconstruction file:" -reconstructions.reconstruction.label.original_audio_label: "Original audio:" reconstructions.reconstruction.label.path_not_found: "not found" reconstructions.reconstruction.label.path_not_applicable: "N/A" reconstructions.reconstruction.label.original_audio_radio: "Original audio" @@ -453,6 +457,9 @@ reconstructions.reconstruction.label.stems_empty: "This reconstruction records n reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin" reconstructions.reconstruction.label.stems_mode_strict: "Strict" reconstructions.reconstruction.template.stems_setup: "Mode: {mode} · Channel cap: {cap}" +reconstructions.reconstruction.label.collapse_levels: "Collapse levels" +reconstructions.reconstruction.message.collapse_levels_tooltip: "Draw every recording in one table." +reconstructions.reconstruction.message.status_collapse_levels: "Draw the recordings in one table or under their levels." # ============================================================================= # Reconstructions tab — Instruments diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index a7a1c4a15..bdd85a517 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -1,3 +1,4 @@ +master_column_width: 26 channel_column_width: 90 remove_button_width: 30 level_strip_height: 6 diff --git a/src/sampletones_config/theme/channels/muted.yaml b/src/sampletones_config/theme/channels/muted.yaml new file mode 100644 index 000000000..659326523 --- /dev/null +++ b/src/sampletones_config/theme/channels/muted.yaml @@ -0,0 +1,12 @@ +name: channel_muted +tag: global.theme.channel_muted + +components: + - item_type: Checkbox + entries: + - type: color + key: CheckMark + value: .text_muted + - type: color + key: Text + value: .text_muted diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py index 80ff5f273..f7903c8d1 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py @@ -1,31 +1,32 @@ -from typing import AbstractSet, Dict, Mapping +from typing import Dict, Mapping import numpy as np from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection def filter_approximations( stems_data: StemsData, approximations: Mapping[ChannelName, np.ndarray], - selected_stem_ids: AbstractSet[int], + selection: StemSelection, frame_length: int, ) -> Dict[ChannelName, np.ndarray]: """Returns the per-channel approximations with the unselected stems' frames zeroed. Stem id ``i`` names frame ``i`` of its channel — the same index the channel's stored - approximation slices hold — so a frame whose stem is unselected becomes silence while - every other frame keeps its samples. The arrays keep their lengths, which is what - aligns a filtered mix with the unfiltered one sample for sample. Every selected stem - answers the original arrays, and channels the stems data names come back filtered. - The mask covers the frames the stored array holds; samples past the last recorded + approximation slices hold — so a frame whose stem is unselected on that channel becomes + silence while every other frame keeps its samples. The selection answers each channel on + its own, so one recording is heard on a channel and stays quiet on the next. The arrays + keep their lengths, which is what aligns a filtered mix with the unfiltered one sample for + sample. The mask covers the frames the stored array holds; samples past the last recorded frame keep their values. """ filtered: Dict[ChannelName, np.ndarray] = {} for channel, stem_ids in stems_data.assignments_by_channel.items(): approximation = approximations[channel] - keep = np.isin(np.array(stem_ids, dtype=int), list(selected_stem_ids)) + keep = np.isin(np.array(stem_ids, dtype=int), list(selection.stems_for(channel))) keep_samples = np.repeat(keep, frame_length) masked = np.array(approximation, copy=True) masked[~keep_samples[: len(masked)]] = 0 diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/selection.py b/src/sampletones_core/reconstructions/reconstruction/stems/selection.py new file mode 100644 index 000000000..bf2043bf7 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/selection.py @@ -0,0 +1,35 @@ +from typing import AbstractSet, Dict, FrozenSet, Iterable, Self + +from pydantic import BaseModel, ConfigDict + +from sampletones_core.constants.enums import ChannelName + + +class StemSelection(BaseModel): + """Which stems a reader is listening to, channel by channel. + + A stem is heard on a channel once its id stands in that channel's set, so one recording + carries a channel while another keeps quiet on it. What the recordings are mixed into is + drawn from the stems heard anywhere, which is what :meth:`any_channel` answers. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + channels: Dict[ChannelName, FrozenSet[int]] + + @classmethod + def everywhere( + cls, + stem_ids: AbstractSet[int], + channels: Iterable[ChannelName], + ) -> Self: + """The selection hearing every stem of ``stem_ids`` on every channel of ``channels``.""" + return cls(channels={channel: frozenset(stem_ids) for channel in channels}) + + def stems_for(self, channel: ChannelName) -> FrozenSet[int]: + """The stems heard on one channel, empty where the channel names none.""" + return self.channels.get(channel, frozenset()) + + def any_channel(self) -> FrozenSet[int]: + """The stems heard on at least one channel.""" + return frozenset().union(*self.channels.values()) if self.channels else frozenset() diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 1210a0476..2e21b7d51 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -10,6 +10,7 @@ from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy @@ -222,12 +223,16 @@ def masked_channels(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray] masked[channel] = frames return masked + def heard(selected: AbstractSet[int]) -> StemSelection: + """The selection hearing the named stems on every channel.""" + return StemSelection.everywhere(frozenset(selected), channels) + unfiltered = data.waveform_data() - full = data.waveform_data(frozenset(all_stem_ids)) + full = data.waveform_data(heard(all_stem_ids)) np.testing.assert_allclose(full.approximation, unfiltered.approximation, atol=_MIX_TOLERANCE) np.testing.assert_allclose(full.original_audio, unfiltered.original_audio, atol=_MIX_TOLERANCE) np.testing.assert_allclose( - data.partials_for(channels, frozenset(all_stem_ids)), + data.partials_for(channels, heard(all_stem_ids)), data.get_partials(channels), atol=_MIX_TOLERANCE, ) @@ -235,22 +240,22 @@ def masked_channels(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray] for selected_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID): selected = frozenset({selected_id}) expected = masked_channels(selected) - waveform = data.waveform_data(selected) + waveform = data.waveform_data(heard(selected)) for channel, expected_audio in expected.items(): np.testing.assert_array_equal(waveform.approximations[channel], expected_audio) np.testing.assert_allclose(waveform.approximation, mix(list(expected.values())), atol=_MIX_TOLERANCE) np.testing.assert_allclose( - data.partials_for(channels, selected), + data.partials_for(channels, heard(selected)), mix(list(expected.values())), atol=_MIX_TOLERANCE, ) np.testing.assert_allclose( - data.original_mix_for(selected), data.stem_audios[selected_id], atol=_MIX_TOLERANCE + data.original_mix_for(heard(selected)), data.stem_audios[selected_id], atol=_MIX_TOLERANCE ) selected = frozenset() - waveform = data.waveform_data(selected) + waveform = data.waveform_data(heard(selected)) for channel in stems_data.assignments_by_channel: np.testing.assert_array_equal( waveform.approximations[channel], @@ -258,11 +263,11 @@ def masked_channels(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray] ) np.testing.assert_allclose(waveform.approximation, np.zeros_like(data.reconstruction.approximation)) np.testing.assert_allclose( - data.partials_for(channels, selected), + data.partials_for(channels, heard(selected)), np.zeros_like(data.reconstruction.approximation), ) np.testing.assert_allclose( - data.original_mix_for(selected), + data.original_mix_for(heard(selected)), np.zeros_like(data.reconstruction.approximation), ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 8e0489792..3e668e338 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -11,11 +11,17 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +def _heard(*stem_ids: int) -> StemSelection: + """The selection hearing every named stem on every channel.""" + return StemSelection.everywhere(frozenset(stem_ids), ChannelName.items()) + + class TestFromReconstruction: def test_wraps_the_same_object_for_live_linking( self, @@ -319,11 +325,11 @@ def test_partials_keep_only_the_selected_stems_frames( expected = data.reconstruction.approximation.copy() expected[frame_length:] = 0 - partials = data.partials_for([ChannelName.PULSE1], frozenset({0})) + partials = data.partials_for([ChannelName.PULSE1], _heard(0)) np.testing.assert_allclose(partials, expected) np.testing.assert_allclose( - data.partials_for([ChannelName.PULSE1], frozenset({0, 1})), + data.partials_for([ChannelName.PULSE1], _heard(0, 1)), data.reconstruction.approximation, ) @@ -360,11 +366,11 @@ def test_original_mix_mixes_the_selected_recordings( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - np.testing.assert_allclose(data.original_mix_for(frozenset({0})), data.stem_audios[0]) + np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.stem_audios[0]) assert data.original_audio is not None - np.testing.assert_allclose(data.original_mix_for(frozenset({0, 1})), data.original_audio) + np.testing.assert_allclose(data.original_mix_for(_heard(0, 1)), data.original_audio) np.testing.assert_array_equal( - data.original_mix_for(frozenset()), + data.original_mix_for(_heard()), np.zeros_like(data.reconstruction.approximation), ) @@ -380,11 +386,11 @@ def test_a_single_source_with_no_selection_is_silence( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") np.testing.assert_array_equal( - data.partials_for([ChannelName.PULSE1], frozenset()), + data.partials_for([ChannelName.PULSE1], _heard()), np.zeros_like(data.reconstruction.approximation), ) assert data.original_audio is not None - np.testing.assert_allclose(data.original_mix_for(frozenset({0})), data.original_audio) + np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.original_audio) class TestWaveformData: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index fc7a91c48..3827b1010 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -964,7 +964,7 @@ def stems_data_fixture( ) return ReconstructionData.from_reconstruction(stems_reconstruction, name="Sample") - def test_display_selects_every_stem( + def test_display_hears_every_recording_on_the_channels_it_holds( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -976,13 +976,50 @@ def test_display_selects_every_stem( panel_logic.display_reconstruction() - assert panel_logic._selected_stems == frozenset({0, 1}) + assert panel_logic._stem_channels == { + 0: frozenset({ChannelName.PULSE1}), + 1: frozenset(), + } assert len(stems_views) == 1 - assert {row.stem_id for row in stems_views[0].stems} == {0, 1} - assert all(row.selected for row in stems_views[0].stems) - assert stems_views[0].hierarchy_mode is None or stems_views[0].show_setup_line + rows = stems_views[0].stems.rows + assert {row.key for row in rows} == {"0", "1"} + assert all(row.channels == row.offered_channels for row in rows) + assert stems_views[0].stems.channels_in_play == (ChannelName.PULSE1,) - def test_set_selected_stems_filters_waveform_and_playback( + def test_a_recording_holding_no_frames_offers_no_box( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = stems_data + stems_views = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + rows = {row.key: row for row in stems_views[0].stems.rows} + assert rows["0"].offered_channels == frozenset({ChannelName.PULSE1}) + assert rows["1"].offered_channels == frozenset() + assert not rows["1"].offers_channels + + def test_a_row_stands_where_the_hierarchy_put_its_recording( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = stems_data + stems_views = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + rows = stems_views[0].stems.rows + assert [(row.key, row.level, row.position) for row in rows] == [("0", 0, 0), ("1", 0, 1)] + assert all(row.level_size == 2 and row.level_count == 1 for row in rows) + + def test_silencing_a_recording_filters_waveform_and_playback( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, @@ -995,11 +1032,11 @@ def test_set_selected_stems_filters_waveform_and_playback( panel_logic.on_waveform_load_changed = lambda waveform, channels: waveform_updates.append(waveform) panel_logic.on_audio_data_changed = lambda audio: audio_updates.append(audio) - panel_logic.set_selected_stems(frozenset({0})) + panel_logic.set_stem_channels(0, frozenset()) expected = stems_data.partials_for( panel_logic._selected_channels, - frozenset({0}), + panel_logic._stem_selection, ) assert len(waveform_updates) == 1 np.testing.assert_allclose(waveform_updates[0].partials(panel_logic._selected_channels), expected) @@ -1007,6 +1044,24 @@ def test_set_selected_stems_filters_waveform_and_playback( assert audio_updates[0] is not None np.testing.assert_allclose(audio_updates[0].sample, expected) + def test_a_channel_switched_off_for_everything_mutes_its_column( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = stems_data + panel_logic.display_reconstruction() + stems_views = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.set_selected_channels([]) + + assert len(stems_views) == 1 + rows = {row.key: row for row in stems_views[0].stems.rows} + assert stems_views[0].stems.muted_channels == frozenset({ChannelName.PULSE1}) + assert rows["0"].channels == frozenset({ChannelName.PULSE1}) + def test_export_wav_uses_the_stems_filter( self, panel_logic: ReconstructionPanelLogic, @@ -1017,7 +1072,7 @@ def test_export_wav_uses_the_stems_filter( ) -> None: mock_reconstruction_manager.current_reconstruction = stems_data panel_logic.display_reconstruction() - panel_logic.set_selected_stems(frozenset({0})) + panel_logic.set_stem_channels(0, frozenset()) panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav") @@ -1025,6 +1080,6 @@ def test_export_wav_uses_the_stems_filter( exported_audio = mock_export_service.export_wav.call_args.args[2] expected = stems_data.partials_for( panel_logic._selected_channels, - frozenset({0}), + panel_logic._stem_selection, ) np.testing.assert_allclose(exported_audio, expected) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 5361c877e..7f7780e90 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -24,6 +24,7 @@ SUF_ROW, SUF_STRIP, SUF_TEXT, + TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) @@ -72,6 +73,7 @@ def build( *, draggable: bool = True, removable: bool = True, + master_checkbox: bool = False, ) -> GUIStemsList: stems_list = GUIStemsList( prefix=PREFIX, @@ -80,6 +82,7 @@ def build( status_bar=GUIStatusBar(), draggable=draggable, removable=removable, + master_checkbox=master_checkbox, ) with dpg.window(tag=ROOT_TAG): stems_list.create(ROOT_TAG) @@ -91,6 +94,8 @@ def row( name: str, *, channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + available: bool = True, level: int = 0, position: int = 0, level_size: int = 1, @@ -101,6 +106,8 @@ def row( key=str(path), path=path, channels=channels, + offered_channels=offered_channels, + available=available, level=level, position=position, level_size=level_size, @@ -108,8 +115,19 @@ def row( ) -def view(*rows: StemRowViewModel, live: bool = True) -> StemsListViewModel: - return StemsListViewModel(rows=rows, channels_in_play=CHANNELS, live=live) +def view( + *rows: StemRowViewModel, + live: bool = True, + muted_channels: FrozenSet[ChannelName] = frozenset(), + collapse_levels: bool = False, +) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=muted_channels, + live=live, + collapse_levels=collapse_levels, + ) def row_tag(entry: StemRowViewModel, suffix: str) -> str: @@ -336,3 +354,169 @@ def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( stems_list.update_view(view(row("bass", channels=frozenset()))) assert dpg.get_alias_id(channel_tag(bass, ChannelName.PULSE1)) == standing + + +class TestOfferedChannels: + def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass", channels=frozenset({ChannelName.PULSE1}), offered_channels=frozenset({ChannelName.PULSE1})) + + stems_list.update_view(view(bass)) + + assert dpg.does_item_exist(channel_tag(bass, ChannelName.PULSE1)) + assert not dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) + + def test_a_recording_missing_from_disk_greys_out(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass", available=False) + + stems_list.update_view(view(bass)) + + assert dpg.get_item_theme(row_tag(bass, SUF_TEXT)) == ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_ROW_INERT).tag + + def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + stems_list.update_view(view(row("bass", offered_channels=frozenset({ChannelName.PULSE1})))) + + bass = row("bass") + stems_list.update_view(view(bass)) + + assert dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) + + +class TestMasterCheckbox: + def test_a_master_box_reads_whether_the_row_holds_a_channel(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, master_checkbox=True) + playing = row("bass") + quiet = row("pad", channels=frozenset()) + + stems_list.update_view(view(playing, quiet)) + + assert dpg.get_value(row_tag(playing, SUF_CHECKBOX)) + assert not dpg.get_value(row_tag(quiet, SUF_CHECKBOX)) + + def test_ticking_the_master_box_hands_the_row_every_channel_it_offers( + self, + dpg_context: None, + layout_config, + ) -> None: + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config, master_checkbox=True) + stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) + bass = row("bass", channels=frozenset(), offered_channels=frozenset({ChannelName.PULSE1})) + stems_list.update_view(view(bass)) + + master_tag = row_tag(bass, SUF_CHECKBOX) + dpg.get_item_callback(master_tag)(master_tag, True, bass.key) + + assert reported == [(bass.key, frozenset({ChannelName.PULSE1}))] + + def test_unticking_the_master_box_takes_every_channel_away(self, dpg_context: None, layout_config) -> None: + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config, master_checkbox=True) + stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) + bass = row("bass") + stems_list.update_view(view(bass)) + + master_tag = row_tag(bass, SUF_CHECKBOX) + dpg.get_item_callback(master_tag)(master_tag, False, bass.key) + + assert reported == [(bass.key, frozenset())] + + def test_a_row_offering_no_channel_has_nothing_for_its_master_box_to_do( + self, + dpg_context: None, + layout_config, + ) -> None: + stems_list = build(layout_config, master_checkbox=True) + silent = row("pad", channels=frozenset(), offered_channels=frozenset()) + + stems_list.update_view(view(silent)) + + assert not dpg.is_item_enabled(row_tag(silent, SUF_CHECKBOX)) + + def test_a_list_without_a_master_box_draws_none(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert not dpg.does_item_exist(row_tag(bass, SUF_CHECKBOX)) + + +class TestMutedChannels: + def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.TRIANGLE}))) + + muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag + assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) == muted + assert dpg.get_item_theme(channel_tag(bass, ChannelName.PULSE1)) != muted + + def test_a_muted_box_keeps_its_value_and_stays_clickable(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + + stems_list.update_view(view(bass, muted_channels=frozenset(CHANNELS))) + + for channel_name in CHANNELS: + assert dpg.get_value(channel_tag(bass, channel_name)) + assert dpg.is_item_enabled(channel_tag(bass, channel_name)) + + def test_a_channel_switched_back_on_takes_its_own_colour_again(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config) + bass = row("bass") + stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.TRIANGLE}))) + + stems_list.update_view(view(bass)) + + muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag + assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) != muted + + +class TestCollapsedLevels: + def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, draggable=False) + rows = ( + row("bass", level=0, position=0, level_size=1, level_count=2), + row("pad", level=1, position=0, level_size=1, level_count=2), + ) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + assert dpg.does_item_exist(stems_list.table_tag) + assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) + for entry in rows: + assert dpg.does_item_exist(row_tag(entry, SUF_TEXT)) + + def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, draggable=False) + rows = ( + row("bass", level=0, position=0, level_size=1, level_count=2), + row("pad", level=1, position=0, level_size=1, level_count=2), + ) + stems_list.update_view(view(*rows, collapse_levels=True)) + + stems_list.update_view(view(*rows)) + + assert not dpg.does_item_exist(stems_list.table_tag) + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "1", SUF_TEXT)) + + +class TestActivation: + def test_a_clicked_row_reports_itself_and_stays_unselected(self, dpg_context: None, layout_config) -> None: + activated: List[str] = [] + stems_list = build(layout_config, draggable=False) + stems_list.on_row_activated = activated.append + bass = row("bass") + stems_list.update_view(view(bass)) + + name_tag = row_tag(bass, SUF_TEXT) + dpg.set_value(name_tag, True) + dpg.get_item_callback(name_tag)(name_tag, True, bass.key) + + assert activated == [bass.key] + assert not dpg.get_value(name_tag) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py index 3d65db54e..e34fac4f8 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -1,4 +1,5 @@ -from typing import Iterator +from pathlib import Path +from typing import FrozenSet, Iterator, List, Tuple import dearpygui.dearpygui as dpg import pytest @@ -13,12 +14,15 @@ PALETTES_DIRECTORY, THEME_DIRECTORY, ) +from sampletones_application.tags.general import SUF_CHECKBOX, SUF_TEXT from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP, ) from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.panels.reconstruction.stems import ( GUIReconstructionStemsPanel, ) @@ -28,11 +32,15 @@ from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.reconstruction.stems import ( ReconstructionStemsViewModel, - StemViewModel, +) +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, ) from sampletones_core.constants.enums import ChannelName, HierarchyMode ROOT_TAG = "test_root" +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.NOISE) @pytest.fixture @@ -61,8 +69,12 @@ def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: @pytest.fixture -def panel(dpg_context: None) -> GUIReconstructionStemsPanel: - return GUIReconstructionStemsPanel(language_manager=LanguageManager(LANG_EN)) +def panel(dpg_context: None, layout_config: LayoutConfig) -> GUIReconstructionStemsPanel: + return GUIReconstructionStemsPanel( + stems_layout=layout_config.general.stems, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + ) def render(panel: GUIReconstructionStemsPanel) -> None: @@ -70,126 +82,192 @@ def render(panel: GUIReconstructionStemsPanel) -> None: panel.create_panel(ROOT_TAG) -def _stem_row(stem_id: int, *, label: str, selected: bool, enabled: bool) -> StemViewModel: - return StemViewModel( - stem_id=stem_id, - label=label, - channels=(ChannelName.PULSE1, ChannelName.NOISE), - enabled=enabled, - selected=selected, +def _row( + stem_id: int, + *, + name: str, + channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + level: int = 0, + position: int = 0, + level_size: int = 1, + level_count: int = 1, +) -> StemRowViewModel: + return StemRowViewModel( + key=str(stem_id), + path=Path(f"/audio/{name}.wav"), + channels=channels, + offered_channels=offered_channels, + available=True, + level=level, + position=position, + level_size=level_size, + level_count=level_count, ) -def _view_model(*rows: StemViewModel, hierarchy_mode=None) -> ReconstructionStemsViewModel: +def _view_model( + *rows: StemRowViewModel, + hierarchy_mode: HierarchyMode | None = None, + muted_channels: FrozenSet[ChannelName] = frozenset(), +) -> ReconstructionStemsViewModel: return ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=rows, + stems=StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS if rows else (), + muted_channels=muted_channels, + live=True, + collapse_levels=False, + ), hierarchy_mode=hierarchy_mode, channel_cap=2 if hierarchy_mode is not None else None, ) class TestStemsPanelRows: - def test_one_checkbox_row_per_stem(self, panel: GUIReconstructionStemsPanel) -> None: + def test_one_row_per_recording(self, panel: GUIReconstructionStemsPanel) -> None: render(panel) - panel.update_view( - _view_model( - _stem_row(0, label="kick.wav", selected=True, enabled=True), - _stem_row(1, label="snare.wav", selected=True, enabled=True), - ) - ) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) - kick_tag = GUIReconstructionStemsPanel._stem_checkbox_tag(0) - snare_tag = GUIReconstructionStemsPanel._stem_checkbox_tag(1) - assert dpg.does_item_exist(kick_tag) - assert dpg.does_item_exist(snare_tag) - assert dpg.get_value(kick_tag) - assert dpg.get_value(snare_tag) - assert dpg.get_item_label(kick_tag) == "kick.wav" + stems_list = panel.stems_list + assert dpg.get_item_label(stems_list.row_tag("0", SUF_TEXT)) == "kick" + assert dpg.get_item_label(stems_list.row_tag("1", SUF_TEXT)) == "snare" - def test_a_stem_without_assigned_frames_is_disabled(self, panel: GUIReconstructionStemsPanel) -> None: + def test_a_row_offers_a_box_on_every_channel_its_recording_holds( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: render(panel) - panel.update_view( - _view_model( - _stem_row(0, label="kick.wav", selected=False, enabled=False), - ) - ) + panel.update_view(_view_model(_row(0, name="kick", offered_channels=frozenset({ChannelName.PULSE1})))) - assert not dpg.is_item_enabled(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) + stems_list = panel.stems_list + assert dpg.does_item_exist(stems_list.channel_tag("0", ChannelName.PULSE1)) + assert not dpg.does_item_exist(stems_list.channel_tag("0", ChannelName.NOISE)) - def test_rows_follow_a_changed_stem_set(self, panel: GUIReconstructionStemsPanel) -> None: + def test_every_row_carries_a_master_box(self, panel: GUIReconstructionStemsPanel) -> None: render(panel) - panel.update_view( - _view_model( - _stem_row(0, label="kick.wav", selected=True, enabled=True), - _stem_row(1, label="snare.wav", selected=True, enabled=True), - ) - ) - panel.update_view( - _view_model( - _stem_row(1, label="snare.wav", selected=True, enabled=True), - ) - ) + panel.update_view(_view_model(_row(0, name="kick"))) - assert not dpg.does_item_exist(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) - assert dpg.does_item_exist(GUIReconstructionStemsPanel._stem_checkbox_tag(1)) + assert dpg.get_value(panel.stems_list.row_tag("0", SUF_CHECKBOX)) - def test_a_reused_row_takes_the_new_stem_label(self, panel: GUIReconstructionStemsPanel) -> None: + def test_rows_follow_a_changed_recording_set(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + panel.update_view(_view_model(_row(1, name="snare"))) + + stems_list = panel.stems_list + assert not dpg.does_item_exist(stems_list.row_tag("0", SUF_TEXT)) + assert dpg.does_item_exist(stems_list.row_tag("1", SUF_TEXT)) + + +class TestStemsPanelSelection: + def test_unticking_a_channel_reports_the_recording_and_what_it_keeps( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + reported: List[Tuple[int, FrozenSet[ChannelName]]] = [] + panel.on_stem_channels_changed = lambda stem_id, channels: reported.append((stem_id, channels)) render(panel) + panel.update_view(_view_model(_row(0, name="kick"))) + + noise_tag = panel.stems_list.channel_tag("0", ChannelName.NOISE) + dpg.set_value(noise_tag, False) + dpg.get_item_callback(noise_tag)(noise_tag, False, ("0", ChannelName.NOISE)) + + assert reported == [(0, frozenset({ChannelName.PULSE1}))] + + def test_unticking_the_master_box_silences_the_recording_everywhere( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + reported: List[Tuple[int, FrozenSet[ChannelName]]] = [] + panel.on_stem_channels_changed = lambda stem_id, channels: reported.append((stem_id, channels)) + render(panel) + panel.update_view(_view_model(_row(0, name="kick"))) + + master_tag = panel.stems_list.row_tag("0", SUF_CHECKBOX) + dpg.get_item_callback(master_tag)(master_tag, False, "0") + + assert reported == [(0, frozenset())] + + +class TestStemsPanelLevels: + def test_the_collapse_toggle_appears_once_there_are_levels_to_collapse( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + render(panel) + + panel.update_view(_view_model(_row(0, name="kick"))) + assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS) + panel.update_view( _view_model( - _stem_row(0, label="kick.wav", selected=True, enabled=True), + _row(0, name="kick", level=0, level_count=2), + _row(1, name="snare", level=1, level_count=2), ) ) + assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS) + def test_collapsing_redraws_the_rows_in_one_table(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) panel.update_view( _view_model( - _stem_row(0, label="snare.wav", selected=True, enabled=True), + _row(0, name="kick", level=0, level_count=2), + _row(1, name="snare", level=1, level_count=2), ) ) - assert dpg.get_item_label(GUIReconstructionStemsPanel._stem_checkbox_tag(0)) == "snare.wav" + dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, True) + dpg.get_item_callback(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS)( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, + True, + ) + assert dpg.does_item_exist(panel.stems_list.table_tag) + assert dpg.does_item_exist(panel.stems_list.row_tag("1", SUF_TEXT)) -class TestStemsPanelSelection: - def test_unchecking_a_stem_reports_the_remaining_selection(self, panel: GUIReconstructionStemsPanel) -> None: - selections = [] - panel.on_stems_changed = selections.append + def test_a_reader_who_collapsed_the_levels_keeps_them_collapsed_across_an_edit( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: render(panel) panel.update_view( _view_model( - _stem_row(0, label="kick.wav", selected=True, enabled=True), - _stem_row(1, label="snare.wav", selected=True, enabled=True), + _row(0, name="kick", level=0, level_count=2), + _row(1, name="snare", level=1, level_count=2), ) ) + dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, True) - kick_tag = GUIReconstructionStemsPanel._stem_checkbox_tag(0) - dpg.set_value(kick_tag, False) - dpg.get_item_callback(kick_tag)(kick_tag, False) + panel.update_view( + _view_model( + _row(0, name="kick", level=0, level_count=2), + _row(2, name="hat", level=1, level_count=2), + ) + ) - assert selections == [frozenset({1})] + assert dpg.does_item_exist(panel.stems_list.table_tag) class TestStemsPanelStates: def test_the_setup_line_states_mode_and_cap(self, panel: GUIReconstructionStemsPanel) -> None: render(panel) - panel.update_view( - _view_model( - _stem_row(0, label="kick.wav", selected=True, enabled=True), - hierarchy_mode=HierarchyMode.STRICT, - ) - ) + panel.update_view(_view_model(_row(0, name="kick"), hierarchy_mode=HierarchyMode.STRICT)) assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) assert "Strict" in dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) assert "2" in dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) def test_the_empty_state_shows_for_a_loaded_reconstruction_without_source( - self, panel: GUIReconstructionStemsPanel + self, + panel: GUIReconstructionStemsPanel, ) -> None: render(panel) @@ -197,3 +275,4 @@ def test_the_empty_state_shows_for_a_loaded_reconstruction_without_source( assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY) assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) + assert not dpg.is_item_shown(panel.stems_list.tag) diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 99cbee5d6..b74daa833 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -31,6 +31,8 @@ def _row( key=str(path), path=path, channels=channels, + offered_channels=frozenset(ChannelName.items()), + available=True, level=level, position=position, level_size=level_size, diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 6692860c9..8adf2b208 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -15,8 +15,17 @@ from sampletones_application.view_model.reconstruction.stems import ( ReconstructionStemsViewModel, ) +from sampletones_application.view_model.shared.stems import StemsListViewModel from sampletones_core.constants.enums import HierarchyMode +EMPTY_STEMS = StemsListViewModel( + rows=(), + channels_in_play=(), + muted_channels=frozenset(), + live=True, + collapse_levels=False, +) + @dataclass(frozen=True) class EnablementCase: @@ -136,7 +145,7 @@ class TestReconstructionStemsViewModel: def test_the_setup_line_follows_the_stems_record(self) -> None: stems = ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=(), + stems=EMPTY_STEMS, hierarchy_mode=HierarchyMode.STRICT, channel_cap=2, ) @@ -147,11 +156,11 @@ def test_the_setup_line_follows_the_stems_record(self) -> None: def test_the_empty_state_names_a_loaded_reconstruction_with_no_source(self) -> None: loaded = ReconstructionStemsViewModel( reconstruction_loaded=True, - stems=(), + stems=EMPTY_STEMS, ) closed = ReconstructionStemsViewModel( reconstruction_loaded=False, - stems=(), + stems=EMPTY_STEMS, ) assert loaded.show_empty_state diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py index 8f660fe19..0186e7e08 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -8,11 +8,18 @@ from sampletones_core.reconstructions.reconstruction.stems.filter import ( filter_approximations, ) +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy FRAME_LENGTH: Final[int] = 2 +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) + + +def _heard(*stem_ids: int) -> StemSelection: + """The selection hearing every named stem on every channel.""" + return StemSelection.everywhere(frozenset(stem_ids), EVERY_CHANNEL) def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: @@ -36,7 +43,7 @@ def test_unselected_frames_become_silence(self) -> None: filtered = filter_approximations( stems_data, approximations, - {0, 2}, + _heard(0, 2), FRAME_LENGTH, ) @@ -53,7 +60,7 @@ def test_every_selected_stem_answers_the_original_arrays(self) -> None: filtered = filter_approximations( stems_data, approximations, - {0, 1, 2}, + _heard(0, 1, 2), FRAME_LENGTH, ) @@ -68,7 +75,7 @@ def test_no_selected_stem_is_silence(self) -> None: filtered = filter_approximations( stems_data, approximations, - set(), + _heard(), FRAME_LENGTH, ) @@ -83,7 +90,7 @@ def test_the_stored_arrays_keep_their_samples(self) -> None: filter_approximations( stems_data, approximations, - {0}, + _heard(0), FRAME_LENGTH, ) @@ -102,10 +109,30 @@ def test_channels_the_stems_data_names_come_back_filtered(self) -> None: filtered = filter_approximations( stems_data, approximations, - {1}, + _heard(1), FRAME_LENGTH, ) assert set(filtered) == {ChannelName.PULSE1, ChannelName.NOISE} np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.zeros(2, dtype=np.float32)) np.testing.assert_array_equal(filtered[ChannelName.NOISE], np.ones(2, dtype=np.float32)) + + def test_a_stem_heard_on_one_channel_stays_quiet_on_the_other(self) -> None: + stems_data = _stems_data( + (ChannelName.PULSE1, [0]), + (ChannelName.NOISE, [0]), + ) + approximations = { + ChannelName.PULSE1: np.ones(2, dtype=np.float32), + ChannelName.NOISE: np.ones(2, dtype=np.float32), + } + + filtered = filter_approximations( + stems_data, + approximations, + StemSelection(channels={ChannelName.PULSE1: frozenset({0}), ChannelName.NOISE: frozenset()}), + FRAME_LENGTH, + ) + + np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.ones(2, dtype=np.float32)) + np.testing.assert_array_equal(filtered[ChannelName.NOISE], np.zeros(2, dtype=np.float32)) From 991043d4c81836d667db321735b07d532e0cc489 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 20:13:46 +0200 Subject: [PATCH 060/142] Joined: the export service to the shared progress vocabulary --- src/sampletones_application/application.py | 18 ++ .../services/export/reporter.py | 62 ++++++ .../services/export/result.py | 16 +- .../services/export/service.py | 162 +++++++++----- .../services/progress.py | 74 +++++++ .../services/render/constants.py | 1 - .../services/render/progress.py | 49 ----- .../services/render/service.py | 8 +- .../services/test_export.py | 52 +++-- .../services/export/test_service.py | 197 +++++++++++++++--- .../services/test_progress.py | 78 +++++++ .../sampletones_application/test_busy_lock.py | 50 ++++- 12 files changed, 603 insertions(+), 164 deletions(-) create mode 100644 src/sampletones_application/services/export/reporter.py create mode 100644 src/sampletones_application/services/progress.py delete mode 100644 src/sampletones_application/services/render/progress.py create mode 100644 tests/unit/sampletones_application/services/test_progress.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index a64f5f585..86f07f335 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -67,6 +67,7 @@ ) from sampletones_application.services import ( ConversionService, + ExportResult, ExportService, RegeneratedInstrument, RegenerationService, @@ -75,6 +76,7 @@ SampleRetuneService, ServiceCancelled, ServiceError, + ServiceProgress, ServiceSuccess, SongRenderService, ) @@ -236,6 +238,7 @@ def __init__( self.conversion_service: ConversionService = ConversionService(priority=_priority) self.regeneration_service: RegenerationService = RegenerationService(priority=_priority) self.export_service: ExportService = ExportService(priority=_priority) + self.export_service.subscribe(self._on_export_activity) self.render_service: SongRenderService = SongRenderService(priority=_priority) self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) @@ -882,8 +885,23 @@ def _is_operation_active(self) -> bool: self._main_tab.is_converter_active() or self._instructions_tab.is_library_generating() or self._render_coordinator.is_active + or self.export_service.is_running() ) + def _on_export_activity(self, result: ExportResult) -> None: + """Follows an export claiming the application and handing it back. + + A format carrying its own player spends seconds on a song, which is the same ground a + conversion or a render occupies, so its edges reach the same busy state. What a run says + while it is under way changes nothing about who holds the application, so only its + starting and its finishing are edges. + """ + match result: + case ServiceProgress(): + return + case _: + self._refresh_busy_state() + def _refresh_busy_state(self) -> None: """Re-evaluate the reconstruct and generate-library buttons whenever a conversion, library generation or render starts or finishes, keeping the long operations mutually exclusive. Each diff --git a/src/sampletones_application/services/export/reporter.py b/src/sampletones_application/services/export/reporter.py new file mode 100644 index 000000000..c9834525c --- /dev/null +++ b/src/sampletones_application/services/export/reporter.py @@ -0,0 +1,62 @@ +from typing import Callable, Final, FrozenSet, Optional + +from sampletones_application.services.progress import UNMEASURED, StageProgress +from sampletones_application.services.result import ServiceProgress +from sampletones_core.exports.progress import ExportProgress +from sampletones_core.exports.stage import ExportStage + +ESTIMATED_STAGES: Final[FrozenSet[ExportStage]] = frozenset( + { + ExportStage.WALKING, + ExportStage.WRITING, + } +) + + +class ExportProgressReporter: + """Carries a format's own account of itself out to whoever is watching the export. + + A run passes through stages counting in units of their own — the song's ticks, the bytes a + dictionary settles at, the files a batch writes — so each stage is reported against what it + is measured by and gets a limiter of its own the moment it is first heard from. A stage the + run never enters is never reported, which is what keeps a bar from being carved into equal + parts that mean nothing. + + Compressing is the stage with no end to travel toward: it finishes when the song runs out of + phrases that pay for themselves, so it is measured against the room it has and states no + remaining time. + """ + + def __init__( + self, + emit: Callable[[ServiceProgress[ExportStage]], None], + withdrawn: Callable[[], bool], + ) -> None: + self._emit = emit + self._withdrawn = withdrawn + self._stage: Optional[ExportStage] = None + self._progress: Optional[StageProgress[ExportStage]] = None + + def __call__(self, progress: ExportProgress) -> bool: + """Reports one stage of the run, and answers whether it goes on. + + Args: + progress: What the format says it has reached. + + Returns: + bool: Whether the export is still wanted. + """ + self._limiter(progress).advance(progress.completed) + return not self._withdrawn() + + def _limiter(self, progress: ExportProgress) -> StageProgress[ExportStage]: + if self._progress is None or self._stage != progress.stage: + self._stage = progress.stage + self._progress = StageProgress( + progress.stage, + UNMEASURED if progress.total is None else progress.total, + emit=self._emit, + estimates=progress.stage in ESTIMATED_STAGES, + ) + + return self._progress diff --git a/src/sampletones_application/services/export/result.py b/src/sampletones_application/services/export/result.py index 5a02a1122..14d010780 100644 --- a/src/sampletones_application/services/export/result.py +++ b/src/sampletones_application/services/export/result.py @@ -1,4 +1,18 @@ +from typing import Union + from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceProgress, + ServiceStarted, +) +from sampletones_core.exports.stage import ExportStage -ExportResult = ExportSuccess | ExportError +ExportResult = Union[ + ServiceStarted, + ServiceProgress[ExportStage], + ExportSuccess, + ExportError, + ServiceCancelled, +] diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index 177eaf245..e7c3e4136 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -1,27 +1,34 @@ +import threading from functools import partial from pathlib import Path -from typing import Callable +from typing import Callable, Final, Optional import numpy as np from sampletones_application.services.base import ServiceBase from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.reporter import ExportProgressReporter from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.services.result import ServiceCancelled, ServiceStarted from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ExportReporter from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) +from sampletones_shared.exceptions import OperationCancelled from sampletones_shared.logger import logger NO_EXPORT_FORMAT: None = None +WHOLE_ENVELOPE: None = None +UNMEASURED_AT_THE_START: Final[int] = 0 class ExportService(ServiceBase[ExportResult]): @@ -31,11 +38,32 @@ class ExportService(ServiceBase[ExportResult]): format: it owns the thread boundary and the error boundary, and the backend owns what lands on disk. Each result names the format it was written in, letting one subscriber report an outcome in the words of the program that reads it. + + A format carrying its own player spends seconds on a song, so a run reports the stage it is + in as it goes and answers a cancel at the next point the format looks up. One export runs at + a time, and a request arriving while one is in flight is declined. """ def __init__(self, priority: int = 0) -> None: super().__init__(priority) self._executor = SingleThreadExecutor() + self._cancel_event = threading.Event() + self._running = threading.Event() + + def cancel(self) -> None: + """Asks a running export to stop at the next point the format looks up.""" + self._cancel_event.set() + + def is_running(self) -> bool: + return self._running.is_set() + + def shutdown(self) -> None: + """Winds a running export down for application exit. + + The worker runs on a :class:`SingleThreadExecutor`, so the teardown that joins every + background worker reaches this one; asking it to stop first is what keeps that join short. + """ + self._cancel_event.set() def export_wav( self, @@ -43,29 +71,19 @@ def export_wav( sample_rate: int, audio: np.ndarray, ) -> None: - def task() -> None: - try: - write_wave(filepath, sample_rate, audio) - logger.info(f"Exported reconstruction to WAV: {logger.format_path(filepath)}") - self._emit( - ExportSuccess( - kind=ExportKind.WAV, - filepath=filepath, - export_format=NO_EXPORT_FORMAT, - truncation=None, - ) - ) - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"Failed to export reconstruction to WAV: {filepath}") - self._emit( - ExportError( - kind=ExportKind.WAV, - export_format=NO_EXPORT_FORMAT, - exception=exception, - ) - ) + """Writes the audio a reconstruction sounds as, straight to a file. - self._executor.execute(task, wait=False) + Args: + filepath: The file to write. + sample_rate: The rate the samples were rendered at. + audio: The samples to write. + """ + self._submit( + ExportKind.WAV, + filepath, + NO_EXPORT_FORMAT, + partial(self._written_wave, filepath, sample_rate, audio), + ) def export_instrument( self, @@ -106,12 +124,24 @@ def export_project( partial(backend.write_project, destination, request), ) + def _written_wave( + self, + filepath: Path, + sample_rate: int, + audio: np.ndarray, + _report: ExportReporter, + /, + ) -> ExportArtifact: + """Writes the samples straight out, which takes a moment and reports no stages.""" + write_wave(filepath, sample_rate, audio) + return ExportArtifact(paths=(filepath,), truncation=WHOLE_ENVELOPE) + def _submit( self, kind: ExportKind, destination: Path, - export_format: ExportFormat, - write: Callable[[], ExportArtifact], + export_format: Optional[ExportFormat], + write: Callable[[ExportReporter], ExportArtifact], ) -> None: """Runs one backend write on the executor and reports what it produced. @@ -122,32 +152,64 @@ def _submit( Args: kind: The artefact the run produces, naming the dialog that reports it. destination: The destination the run was given. - export_format: The format the run writes, carried through to the result. - write: Calls the backend and returns what it left on disk. + export_format: The format the run writes, carried through to the result, and ``None`` + for an audio export. + write: Calls the backend with the reporter it says its stages through, and returns + what it left on disk. """ + if self.is_running(): + logger.warning(f"{self.class_name}: an export is already running; the request was declined") + return - def task() -> None: - try: - artifact = write() - for path in artifact.paths: - logger.info(f"Exported {kind.value}: {logger.format_path(path)}") - - self._emit( - ExportSuccess( - kind=kind, - filepath=artifact.paths[0] if artifact.paths else destination, - export_format=export_format, - truncation=artifact.truncation, - ) - ) - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"Failed to export to: {destination}") - self._emit( - ExportError( - kind=kind, - export_format=export_format, - exception=exception, - ) + self._cancel_event.clear() + self._running.set() + if not self._executor.execute(partial(self._run, kind, destination, export_format, write), wait=False): + self._running.clear() + + def _run( + self, + kind: ExportKind, + destination: Path, + export_format: Optional[ExportFormat], + write: Callable[[ExportReporter], ExportArtifact], + ) -> None: + try: + self._emit(ServiceStarted(total=UNMEASURED_AT_THE_START)) + self._report_written(kind, destination, export_format, write(self._reporter())) + except OperationCancelled: + logger.info(f"The export to {logger.format_path(destination)} was cancelled") + self._emit(ServiceCancelled()) + except Exception as exception: # pylint: disable=broad-exception-caught + logger.error_with_traceback(exception, f"Failed to export to: {destination}") + self._emit( + ExportError( + kind=kind, + export_format=export_format, + exception=exception, ) + ) + finally: + self._running.clear() + + def _report_written( + self, + kind: ExportKind, + destination: Path, + export_format: Optional[ExportFormat], + artifact: ExportArtifact, + ) -> None: + for path in artifact.paths: + logger.info(f"Exported {kind.value}: {logger.format_path(path)}") + + self._emit( + ExportSuccess( + kind=kind, + filepath=artifact.paths[0] if artifact.paths else destination, + export_format=export_format, + truncation=artifact.truncation, + ) + ) - self._executor.execute(task, wait=False) + def _reporter(self) -> ExportReporter: + """What the backend says its stages through, and asks whether the run is still wanted.""" + return ExportProgressReporter(self._emit, self._cancel_event.is_set) diff --git a/src/sampletones_application/services/progress.py b/src/sampletones_application/services/progress.py new file mode 100644 index 000000000..cf54b49dc --- /dev/null +++ b/src/sampletones_application/services/progress.py @@ -0,0 +1,74 @@ +from typing import Callable, Final, Generic, Optional, TypeVar + +from sampletones_application.services.result import ServiceProgress +from sampletones_core.parallelization import ETAEstimator + +StageT = TypeVar("StageT") + +PROGRESS_STEPS: Final[int] = 200 +UNMEASURED: Final[int] = 0 + + +class StageProgress(Generic[StageT]): + """One stage of a long operation, reported at a bounded rate. + + A stage may step through millions of samples or a few files, so reporting every step would + fill the callback queue with updates no eye resolves and no bar redraws. Emitting on a + fraction of what the stage is measured against holds the report rate steady whatever the work + is, and a stage landing on its total is always reported, so a bar arrives at its end. + + What a stage counts moves by its own rules: a render's samples rise toward the song's length, + while a compression's bytes fall as the dictionary earns its keep. A step is therefore a + change of either sign. + """ + + def __init__( + self, + stage: StageT, + total: int, + *, + emit: Callable[[ServiceProgress[StageT]], None], + estimates: bool, + ) -> None: + """Holds one stage's reporting. + + Args: + stage: The work being reported, which names the unit the counts are in. + total: What the stage's count is measured against, and ``UNMEASURED`` where nothing + bounds it. + emit: Carries a report to the service's subscribers. + estimates: Whether the count reaches ``total`` at a rate a remaining time can be read + from. A stage measured against a limit it is not travelling toward states no + estimate, since one taken from that would be a guess wearing the clothes of a fact. + """ + self._stage = stage + self._total = total + self._emit = emit + self._estimator = ETAEstimator(total=total) if estimates and total > UNMEASURED else None + self._interval = max(1, total // PROGRESS_STEPS) + self._reported: int = 0 + + def advance(self, completed: int) -> None: + """Reports the stage at ``completed`` where a step is due. + + Args: + completed: What the stage has covered so far, in the unit the stage counts in. + """ + if completed != self._total and abs(completed - self._reported) < self._interval: + return + + self._reported = completed + self._emit( + ServiceProgress( + completed=completed, + total=self._total, + current_item=self._stage, + eta_seconds=self._estimate(completed), + ) + ) + + def _estimate(self, completed: int) -> Optional[float]: + if self._estimator is None: + return None + + return self._estimator.update(completed) diff --git a/src/sampletones_application/services/render/constants.py b/src/sampletones_application/services/render/constants.py index df1ca36ec..fb0381bdf 100644 --- a/src/sampletones_application/services/render/constants.py +++ b/src/sampletones_application/services/render/constants.py @@ -1,5 +1,4 @@ from typing import Final -PROGRESS_STEPS: Final[int] = 200 ENCODE_BLOCK_SAMPLES: Final[int] = 1 << 16 SCRATCH_SUFFIX: Final[str] = ".scratch" diff --git a/src/sampletones_application/services/render/progress.py b/src/sampletones_application/services/render/progress.py deleted file mode 100644 index da09d67e1..000000000 --- a/src/sampletones_application/services/render/progress.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import Callable - -from sampletones_application.services.render.constants import PROGRESS_STEPS -from sampletones_application.services.render.result import RenderStage -from sampletones_application.services.result import ServiceProgress -from sampletones_core.parallelization import ETAEstimator - - -class StageProgress: - """One pass of a render, reported at a bounded rate. - - A render walks a song sample by sample, so reporting every step would fill the callback - queue with updates no eye resolves and no bar redraws. Emitting on a fraction of the total - holds the report rate steady whatever the song's length, and the last position is always - reported, so a bar arrives at its end. - """ - - def __init__( - self, - stage: RenderStage, - total: int, - *, - emit: Callable[[ServiceProgress[RenderStage]], None], - ) -> None: - self._stage = stage - self._total = total - self._emit = emit - self._estimator = ETAEstimator(total=total) - self._interval = max(1, total // PROGRESS_STEPS) - self._reported: int = 0 - - def advance(self, completed: int) -> None: - """Reports the pass at ``completed`` samples where a step is due. - - Args: - completed: The samples this pass has covered so far. - """ - if completed < self._total and completed - self._reported < self._interval: - return - - self._reported = completed - self._emit( - ServiceProgress( - completed=completed, - total=self._total, - current_item=self._stage, - eta_seconds=self._estimator.update(completed), - ) - ) diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py index fd43c2844..840fc4eed 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -3,7 +3,7 @@ from pathlib import Path from sampletones_application.services.base import ServiceBase -from sampletones_application.services.render.progress import StageProgress +from sampletones_application.services.progress import StageProgress from sampletones_application.services.render.result import RenderResult, RenderStage from sampletones_application.services.render.sink import ( EncodeReporter, @@ -133,7 +133,7 @@ def _synthesize( The song is rendered as the document holds it, from the top: the position a listener left the playhead at is a listening choice, and a render describes the whole song. """ - progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit) + progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit, estimates=True) synthesizer.set_position(0, 0) synthesizer.reset() @@ -150,10 +150,10 @@ def _synthesize( return not self._cancel_event.is_set() def _encode_reporter(self, total_samples: int) -> EncodeReporter: - progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit) + progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit, estimates=True) return partial(self._report_encoded, progress) - def _report_encoded(self, progress: StageProgress, encoded: int) -> bool: + def _report_encoded(self, progress: StageProgress[RenderStage], encoded: int) -> bool: progress.advance(encoded) return not self._cancel_event.is_set() diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index e7d814f24..169204d07 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -21,6 +21,11 @@ MAX_VOLUME: Final[int] = 15 +def outcome(results: List[Any]) -> Any: + """The result a run finished on, which follows whatever it said while it ran.""" + return results[-1] + + @pytest.fixture(name="backend") def backend_fixture() -> FamiTrackerBackend: return FamiTrackerBackend() @@ -82,10 +87,9 @@ def test_emits_export_success_with_correct_filepath(self, tmp_path, default_conf filepath = tmp_path / "output.wav" export_service.export_wav(filepath, default_config.sample_rate, np.zeros(1000, dtype=np.float32)) - assert len(results) == 1 - assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.WAV - assert results[0].filepath == filepath + assert isinstance(outcome(results), ExportSuccess) + assert outcome(results).kind == ExportKind.WAV + assert outcome(results).filepath == filepath def test_written_wav_is_readable(self, tmp_path, default_config) -> None: export_service = ExportService() @@ -105,9 +109,8 @@ def test_invalid_sample_rate_emits_export_error(self, tmp_path) -> None: export_service.export_wav(tmp_path / "output.wav", 1234, np.zeros(100, dtype=np.float32)) - assert len(results) == 1 - assert isinstance(results[0], ExportError) - assert results[0].kind == ExportKind.WAV + assert isinstance(outcome(results), ExportError) + assert outcome(results).kind == ExportKind.WAV class TestExportInstrumentIntegration: @@ -129,10 +132,9 @@ def test_emits_export_success_with_correct_kind_and_filepath(self, tmp_path, pul filepath = tmp_path / "instrument.fti" export_service.export_instrument(filepath, backend, instrument_export("test_instrument", pulse_features)) - assert len(results) == 1 - assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.INSTRUMENT - assert results[0].filepath == filepath + assert isinstance(outcome(results), ExportSuccess) + assert outcome(results).kind == ExportKind.INSTRUMENT + assert outcome(results).filepath == filepath def test_directory_path_emits_export_error(self, tmp_path, pulse_features, backend) -> None: export_service = ExportService() @@ -141,9 +143,8 @@ def test_directory_path_emits_export_error(self, tmp_path, pulse_features, backe export_service.export_instrument(tmp_path, backend, instrument_export("test_instrument", pulse_features)) - assert len(results) == 1 - assert isinstance(results[0], ExportError) - assert results[0].kind == ExportKind.INSTRUMENT + assert isinstance(outcome(results), ExportError) + assert outcome(results).kind == ExportKind.INSTRUMENT class TestExportSampleIntegration: @@ -173,11 +174,10 @@ def test_emits_export_success_with_a_path_that_was_written(self, tmp_path, pulse request = sample_export("sample", instrument_export("inst", pulse_features)) export_service.export_sample(tmp_path / "sample.fti", backend, request) - assert len(results) == 1 - assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.SAMPLE - assert results[0].filepath == tmp_path / "inst.fti" - assert results[0].filepath.exists() + assert isinstance(outcome(results), ExportSuccess) + assert outcome(results).kind == ExportKind.SAMPLE + assert outcome(results).filepath == tmp_path / "inst.fti" + assert outcome(results).filepath.exists() def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None: new_dir = tmp_path / "subdir" @@ -197,7 +197,7 @@ def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> No export_service.export_sample(tmp_path / "sample.fti", backend, sample_export("sample")) assert list(tmp_path.glob("*.fti")) == [] - assert isinstance(results[0], ExportSuccess) + assert isinstance(outcome(results), ExportSuccess) class TestExportToTheConsoleIntegration: @@ -224,10 +224,9 @@ def test_the_result_names_the_program_that_was_written(self, tmp_path, pulse_fea filepath, console_backend, sample_export("sample", instrument_export("inst", pulse_features)) ) - assert len(results) == 1 - assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.SAMPLE - assert results[0].filepath == filepath + assert isinstance(outcome(results), ExportSuccess) + assert outcome(results).kind == ExportKind.SAMPLE + assert outcome(results).filepath == filepath def test_a_reconstruction_outgrowing_the_program_area_is_reported(self, tmp_path, console_backend) -> None: """The console holds one program in 32 KB, so a reconstruction running past it reaches @@ -241,6 +240,5 @@ def test_a_reconstruction_outgrowing_the_program_area_is_reported(self, tmp_path request = sample_export("sample", instrument_export("inst", overlong_features(REFERENCE_PITCH))) export_service.export_sample(filepath, console_backend, request) - assert len(results) == 1 - assert isinstance(results[0], ExportError) - assert results[0].kind == ExportKind.SAMPLE + assert isinstance(outcome(results), ExportError) + assert outcome(results).kind == ExportKind.SAMPLE diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 2b49f81a9..a84073924 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Final, List, Optional, Tuple +from typing import Any, Callable, Final, List, Optional, Tuple from unittest.mock import patch import numpy as np @@ -9,21 +9,39 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceProgress, + ServiceStarted, +) from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ( + SILENT_REPORTER, + ExportReporter, + announce, +) from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) from sampletones_core.exports.scope import ExportScope +from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 +NOTHING_WRITTEN: Final[int] = 0 +ONE_FILE: Final[int] = 1 + + +def outcome(results: List[Any]) -> Any: + """The result a run finished on, which follows whatever it said while it ran.""" + return results[-1] class StubBackend: @@ -41,6 +59,7 @@ def __init__( self.truncation = truncation self.exception = exception self.calls: List[Tuple[str, Path, Any]] = [] + self.on_write: Optional[Callable[[], None]] = None @property def export_format(self) -> ExportFormat: @@ -57,33 +76,42 @@ def write_instrument( self, destination: Path, request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: - return self._write("instrument", destination, request) + return self._write("instrument", destination, request, report) def write_sample( self, destination: Path, request: SampleExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: - return self._write("sample", destination, request) + return self._write("sample", destination, request, report) def write_project( self, destination: Path, request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: - return self._write("project", destination, request) + return self._write("project", destination, request, report) def _write( self, scope: str, destination: Path, request: Any, + report: ExportReporter, ) -> ExportArtifact: self.calls.append((scope, destination, request)) + if self.on_write is not None: + self.on_write() + + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) if self.exception is not None: raise self.exception + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) return ExportArtifact(paths=(destination,), truncation=self.truncation) @@ -134,8 +162,7 @@ def test_success_emits_export_success(self, service, tmp_path) -> None: with patch("sampletones_application.services.export.service.write_wave"): export_service.export_wav(filepath, 44100, np.zeros(100)) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportSuccess) assert result.kind == ExportKind.WAV assert result.filepath == filepath @@ -165,8 +192,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: ): export_service.export_wav(filepath, 44100, np.zeros(100)) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportError) assert result.kind == ExportKind.WAV assert result.exception is exception @@ -194,8 +220,7 @@ def test_success_emits_export_success(self, service, tmp_path) -> None: build_instrument(), ) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportSuccess) assert result.kind == ExportKind.INSTRUMENT assert result.filepath == filepath @@ -224,8 +249,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: build_instrument(), ) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportError) assert result.kind == ExportKind.INSTRUMENT assert result.exception is exception @@ -252,8 +276,7 @@ def test_success_emits_export_success_with_the_destination( export_service.export_sample(tmp_path, StubBackend(), build_sample()) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportSuccess) assert result.kind == ExportKind.SAMPLE assert result.filepath == tmp_path @@ -281,8 +304,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: build_sample(), ) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportError) assert result.kind == ExportKind.SAMPLE assert result.exception is exception @@ -296,9 +318,8 @@ def test_a_sample_with_no_slices_emits_success( export_service.export_sample(tmp_path, StubBackend(), build_sample(0)) - assert len(results) == 1 - assert isinstance(results[0], ExportSuccess) - assert results[0].kind == ExportKind.SAMPLE + assert isinstance(outcome(results), ExportSuccess) + assert outcome(results).kind == ExportKind.SAMPLE class TestExportProject: @@ -308,8 +329,7 @@ def test_success_emits_export_success(self, service, tmp_path) -> None: export_service.export_project(filepath, StubBackend(), build_project()) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportSuccess) assert result.kind == ExportKind.PROJECT assert result.filepath == filepath @@ -338,8 +358,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None: build_project(), ) - assert len(results) == 1 - result = results[0] + result = outcome(results) assert isinstance(result, ExportError) assert result.kind == ExportKind.PROJECT assert result.exception is exception @@ -359,7 +378,7 @@ def test_a_tracker_export_names_the_format_it_was_written_in( build_instrument(), ) - assert results[0].export_format == ExportFormat.FAMITRACKER + assert outcome(results).export_format == ExportFormat.FAMITRACKER def test_a_failed_tracker_export_names_the_format_it_was_written_in( self, @@ -374,7 +393,7 @@ def test_a_failed_tracker_export_names_the_format_it_was_written_in( build_sample(), ) - assert results[0].export_format == ExportFormat.FAMITRACKER + assert outcome(results).export_format == ExportFormat.FAMITRACKER def test_a_wav_export_names_no_format(self, service, tmp_path) -> None: export_service, results = service @@ -386,7 +405,7 @@ def test_a_wav_export_names_no_format(self, service, tmp_path) -> None: np.zeros(100), ) - assert results[0].export_format is None + assert outcome(results).export_format is None class TestExportTruncationReporting: @@ -403,7 +422,7 @@ def test_a_complete_instrument_reports_no_truncation( build_instrument(), ) - assert results[0].truncation is None + assert outcome(results).truncation is None def test_a_shortened_instrument_carries_the_backend_report( self, @@ -423,7 +442,7 @@ def test_a_shortened_instrument_carries_the_backend_report( build_instrument(), ) - assert results[0].truncation == truncation + assert outcome(results).truncation == truncation def test_a_shortened_sample_carries_the_backend_report( self, @@ -443,7 +462,7 @@ def test_a_shortened_sample_carries_the_backend_report( build_sample(3), ) - assert results[0].truncation == truncation + assert outcome(results).truncation == truncation def test_a_wav_export_reports_no_truncation( self, @@ -459,7 +478,7 @@ def test_a_wav_export_reports_no_truncation( np.zeros(100), ) - assert results[0].truncation is None + assert outcome(results).truncation is None class TestExportServiceConcurrency: @@ -510,3 +529,121 @@ def on_result(result: Any) -> None: ) assert call_count == 0 + + +class CancellingBackend: + """Withdraws the run from inside it, the way a user pressing Cancel does.""" + + def __init__(self, service: ExportService) -> None: + self._service = service + self.stages: List[ExportStage] = [] + + @property + def export_format(self) -> ExportFormat: + return ExportFormat.NSF + + @property + def supported_scopes(self) -> frozenset: + return frozenset(ExportScope) + + def extension(self, scope: ExportScope) -> str: + return ".nsf" + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + report: ExportReporter = SILENT_REPORTER, + ) -> ExportArtifact: + announce(report, ExportStage.WALKING, NOTHING_WRITTEN, None) + self.stages.append(ExportStage.WALKING) + self._service.cancel() + announce(report, ExportStage.COMPRESSING, ONE_FILE, None) + self.stages.append(ExportStage.COMPRESSING) + return ExportArtifact(paths=(destination,), truncation=None) + + def write_sample( + self, + destination: Path, + request: SampleExport, + report: ExportReporter = SILENT_REPORTER, + ) -> ExportArtifact: + raise NotImplementedError + + def write_project( + self, + destination: Path, + request: ProjectExport, + report: ExportReporter = SILENT_REPORTER, + ) -> ExportArtifact: + raise NotImplementedError + + +class TestWhatARunSaysAboutItself: + """The export speaks the vocabulary every other service speaks.""" + + def test_a_run_opens_with_a_start(self, service, tmp_path) -> None: + export_service, results = service + export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument()) + assert isinstance(results[0], ServiceStarted) + + def test_the_stage_a_format_names_reaches_the_subscriber(self, service, tmp_path) -> None: + export_service, results = service + export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument()) + reported = [result for result in results if isinstance(result, ServiceProgress)] + assert reported and all(result.current_item == ExportStage.WRITING for result in reported) + + def test_a_stage_that_lands_on_its_total_is_reported(self, service, tmp_path) -> None: + export_service, results = service + export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument()) + reported = [result for result in results if isinstance(result, ServiceProgress)] + assert reported[-1].completed == ONE_FILE + + +class TestWithdrawingARun: + """A cancelled export answers with a cancellation rather than a failure.""" + + def test_a_cancelled_run_ends_cancelled(self, service, tmp_path) -> None: + export_service, results = service + export_service.export_instrument( + tmp_path / "instrument.nsf", + CancellingBackend(export_service), + build_instrument(), + ) + assert isinstance(outcome(results), ServiceCancelled) + + def test_a_cancelled_run_reports_no_failure(self, service, tmp_path) -> None: + export_service, results = service + export_service.export_instrument( + tmp_path / "instrument.nsf", + CancellingBackend(export_service), + build_instrument(), + ) + assert not any(isinstance(result, (ExportSuccess, ExportError)) for result in results) + + def test_the_format_stops_where_it_was_told(self, service, tmp_path) -> None: + export_service, _ = service + backend = CancellingBackend(export_service) + export_service.export_instrument(tmp_path / "instrument.nsf", backend, build_instrument()) + assert backend.stages == [ExportStage.WALKING] + + +class TestOneExportAtATime: + """An export claims the application, so a second request is declined rather than queued.""" + + def test_a_finished_run_leaves_the_service_idle(self, service, tmp_path) -> None: + export_service, _ = service + export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument()) + assert export_service.is_running() is False + + def test_a_request_arriving_mid_run_is_declined(self, service, tmp_path) -> None: + export_service, _ = service + backend = StubBackend() + second = StubBackend() + + def start_another() -> None: + export_service.export_instrument(tmp_path / "second.fti", second, build_instrument()) + + backend.on_write = start_another + export_service.export_instrument(tmp_path / "instrument.fti", backend, build_instrument()) + assert second.calls == [] diff --git a/tests/unit/sampletones_application/services/test_progress.py b/tests/unit/sampletones_application/services/test_progress.py new file mode 100644 index 000000000..b8935e976 --- /dev/null +++ b/tests/unit/sampletones_application/services/test_progress.py @@ -0,0 +1,78 @@ +from typing import Final, List + +from sampletones_application.services.progress import ( + PROGRESS_STEPS, + UNMEASURED, + StageProgress, +) +from sampletones_application.services.render.result import RenderStage +from sampletones_application.services.result import ServiceProgress +from sampletones_core.exports.stage import ExportStage + +TOTAL_SAMPLES: Final[int] = PROGRESS_STEPS * 100 +STEP: Final[int] = TOTAL_SAMPLES // PROGRESS_STEPS +PROGRAM_AREA: Final[int] = 32429 +FIRST_SIZE: Final[int] = 12689 +SMALLER_SIZE: Final[int] = FIRST_SIZE - PROGRAM_AREA // PROGRESS_STEPS - 1 + + +class TestReportingAtABoundedRate: + """A stage reports on a fraction of what it is measured against, whatever its length.""" + + def test_a_step_reaches_the_subscriber(self) -> None: + reports: List[ServiceProgress[RenderStage]] = [] + progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) + progress.advance(STEP) + assert reports[-1].completed == STEP + + def test_a_move_short_of_a_step_is_held_back(self) -> None: + reports: List[ServiceProgress[RenderStage]] = [] + progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) + progress.advance(STEP - 1) + assert reports == [] + + def test_a_stage_landing_on_its_total_is_always_reported(self) -> None: + reports: List[ServiceProgress[RenderStage]] = [] + progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) + progress.advance(TOTAL_SAMPLES) + progress.advance(TOTAL_SAMPLES) + assert [report.completed for report in reports] == [TOTAL_SAMPLES, TOTAL_SAMPLES] + + def test_the_stage_names_what_the_counts_are_in(self) -> None: + reports: List[ServiceProgress[RenderStage]] = [] + progress = StageProgress(RenderStage.ENCODING, TOTAL_SAMPLES, emit=reports.append, estimates=True) + progress.advance(TOTAL_SAMPLES) + assert reports[-1].current_item == RenderStage.ENCODING + + +class TestACountThatFalls: + """A compression's bytes fall as the dictionary earns its keep, and that is still a step.""" + + def test_a_fall_of_a_step_is_reported(self) -> None: + reports: List[ServiceProgress[ExportStage]] = [] + progress = StageProgress(ExportStage.COMPRESSING, PROGRAM_AREA, emit=reports.append, estimates=False) + progress.advance(FIRST_SIZE) + progress.advance(SMALLER_SIZE) + assert [report.completed for report in reports] == [FIRST_SIZE, SMALLER_SIZE] + + +class TestWhereAnEstimateStands: + """A remaining time is stated only where the count travels toward the total.""" + + def test_a_stage_travelling_to_its_total_estimates(self) -> None: + reports: List[ServiceProgress[RenderStage]] = [] + progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) + progress.advance(TOTAL_SAMPLES) + assert reports[-1].eta_seconds is not None + + def test_a_stage_measured_against_a_limit_states_none(self) -> None: + reports: List[ServiceProgress[ExportStage]] = [] + progress = StageProgress(ExportStage.COMPRESSING, PROGRAM_AREA, emit=reports.append, estimates=False) + progress.advance(FIRST_SIZE) + assert reports[-1].eta_seconds is None + + def test_a_stage_with_nothing_to_measure_against_states_none(self) -> None: + reports: List[ServiceProgress[ExportStage]] = [] + progress = StageProgress(ExportStage.WALKING, UNMEASURED, emit=reports.append, estimates=True) + progress.advance(UNMEASURED) + assert reports[-1].eta_seconds is None diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py index 5449f8eef..dd84a2d2f 100644 --- a/tests/unit/sampletones_application/test_busy_lock.py +++ b/tests/unit/sampletones_application/test_busy_lock.py @@ -1,6 +1,14 @@ +from pathlib import Path from unittest.mock import MagicMock from sampletones_application.application import Application +from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.services.result import ServiceProgress, ServiceStarted +from sampletones_core.exports.stage import ExportStage + +NOTHING_MEASURED: int = 0 +BYTES_SO_FAR: int = 812 def _application( @@ -8,6 +16,7 @@ def _application( converter_running: bool = False, library_generating: bool = False, rendering: bool = False, + exporting: bool = False, ) -> Application: """An application with only the attributes the busy methods touch, bypassing the full composition root constructor.""" @@ -19,13 +28,15 @@ def _application( application._reconstructions_tab = MagicMock() application._render_coordinator = MagicMock() application._render_coordinator.is_active = rendering + application.export_service = MagicMock() + application.export_service.is_running.return_value = exporting application._update_menu = MagicMock() return application class TestBusySourceOfTruth: - """``_is_operation_active`` is the single busy authority: a conversion, a library generation or - a render each make it true, and only an idle set makes it false.""" + """``_is_operation_active`` is the single busy authority: a conversion, a library generation, a + render or an export each make it true, and only an idle set makes it false.""" def test_busy_while_converter_runs(self) -> None: assert _application(converter_running=True)._is_operation_active() is True @@ -36,10 +47,45 @@ def test_busy_while_library_generates(self) -> None: def test_busy_while_song_renders(self) -> None: assert _application(rendering=True)._is_operation_active() is True + def test_busy_while_an_export_writes(self) -> None: + assert _application(exporting=True)._is_operation_active() is True + def test_idle_when_none_runs(self) -> None: assert _application()._is_operation_active() is False +class TestExportEdges: + """An export claims the application while it writes, so its start and its end are busy edges.""" + + def test_a_start_refreshes_the_busy_state(self) -> None: + application = _application() + application._on_export_activity(ServiceStarted(total=NOTHING_MEASURED)) + application._update_menu.assert_called_once_with() + + def test_a_report_mid_run_is_no_edge(self) -> None: + application = _application() + application._on_export_activity( + ServiceProgress( + completed=BYTES_SO_FAR, + total=NOTHING_MEASURED, + current_item=ExportStage.COMPRESSING, + ) + ) + application._update_menu.assert_not_called() + + def test_a_finished_export_refreshes_the_busy_state(self) -> None: + application = _application() + application._on_export_activity( + ExportSuccess( + kind=ExportKind.SAMPLE, + filepath=Path("song.nsf"), + export_format=None, + truncation=None, + ) + ) + application._update_menu.assert_called_once_with() + + class TestBusyRefreshPropagation: """A busy-state change nudges both tabs to re-evaluate their action buttons and the menu to re-read what may start another such operation; each reads the live busy authority for itself, From 7b67317583e385a8d07145ca54c3d8e7d3a29bc3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 21:09:58 +0200 Subject: [PATCH 061/142] Added: stem removal from a loaded reconstruction --- docs/concepts/stems.md | 16 + docs/guide/interface.md | 5 + src/sampletones_application/application.py | 49 ++- .../categories/elements/reconstructions.py | 2 + .../coordinators/reconstruction.py | 36 +- .../coordinators/tabs/reconstruction.py | 76 ++++- .../coordinators/tabs/sequencer.py | 8 + .../logic/reconstruction/data.py | 20 +- .../logic/reconstruction/edit.py | 38 +++ .../logic/reconstruction/manager.py | 4 +- .../logic/sequencer/history_detail.py | 17 +- .../tags/reconstructions.py | 6 + .../ui/elements/stems/list.py | 12 +- .../ui/panels/main/converter.py | 1 + .../ui/panels/reconstruction/stems.py | 11 +- src/sampletones_config/lang/en.yaml | 2 + .../reconstruction/stems/removal.py | 205 +++++++++++ .../test_stems_reconstruction.py | 92 ++++- .../coordinators/test_reconstruction.py | 34 +- .../logic/reconstruction/test_data.py | 81 +++++ .../test_application_retune.py | 4 +- .../test_application_sample_rebind.py | 8 +- .../ui/elements/stems/test_list.py | 43 +++ .../panels/reconstruction/test_stems_panel.py | 28 +- .../reconstruction/test_stems_removal.py | 318 ++++++++++++++++++ 25 files changed, 1061 insertions(+), 55 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/edit.py create mode 100644 src/sampletones_core/reconstructions/reconstruction/stems/removal.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index 2f96d03d0..5d67637f5 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -222,6 +222,11 @@ silences them. [Playback](../development/playback.md). Saving the reconstruction records the assignment, never the selection. So is the banding: collapsing the levels changes how the card draws, never what it describes. +7. **Removing a recording edits the document.** Where a box steers listening, + the remove button rewrites what is described: the entry leaves the recorded + setup, its frames rest, and the change is asked about first and recorded in + the project history. A reconstruction holds at least one recording, so the + last row standing keeps its button held back. ### Mechanics @@ -237,3 +242,14 @@ choice changes; the coordinator wires the card's `on_stem_channels_changed` hook to that handler. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. + +Removal runs through `without_stem` +(`sampletones_core.reconstructions.reconstruction.stems.removal`), which returns +a fresh reconstruction: the entry leaves the setup, taking its level along once +that level holds nothing else; its source path leaves `audio_filepath` from the +position it stood at; every frame it held states the silent instruction, zeroes +its samples and takes `RESTING_STEM_ID`; a channel the removal empties stands +by; and the mixed approximation is summed afresh. The tab coordinator hands the +result on as a `ReconstructionEdit`, the payload both a regenerated instrument +and a removed recording travel as, so one path rebinds the open document and +records the edit against the project history. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index c075ccc8f..9d2bfb904 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -101,6 +101,11 @@ Click a row to show its recording in your file browser, and tick **Collapse levels** to read the whole list as one table. The ticks are yours for the session; saving records the assignment, never the selection. +**x** at the end of a row is a different matter: it takes the recording out of +the reconstruction for good, so the application asks first. The frames it held +fall silent and its row goes, leaving the rest playing as they did. One recording +always stays, so the last row keeps its **x** greyed out. + To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase presets...** writes the same as `.json`, **NSF program...** writes a single `.nsf` diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index a64f5f585..bc88ca2a0 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -48,6 +48,11 @@ document_title, ) from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.logic.reconstruction.edit import ( + InstrumentEdit, + ReconstructionEdit, + StemRemoval, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.render import SongRenderLogic from sampletones_application.parameters import ( @@ -68,7 +73,6 @@ from sampletones_application.services import ( ConversionService, ExportService, - RegeneratedInstrument, RegenerationService, RetunedSample, RetuneResult, @@ -135,6 +139,7 @@ from sampletones_application.view_model.shared.audio_settings import ( AudioSettingsViewModel, ) +from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_application.view_model.shared.project_properties import ( ProjectPropertiesViewModel, @@ -405,6 +410,7 @@ def __init__( on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, on_reconstruction_instrument_updated=self._regenerate_instrument, + on_reconstruction_stem_removed=self._reconstruction_coordinator.apply_edit, original_audio_locator=self._original_audio_locator, layout=ReconstructionTabParameters.from_config(self.layout), language_manager=self.language_manager, @@ -1002,7 +1008,7 @@ def _rebind_replaced_sample( if sample is None or sample.reconstruction is not self.reconstruction_manager.reconstruction: return - self.reconstruction_manager.apply_regenerated(reconstruction) + self.reconstruction_manager.apply_edited(reconstruction) self._reconstructions_tab.update_reconstruction() def _regenerate_instrument( @@ -1021,17 +1027,15 @@ def _regenerate_instrument( def _on_reconstruction_updated( self, - outcome: RegeneratedInstrument, + edit: ReconstructionEdit, ) -> None: """Records a reconstruction edit against the project when it owns the sample. - Regeneration produces a fresh reconstruction. When the edited document is a + An edit produces a fresh reconstruction. When the edited document is a project sample, the sample adopts the new reconstruction as one history - entry labelled with the channel and feature ``outcome`` names; the - copy-on-write swap keeps every prior snapshot's reconstruction intact. A - standalone reconstruction leaves the project untouched. Consecutive edits - of the same sample coalesce, so a continuous graph movement records a - single entry. + entry the ``edit`` labels and keys; the copy-on-write swap keeps every prior + snapshot's reconstruction intact. A standalone reconstruction leaves the + project untouched. """ sample = self._owning_project_sample() if sample is None: @@ -1039,18 +1043,29 @@ def _on_reconstruction_updated( with self.history.transaction( HistoryAction.EDIT_RECONSTRUCTION, - detail=self._sequencer_tab.reconstruction_edit_detail( - sample.id, - outcome.channel_name, - outcome.feature_key, - ), - coalesce=(sample.id,), + detail=self._edit_detail(sample.id, edit), + coalesce=edit.coalesce_key(sample.id), ): self.project_controller.replace_sample_reconstruction( sample.id, - outcome.reconstruction, + edit.reconstruction, ) + def _edit_detail(self, sample_id: str, edit: ReconstructionEdit) -> HistoryDetail: + """The history line an edit reads as: the feature it moved, or the recording it took out.""" + match edit: + case InstrumentEdit(): + return self._sequencer_tab.reconstruction_edit_detail( + sample_id, + edit.channel_name, + edit.feature_key, + ) + case StemRemoval(): + return self._sequencer_tab.reconstruction_stem_detail( + sample_id, + edit.stem_name, + ) + def _retune_samples_for_rate(self, nes_frequency: int) -> None: """Refreshes the stored reconstructions of samples left out of sync by a rate change. @@ -1120,7 +1135,7 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: ) if is_open: - self.reconstruction_manager.apply_regenerated( + self.reconstruction_manager.apply_edited( retuned.reconstruction, ) self._reconstructions_tab.update_reconstruction() diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index d06c70516..7cc1230c7 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -43,6 +43,8 @@ class ReconstructionPanelElements(AbstractElement): COLLAPSE_LEVELS = "collapse_levels" COLLAPSE_LEVELS_TOOLTIP = "collapse_levels_tooltip" STATUS_COLLAPSE_LEVELS = "status_collapse_levels" + REMOVE_STEM_DIALOG = "remove_stem_dialog" + REMOVE_STEM_MESSAGE = "remove_stem_message" class ReconstructionsInstrumentsElements(AbstractElement): diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index b66c0f5a5..23bd31998 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -7,6 +7,10 @@ from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) +from sampletones_application.logic.reconstruction.edit import ( + InstrumentEdit, + ReconstructionEdit, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.services import ( RegeneratedInstrument, @@ -64,7 +68,7 @@ def __init__( language_manager: LanguageManager, on_tab_switch: Callback, on_session_state_changed: VoidCallback, - on_reconstruction_updated: Callable[[RegeneratedInstrument], None], + on_reconstruction_updated: Callable[[ReconstructionEdit], None], is_reconstruction_embedded: Callable[[], bool], ) -> None: self._reconstruction_manager = reconstruction_manager @@ -307,24 +311,25 @@ def _on_closed(self) -> None: self._tab.close_reconstruction() self._session_manager.set_current_reconstruction(None) - def _on_updated(self, outcome: RegeneratedInstrument) -> None: - """Applies a regenerated reconstruction across the open document and project. + def apply_edit(self, edit: ReconstructionEdit) -> None: + """Applies an edited reconstruction across the open document and project. - The owning-sample hook runs first, while the manager still holds the prior - reconstruction, so it can locate the owned sample by identity and record the - edit against the project history, tagged with the channel and feature the - ``outcome`` names. The open document then rebinds to the new reconstruction, - keeping the editor and any owned sample sharing one object. + Every edit of the open document arrives here, so one path answers a regenerated + instrument and a removed recording alike. The owning-sample hook runs first, while + the manager still holds the prior reconstruction, so it can locate the owned sample + by identity and record the edit against the project history as the ``edit`` + describes itself. The open document then rebinds to the new reconstruction, keeping + the editor and any owned sample sharing one object. """ - self._on_reconstruction_updated_callback(outcome) - self._reconstruction_manager.apply_regenerated(outcome.reconstruction) + self._on_reconstruction_updated_callback(edit) + self._reconstruction_manager.apply_edited(edit.reconstruction) self._tab.update_reconstruction() self._reconstruction_manager.mark_updated() def _on_regeneration_result(self, result: RegenerationResult) -> None: match result: case ServiceSuccess(value=outcome): - self._on_updated(outcome) + self.apply_edit(self._instrument_edit(outcome)) case ServiceError(exception=exception): logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) @@ -333,6 +338,15 @@ def _on_regeneration_result(self, result: RegenerationResult) -> None: self._set_reconstruction_dimmed(self._regeneration_service.is_running()) + @staticmethod + def _instrument_edit(outcome: RegeneratedInstrument) -> InstrumentEdit: + """Reads a regeneration result as the edit the project history records.""" + return InstrumentEdit( + reconstruction=outcome.reconstruction, + channel_name=outcome.channel_name, + feature_key=outcome.feature_key, + ) + def _set_reconstruction_dimmed(self, dimmed: bool) -> None: """Fades the reconstruction waveform while the regeneration worker is busy. diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 1fcfbac6f..cd7bd3260 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -17,6 +17,7 @@ from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.logic.reconstruction.edit import StemRemoval from sampletones_application.logic.reconstruction.instruments import ( OnReconstructionInstrumentUpdatedCallback, ReconstructionInstrumentsLogic, @@ -50,6 +51,7 @@ TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION, TAG_RECONSTRUCTIONS_BROWSER_PANEL, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS, @@ -88,6 +90,7 @@ from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.scope import ExportScope +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import ( DeserializationError, @@ -120,6 +123,7 @@ def __init__( on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, + on_reconstruction_stem_removed: Callable[[StemRemoval], None], original_audio_locator: OriginalAudioLocator, *, layout: ReconstructionTabParameters, @@ -133,6 +137,7 @@ def __init__( self._export_backends = export_backends self._dialogs = dialogs self._original_audio_locator = original_audio_locator + self._on_reconstruction_stem_removed = on_reconstruction_stem_removed self._geometry = layout.geometry self._side_panel_count: int @@ -239,6 +244,7 @@ def __init__( self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels self._reconstruction_stems_panel.on_stem_channels_changed = self._reconstruction_panel_logic.set_stem_channels + self._reconstruction_stems_panel.on_stem_remove_requested = self._request_remove_stem self._browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view @@ -386,7 +392,11 @@ def _instrument_filters(self) -> Tuple[FileFilter, ...]: type selector, so the one that is picked there names the format. """ return tuple( - self._export_filter(export_format, ExportScope.INSTRUMENT) for export_format in INSTRUMENT_EXPORT_FORMATS + self._export_filter( + export_format, + ExportScope.INSTRUMENT, + ) + for export_format in INSTRUMENT_EXPORT_FORMATS ) @ignore_none_path @@ -395,7 +405,10 @@ def _handle_export_instrument( filepath: Path, channel_name: ChannelName, ) -> None: - self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath, channel_name) + self._reconstruction_panel_logic.handle_export_instrument_confirmed( + filepath, + channel_name, + ) def _open_export_instruments_dialog( self, @@ -434,14 +447,26 @@ def _handle_export_instruments( destination: Path, export_format: ExportFormat, ) -> None: - self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination, export_format) + self._reconstruction_panel_logic.handle_export_instruments_confirmed( + destination, + export_format, + ) - def _open_export_wav_dialog(self, default_filename: str, default_path: str) -> None: + def _open_export_wav_dialog( + self, + default_filename: str, + default_path: str, + ) -> None: filepath = save_file_dialog( title=self._export_messages.wav_title, initial_directory=default_path, default_filename=default_filename, - filters=(FileFilter.for_extensions(self._language_manager["global.dialog.filter.wave"], [EXT_FILE_WAVE]),), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.wave"], + [EXT_FILE_WAVE], + ), + ), ) self._handle_export_wav(filepath) @@ -580,7 +605,10 @@ def save_browser_shape(self) -> None: self._browser_panel.expanded_rows, ) - def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: + def repaint_browser_favorites( + self, + nodes: Sequence[FileSystemNode], + ) -> None: self._browser_panel.update_favorite_indicators(nodes) def display_reconstruction(self) -> None: @@ -601,6 +629,37 @@ def _request_remove_reconstruction(self, filepath: Path) -> None: path=filepath, ) + def _request_remove_stem(self, stem_id: int) -> None: + """Asks before a recording leaves the loaded reconstruction, naming the file it stands as.""" + row = self._reconstruction_stems_panel.stems_list.row(str(stem_id)) + if row is None: + return + + self._dialogs.show_confirmation( + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, + title=self._language_manager["reconstructions.reconstruction.title.remove_stem_dialog"], + message=self._language_manager["reconstructions.reconstruction.message.remove_stem_message"], + on_confirm=lambda: self._remove_stem(stem_id, row.name), + ok_label=self._lbl_remove, + path=row.path, + ) + + def _remove_stem(self, stem_id: int, name: str) -> None: + """Takes the recording out of the open document and hands the edit on to be recorded.""" + reconstruction_data = self._reconstruction_manager.current_reconstruction + if reconstruction_data is None: + return + + self._on_reconstruction_stem_removed( + StemRemoval( + reconstruction=without_stem( + reconstruction_data.reconstruction, + stem_id, + ), + stem_name=name, + ) + ) + def _request_remove_directory(self, directory: Path) -> None: self._dialogs.show_confirmation( tag=TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_DIRECTORY_CONFIRMATION, @@ -661,7 +720,10 @@ def player(self) -> AudioPlayerProtocol: def request_export_wav_dialog(self) -> None: self._reconstruction_panel_logic.request_export_wav_dialog() - def request_export_instruments_dialog(self, export_format: ExportFormat) -> None: + def request_export_instruments_dialog( + self, + export_format: ExportFormat, + ) -> None: self._reconstruction_panel_logic.request_export_instruments_dialog(export_format) def _on_browser_autoplay_error(self, exception: Exception) -> None: diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 40afe11c9..3ed224e91 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -884,6 +884,14 @@ def reconstruction_edit_detail( feature_key, ) + def reconstruction_stem_detail( + self, + sample_id: str, + stem_name: str, + ) -> HistoryDetail: + """Describes a recording taken out of a reconstruction for the project history.""" + return self._history_detail.remove_stem(sample_id, stem_name) + def _build_history_view_model(self) -> HistoryViewModel: cursor = self._history.cursor entries = tuple( diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 71027aa9a..ed058d181 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -75,17 +75,31 @@ def detached_copy(self, filepath: Path) -> Self: def with_reconstruction(self, reconstruction: Reconstruction) -> Self: """Rebinds this data to an edited reconstruction, keeping name and origin. - A regeneration produces a fresh reconstruction object; the display name, - file location and source audio are unchanged, so only the reconstruction - and its derived features are refreshed. + An edit produces a fresh reconstruction object; the display name and file location + stand, so the reconstruction, its derived features and the recordings its entries + hold are what the rebind refreshes. """ return replace( self, reconstruction=reconstruction, config=reconstruction.config, feature_data=FeatureData.load(reconstruction), + stem_audios=self._recordings_for(reconstruction), ) + def _recordings_for(self, reconstruction: Reconstruction) -> Tuple[np.ndarray, ...]: + """The loaded recordings, each following the entry it was loaded for. + + A recording belongs to the entry standing at its position, so an entry the edit keeps + carries its audio to the position it now holds and an entry taken out releases it. + Recordings the load left out stay out. + """ + if not self.stem_audios: + return () + + positions = {entry.id: index for index, entry in enumerate(self.reconstruction.stems_data.config.entries)} + return tuple(self.stem_audios[positions[entry.id]] for entry in reconstruction.stems_data.config.entries) + @classmethod def _assemble( cls, diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py new file mode 100644 index 000000000..b9da5ada0 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/edit.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Optional, TypeAlias, Union + +from sampletones_application.logic.history.transaction import CoalesceKey +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class InstrumentEdit: + """A regenerated instrument paired with the channel and feature the reader moved. + + Carrying the request context alongside the fresh reconstruction lets the project history + record which channel and feature an edit touched. + """ + + reconstruction: Reconstruction + channel_name: ChannelName + feature_key: FeatureKey + + def coalesce_key(self, sample_id: str) -> Optional[CoalesceKey]: + """Consecutive edits of one sample run together, so a graph movement records one entry.""" + return (sample_id,) + + +@dataclass(frozen=True) +class StemRemoval: + """A recording taken out of the reconstruction, named as the history reports it.""" + + reconstruction: Reconstruction + stem_name: str + + def coalesce_key(self, _sample_id: str) -> Optional[CoalesceKey]: + """Each removal stands on its own, so one undo puts one recording back.""" + return None + + +ReconstructionEdit: TypeAlias = Union[InstrumentEdit, StemRemoval] diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 88b8687d9..03fbd1bcf 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -147,8 +147,8 @@ def detach_current_reconstruction(self) -> None: name=name, ) - def apply_regenerated(self, reconstruction: Reconstruction) -> None: - """Adopts an edited reconstruction produced by regeneration. + def apply_edited(self, reconstruction: Reconstruction) -> None: + """Adopts a reconstruction an edit produced. The open document rebinds to the fresh reconstruction object so the editor and any owning project sample continue to share one identity, while the diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index e33bc1f15..8a90c43cd 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -288,6 +288,13 @@ def edit_reconstruction( self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]), ) + def remove_stem(self, sample_id: str, stem_name: str) -> Segments: + """Describes a recording taken out of a sample's reconstruction: its position and name.""" + return ( + self._sample(sample_id, colon=True), + self._name(stem_name), + ) + def value(self, number: int) -> Segments: return (self._value(str(number)),) @@ -352,7 +359,11 @@ def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment: role=HistoryDetailRole.ROW, ) - def _frame_range(self, first_position: int, last_position: int) -> HistoryDetailSegment: + def _frame_range( + self, + first_position: int, + last_position: int, + ) -> HistoryDetailSegment: """Reads a span of positions as one frame token, a single position standing as its own.""" if first_position == last_position: return self._frame(first_position) @@ -363,7 +374,9 @@ def _frame_range(self, first_position: int, last_position: int) -> HistoryDetail ) @staticmethod - def _covered_channels(covered: Set[Optional[ChannelName]]) -> List[ChannelName]: + def _covered_channels( + covered: Set[Optional[ChannelName]], + ) -> List[ChannelName]: """The channels a run of columns names, an aggregate one standing for all it summarises. Both grids carry a column that answers for every channel — the tracker's sample column and diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 8df30cf11..43628faa4 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -56,6 +56,12 @@ Widget.DIALOG, "remove_directory_confirmation", ) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.DIALOG, + "remove_stem_confirmation", +) TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 51e29d9ce..53cd5f2c8 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -61,7 +61,8 @@ class GUIStemsList(CallbackMixin): same list, so one definition draws them and each owner turns on the affordances it can honour: ``draggable`` makes a row itself the thing you drag and opens a drop strip between the bands, ``master_checkbox`` gives the row a leading box moving every channel at once, - and ``removable`` gives it the danger-toned button that takes it out. Rows are keyed by the + ``removable`` gives it the danger-toned button that takes it out, and ``retain_last_row`` + holds that button back once one row is all that stands. Rows are keyed by the identity their owner reports gestures under, and every column lines up across the bands because each table holds one fixed column per channel in play. """ @@ -75,6 +76,7 @@ def __init__( status_bar: GUIStatusBar, draggable: bool, removable: bool, + retain_last_row: bool, master_checkbox: bool, ) -> None: self._prefix = prefix @@ -83,6 +85,7 @@ def __init__( self._status_bar = status_bar self._draggable = draggable self._removable = removable + self._retain_last_row = retain_last_row self._master_checkbox = master_checkbox self._level_template = language_manager["global.stems.template.level_caption"] @@ -406,7 +409,12 @@ def _render_row(self, row: StemRowViewModel) -> None: dpg_set_value(master_tag, row.takes_part) if self._removable: - dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live) + dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live and self._releasable) + + @property + def _releasable(self) -> bool: + """Whether a row may leave, which a list holding on to its last one answers by its count.""" + return len(self._rows) > 1 or not self._retain_last_row def _row_boxes(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: """The channels the row actually draws a box for, in the order the columns stand.""" diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 0532e6c15..8b5cdeb1c 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -141,6 +141,7 @@ def __init__( status_bar=status_bar, draggable=True, removable=True, + retain_last_row=False, master_checkbox=False, ) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index d2e4a0dca..cab5ff5e3 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -38,7 +38,8 @@ class GUIReconstructionStemsPanel(GUIPanel): box moves every channel the recording offers at once. A channel switched off for the whole reconstruction shows its boxes muted while they stay as clickable as any other, so the reader's per-recording choice keeps standing. A click on a row shows the recording where it - sits on disk. + sits on disk, and the button beside it asks to take the recording out of the reconstruction + for good. The list holds on to its last row, so one recording always stands. """ def __init__( @@ -66,11 +67,13 @@ def __init__( language_manager=language_manager, status_bar=status_bar, draggable=False, - removable=False, + removable=True, + retain_last_row=True, master_checkbox=True, ) self.on_stem_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None + self.on_stem_remove_requested: Optional[Callable[[int], None]] = None super().__init__(tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS) self._enable_vertical_collapse( @@ -108,6 +111,7 @@ def create_panel(self, parent: str) -> None: self._stems_list.on_channels_changed = self._on_channels_changed self._stems_list.on_row_activated = self._on_row_activated + self._stems_list.on_remove_requested = self._on_remove_requested def update_view(self, view_model: ReconstructionStemsViewModel) -> None: self._view_model = view_model @@ -174,6 +178,9 @@ def _on_collapse_toggled(self, _sender: Sender, _value: bool) -> None: def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: self.call(self.on_stem_channels_changed, int(key), channels) + def _on_remove_requested(self, key: str) -> None: + self.call(self.on_stem_remove_requested, int(key)) + def _on_row_activated(self, key: str) -> None: """A clicked row shows its recording where it sits on disk.""" row = self._stems_list.row(key) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index abd9f545b..64cb8edb4 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -460,6 +460,8 @@ reconstructions.reconstruction.template.stems_setup: "Mode: {mode} · Channel ca reconstructions.reconstruction.label.collapse_levels: "Collapse levels" reconstructions.reconstruction.message.collapse_levels_tooltip: "Draw every recording in one table." reconstructions.reconstruction.message.status_collapse_levels: "Draw the recordings in one table or under their levels." +reconstructions.reconstruction.title.remove_stem_dialog: "Remove recording" +reconstructions.reconstruction.message.remove_stem_message: "Remove this recording from the reconstruction? Its frames fall silent." # ============================================================================= # Reconstructions tab — Instruments diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/removal.py b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py new file mode 100644 index 000000000..a2e1ebaa8 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py @@ -0,0 +1,205 @@ +from pathlib import Path +from typing import Dict, FrozenSet, List, Sequence, Tuple + +import numpy as np + +from sampletones_core.audio.mixing import mix +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions.reconstruction.approximations import ApproximationsItem +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy + + +def without_stem(reconstruction: Reconstruction, stem_id: int) -> Reconstruction: + """The reconstruction with one recording taken out, the frames it held left resting. + + A released frame states silence, zeroes the samples it rendered and takes + ``RESTING_STEM_ID`` in the assignment, which is the shape a capped run already records for + a frame every stem passed over. A channel the removal empties stands by, describing no + frame at all. Everything the removal leaves alone stands as it was — the frames of the + recordings that stay, sample for sample, and the stream of a channel that already rested + throughout — and the mixed approximation is summed afresh from what remains. + + The entry leaves the recorded setup, taking its level along once that level holds nothing + else, and its source path leaves ``audio_filepath`` from the position it stood at. The + identifier, configuration, coefficient and metadata carry over, so the result is the same + document holding one recording fewer. + + Args: + reconstruction: The reconstruction the recording is taken out of. + stem_id: The stems entry to remove. + + Returns: + Reconstruction: A fresh reconstruction holding the recordings that stay. + + Raises: + ValueError: If ``stem_id`` names no recorded entry, or names the last one standing. + """ + stems_data = reconstruction.stems_data + config = stems_data.config + if stem_id not in config.entries_by_id: + raise ValueError(f"Stem {stem_id} names no entry of the recorded setup") + + if len(config.entries) == 1: + raise ValueError("A reconstruction holds at least one stem") + + released = {item.channel_name: [held == stem_id for held in item.stem_ids] for item in stems_data.assignments} + assignments = [_released_assignment(item, released[item.channel_name]) for item in stems_data.assignments] + resting = _emptied_channels(assignments, released) + + streams = _released_streams(reconstruction, released, resting) + approximations_data = [ + ApproximationsItem( + channel_name=item.channel_name, + approximation=_released_audio(item, released, resting, reconstruction.config.frame_length), + ) + for item in reconstruction.approximations_data + ] + + return Reconstruction( + metadata=reconstruction.metadata, + id=reconstruction.id, + audio_filepath=_paths_without(reconstruction.audio_filepath, _position_of(config, stem_id)), + config=reconstruction.config, + approximation=mix([item.approximation for item in approximations_data]), + approximations_data=approximations_data, + instructions_data=[streams[channel_name] for channel_name in ChannelName.items()], + stems_data=StemsData( + config=_config_without(config, stem_id), + assignments=assignments, + ), + coefficient=reconstruction.coefficient, + ) + + +def _position_of(config: StemsConfig, stem_id: int) -> int: + """Where the entry stands among the recorded ones, which is the source path it pairs with.""" + return [entry.id for entry in config.entries].index(stem_id) + + +def _config_without(config: StemsConfig, stem_id: int) -> StemsConfig: + """The recorded setup with one entry gone, and the level it emptied gone along with it.""" + levels = [[held for held in level if held != stem_id] for level in config.hierarchy.levels] + return StemsConfig( + entries=[entry for entry in config.entries if entry.id != stem_id], + hierarchy=StemsHierarchy( + levels=[level for level in levels if level], + mode=config.hierarchy.mode, + ), + channel_cap=config.channel_cap, + ) + + +def _paths_without(paths: Tuple[Path, ...], position: int) -> Tuple[Path, ...]: + """The recorded source paths with the one at ``position`` gone, empty staying empty.""" + return tuple(path for index, path in enumerate(paths) if index != position) + + +def _released_assignment(item: ChannelAssignment, released: Sequence[bool]) -> ChannelAssignment: + """The channel's per-frame ownership with each released frame resting.""" + return ChannelAssignment( + channel_name=item.channel_name, + stem_ids=[RESTING_STEM_ID if frame_released else held for held, frame_released in zip(item.stem_ids, released)], + ) + + +def _emptied_channels( + assignments: Sequence[ChannelAssignment], + released: Dict[ChannelName, List[bool]], +) -> FrozenSet[ChannelName]: + """The channels the removal leaves resting through every frame. + + A channel reaches this state by losing frames it held, so one that already rested + throughout is left as it stood. + """ + return frozenset( + item.channel_name + for item in assignments + if any(released[item.channel_name]) and all(held == RESTING_STEM_ID for held in item.stem_ids) + ) + + +def _released_streams( + reconstruction: Reconstruction, + released: Dict[ChannelName, List[bool]], + resting: FrozenSet[ChannelName], +) -> Dict[ChannelName, InstructionsItem]: + """Every channel's stream as the removal leaves it, keyed by channel. + + A channel the assignment says nothing about keeps its stream whole: an edit already + re-derived it, so the conversion's per-frame ownership stopped applying to it. + """ + streams: Dict[ChannelName, InstructionsItem] = {} + for channel_name, stream in reconstruction.streams.items(): + if channel_name in resting: + streams[channel_name] = InstructionsItem.resting(channel_name) + elif channel_name in released: + streams[channel_name] = _released_stream(stream, released[channel_name]) + else: + streams[channel_name] = stream + + return streams + + +def _released_stream(stream: InstructionsItem, released: Sequence[bool]) -> InstructionsItem: + """The channel's stream with each released frame stating silence. + + The silent instruction takes the type the stream already carries, which is the type the + channel is read through, so the stream stays one exporter's throughout. + """ + instructions = [data.instruction for data in stream.instructions] + if not instructions: + return stream + + null: InstructionUnion = type(instructions[0]).null_instruction() + return InstructionsItem.create( + channel_name=stream.channel_name, + instructions=[ + null if index < len(released) and released[index] else instruction + for index, instruction in enumerate(instructions) + ], + initial_pitch=stream.initial_pitch, + held_features=stream.held_features, + ) + + +def _released_audio( + item: ApproximationsItem, + released: Dict[ChannelName, List[bool]], + resting: FrozenSet[ChannelName], + frame_length: int, +) -> np.ndarray: + """The channel's rendered audio with the released frames silent. + + A channel the removal empties comes back silent over its whole span, which keeps its + length among the stored waveforms while it sounds nothing. + """ + if item.channel_name in resting: + return np.zeros_like(item.approximation) + + if item.channel_name not in released: + return item.approximation + + return _silenced(item.approximation, released[item.channel_name], frame_length) + + +def _silenced(approximation: np.ndarray, released: Sequence[bool], frame_length: int) -> np.ndarray: + """The waveform with the samples of each released frame zeroed. + + Frame ``i`` renders samples ``i * frame_length`` onward, so the per-frame flags spread + across the samples they cover. Samples past the last recorded frame keep their values. + """ + silent = np.repeat(np.array(released, dtype=bool), frame_length) + span = min(len(silent), len(approximation)) + mask = np.zeros(len(approximation), dtype=bool) + mask[:span] = silent[:span] + + quiet = np.array(approximation, copy=True) + quiet[mask] = 0 + return quiet diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 2e21b7d51..e42659ca9 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import AbstractSet, Dict, Final +from typing import AbstractSet, Dict, Final, Tuple import numpy as np import pytest @@ -10,6 +10,7 @@ from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -272,6 +273,95 @@ def heard(selected: AbstractSet[int]) -> StemSelection: ) +class TestRemovingAStem: + """The shared three-stem example with one recording taken out of the document for good.""" + + def _three_stems(self, tmp_path: Path) -> Tuple[Reconstruction, Tuple[Path, Path, Path], Config]: + config = three_stem_reconstruction_config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + paths = write_three_stem_recordings(config, tmp_path) + reconstruction = reconstructor.reconstruct(list(paths), three_stem_config()) + assert reconstruction is not None + return reconstruction, paths, config + + def test_the_removed_recording_leaves_the_setup_and_the_source_paths(self, tmp_path: Path) -> None: + reconstruction, paths, _config = self._three_stems(tmp_path) + + remaining = without_stem(reconstruction, STEM_C_ID) + + assert [entry.id for entry in remaining.stems_data.config.entries] == [STEM_A_ID, STEM_B_ID] + assert remaining.stems_data.config.hierarchy.levels == [[STEM_A_ID, STEM_B_ID]] + assert remaining.audio_filepath == (paths[0], paths[1]) + + def test_the_frames_it_held_fall_silent_while_the_rest_stand(self, tmp_path: Path) -> None: + """A removal touches the removed recording's frames alone, sample for sample.""" + reconstruction, _paths, config = self._three_stems(tmp_path) + frame_length = config.library.frame_length + assignments = reconstruction.stems_data.assignments_by_channel + before = {channel: audio.copy() for channel, audio in reconstruction.approximations.items()} + + remaining = without_stem(reconstruction, STEM_C_ID) + + for channel, stem_ids in assignments.items(): + audio = remaining.approximations[channel] + for frame_index, stem_id in enumerate(stem_ids): + span = slice(frame_index * frame_length, (frame_index + 1) * frame_length) + expected = np.zeros(frame_length, dtype=np.float32) if stem_id == STEM_C_ID else before[channel][span] + np.testing.assert_array_equal(audio[span], expected) + + assert remaining.stems_data.assignments_by_channel[channel] == [ + RESTING_STEM_ID if stem_id == STEM_C_ID else stem_id for stem_id in stem_ids + ] + + def test_the_channels_it_alone_held_stand_by(self, tmp_path: Path) -> None: + """Under a cap of one, stem c alone sounds pulse 1 and noise, so both fall quiet with it. + + A channel every remaining recording passes over describes no frame at all, which is what + tells it apart from a channel that plays. + """ + reconstruction, _paths, _config = self._three_stems(tmp_path) + + remaining = without_stem(reconstruction, STEM_C_ID) + + assert remaining.playing_channels == (ChannelName.PULSE2, ChannelName.TRIANGLE) + for channel in (ChannelName.PULSE1, ChannelName.NOISE): + np.testing.assert_array_equal( + remaining.approximations[channel], + np.zeros_like(reconstruction.approximations[channel]), + ) + assert set(remaining.stems_data.assignments_by_channel[channel]) == {RESTING_STEM_ID} + + def test_the_reduced_reconstruction_round_trips_through_the_file(self, tmp_path: Path) -> None: + reconstruction, _paths, _config = self._three_stems(tmp_path) + remaining = without_stem(reconstruction, STEM_B_ID) + save_path = tmp_path / "two_stems.stn" + + remaining.save(save_path) + loaded = Reconstruction.load(save_path) + + assert [entry.id for entry in loaded.stems_data.config.entries] == [STEM_A_ID, STEM_C_ID] + assert loaded.stems_data.config.hierarchy.levels == [[STEM_A_ID], [STEM_C_ID]] + np.testing.assert_allclose(loaded.approximation, remaining.approximation, atol=_MIX_TOLERANCE) + + def test_what_stays_plays_as_it_did_before(self, tmp_path: Path) -> None: + """A removal leaves the same audio the reader heard while listening to the stems that stay.""" + reconstruction, _paths, _config = self._three_stems(tmp_path) + data = ReconstructionData.from_reconstruction(reconstruction, name="three") + channels = list(reconstruction.stems_data.assignments_by_channel) + heard_before = data.waveform_data( + StemSelection.everywhere(frozenset({STEM_A_ID, STEM_B_ID}), channels) + ).approximation + + remaining = without_stem(reconstruction, STEM_C_ID) + reduced = ReconstructionData.from_reconstruction(remaining, name="two") + heard_after = reduced.waveform_data( + StemSelection.everywhere(frozenset({STEM_A_ID, STEM_B_ID}), channels) + ).approximation + + np.testing.assert_allclose(heard_after, heard_before, atol=_MIX_TOLERANCE) + + class TestStemsOriginalAudio: def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> None: config = Config() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index b75d5ec59..d89b37a26 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -6,6 +6,7 @@ import pytest from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.logic.reconstruction.edit import StemRemoval from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.services.regeneration import RegeneratedInstrument from sampletones_application.services.result import ServiceSuccess @@ -118,7 +119,7 @@ def test_history_hook_sees_prior_reconstruction_identity( self, reconstruction_factory: ReconstructionFactory, ) -> None: - """Pins the hook-before-apply order in ``_on_updated``. + """Pins the hook-before-apply order in ``apply_edit``. The hook locates the owning project sample by identity against the prior reconstruction, so it must observe the manager before the document rebinds @@ -155,6 +156,37 @@ def test_history_hook_sees_prior_reconstruction_identity( assert manager.reconstruction is regenerated +class TestStemRemovalApplyOrdering: + def test_a_removed_recording_travels_the_same_path_as_a_regenerated_instrument( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """Every edit of the open document is applied alike, so the history sees them alike.""" + manager = ReconstructionManager(scheduling=MagicMock()) + prior = reconstruction_factory() + manager.load_reconstruction_object(prior, name="lead") + observed: List[Optional[Reconstruction]] = [] + coordinator = ReconstructionCoordinator( + manager, + MagicMock(), + MagicMock(), + MagicMock(), + dialogs=MagicMock(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=lambda _edit: observed.append(manager.reconstruction), + is_reconstruction_embedded=lambda: False, + ) + coordinator.set_reconstructions_tab(MagicMock()) + remaining = reconstruction_factory() + + coordinator.apply_edit(StemRemoval(reconstruction=remaining, stem_name="kick")) + + assert observed == [prior] + assert manager.reconstruction is remaining + + class TestReconstructionRestorePropagatesUnexpected: def test_runtime_error_propagates( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 3e668e338..2db1708fc 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -11,6 +11,7 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -393,6 +394,86 @@ def test_a_single_source_with_no_selection_is_silence( np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.original_audio) +class TestRebindingToAnEditedReconstruction: + def _three_recordings( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + """A document over three recordings, each carrying a shape of its own. + + The shapes differ rather than the levels, since loading normalises each recording and + would read three levels of one shape as the same waveform. + """ + sample_rate = Config().library.sample_rate + shapes = ( + np.linspace(-1.0, 1.0, 64, dtype=np.float32), + np.linspace(1.0, -1.0, 64, dtype=np.float32), + np.concatenate([np.ones(32, dtype=np.float32), -np.ones(32, dtype=np.float32)]), + ) + paths = [] + for index, shape in enumerate(shapes): + path = tmp_path / f"stem_{index}.wav" + write_wave(path, sample_rate, shape) + paths.append(path) + + reconstruction = reconstruction_factory().model_copy( + update={ + "audio_filepath": tuple(paths), + "stems_data": StemsData( + config=StemsConfig( + entries=[StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)], + hierarchy=StemsHierarchy(levels=[[0], [1], [2]]), + ), + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0], + ) + ], + ), + } + ) + return ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + def test_a_recording_follows_the_entry_it_was_loaded_for( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """A recording is read by position, so one entry leaving would slide the rest onto the wrong audio.""" + data = self._three_recordings(reconstruction_factory, tmp_path) + third = data.stem_audios[2] + + remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) + + np.testing.assert_allclose(remaining.original_mix_for(_heard(2)), third) + + def test_the_removed_recording_is_released( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._three_recordings(reconstruction_factory, tmp_path) + second = data.stem_audios[1] + + remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) + + assert len(remaining.stem_audios) == 2 + assert all(not np.array_equal(audio, second) for audio in remaining.stem_audios) + + def test_an_edit_keeping_every_entry_keeps_every_recording( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._three_recordings(reconstruction_factory, tmp_path) + + rebound = data.with_reconstruction(data.reconstruction.model_copy()) + + assert rebound.stem_audios == data.stem_audios + + class TestWaveformData: def test_projects_the_render_relevant_fields( self, diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index 66684f8e6..3752d1f5a 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -64,7 +64,7 @@ def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None: app._apply_retuned_sample(retuned) - app.reconstruction_manager.apply_regenerated.assert_called_once_with(retuned.reconstruction) + app.reconstruction_manager.apply_edited.assert_called_once_with(retuned.reconstruction) app._reconstructions_tab.update_reconstruction.assert_called_once() def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: @@ -75,7 +75,7 @@ def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: app._apply_retuned_sample(retuned) - app.reconstruction_manager.apply_regenerated.assert_not_called() + app.reconstruction_manager.apply_edited.assert_not_called() app._reconstructions_tab.update_reconstruction.assert_not_called() diff --git a/tests/unit/sampletones_application/test_application_sample_rebind.py b/tests/unit/sampletones_application/test_application_sample_rebind.py index dabb9a97c..a434fa712 100644 --- a/tests/unit/sampletones_application/test_application_sample_rebind.py +++ b/tests/unit/sampletones_application/test_application_sample_rebind.py @@ -27,7 +27,7 @@ def test_rebinds_the_editor_showing_the_replaced_sample(self) -> None: app._rebind_replaced_sample("bass-id", incoming) - app.reconstruction_manager.apply_regenerated.assert_called_once_with(incoming) + app.reconstruction_manager.apply_edited.assert_called_once_with(incoming) app._reconstructions_tab.update_reconstruction.assert_called_once() def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: @@ -37,7 +37,7 @@ def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: app._rebind_replaced_sample("bass-id", MagicMock()) - app.reconstruction_manager.apply_regenerated.assert_not_called() + app.reconstruction_manager.apply_edited.assert_not_called() app._reconstructions_tab.update_reconstruction.assert_not_called() def test_leaves_the_editor_alone_when_no_document_is_open(self) -> None: @@ -47,7 +47,7 @@ def test_leaves_the_editor_alone_when_no_document_is_open(self) -> None: app._rebind_replaced_sample("bass-id", MagicMock()) - app.reconstruction_manager.apply_regenerated.assert_not_called() + app.reconstruction_manager.apply_edited.assert_not_called() app._reconstructions_tab.update_reconstruction.assert_not_called() def test_ignores_an_unknown_sample(self) -> None: @@ -55,5 +55,5 @@ def test_ignores_an_unknown_sample(self) -> None: app._rebind_replaced_sample("gone", MagicMock()) - app.reconstruction_manager.apply_regenerated.assert_not_called() + app.reconstruction_manager.apply_edited.assert_not_called() app._reconstructions_tab.update_reconstruction.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 7f7780e90..944fb2192 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -73,6 +73,7 @@ def build( *, draggable: bool = True, removable: bool = True, + retain_last_row: bool = False, master_checkbox: bool = False, ) -> GUIStemsList: stems_list = GUIStemsList( @@ -82,6 +83,7 @@ def build( status_bar=GUIStatusBar(), draggable=draggable, removable=removable, + retain_last_row=retain_last_row, master_checkbox=master_checkbox, ) with dpg.window(tag=ROOT_TAG): @@ -277,6 +279,47 @@ def test_a_list_without_removal_gives_no_button(self, dpg_context: None, layout_ assert not dpg.does_item_exist(row_tag(bass, SUF_BUTTON)) +class TestRetainedLastRow: + def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it( + self, + dpg_context: None, + layout_config, + ) -> None: + stems_list = build(layout_config, retain_last_row=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + + def test_a_row_may_leave_once_another_stands_beside_it(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, retain_last_row=True) + bass = row("bass") + lead = row("lead") + + stems_list.update_view(view(bass, lead)) + + assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + + def test_the_last_row_left_standing_stops_answering(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, retain_last_row=True) + bass = row("bass") + lead = row("lead") + stems_list.update_view(view(bass, lead)) + + stems_list.update_view(view(bass)) + + assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + + def test_a_list_that_keeps_no_row_lets_the_last_one_go(self, dpg_context: None, layout_config) -> None: + stems_list = build(layout_config, retain_last_row=False) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + + class TestGestures: def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context: None, layout_config) -> None: reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py index e34fac4f8..0a8977fc0 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -14,7 +14,7 @@ PALETTES_DIRECTORY, THEME_DIRECTORY, ) -from sampletones_application.tags.general import SUF_CHECKBOX, SUF_TEXT +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_TEXT from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY, @@ -196,6 +196,32 @@ def test_unticking_the_master_box_silences_the_recording_everywhere( assert reported == [(0, frozenset())] +class TestStemsPanelRemoval: + def test_the_remove_button_reports_the_recording_it_stands_for( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + requested: List[int] = [] + render(panel) + panel.on_stem_remove_requested = requested.append + panel.update_view(_view_model(_row(0, name="bass"), _row(1, name="lead"))) + + tag = panel.stems_list.row_tag("0", SUF_BUTTON) + dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag)) + + assert requested == [0] + + def test_the_last_recording_standing_offers_no_way_out( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + """A reconstruction holds at least one recording, so its row stops answering.""" + render(panel) + panel.update_view(_view_model(_row(0, name="bass"))) + + assert not dpg.is_item_enabled(panel.stems_list.row_tag("0", SUF_BUTTON)) + + class TestStemsPanelLevels: def test_the_collapse_toggle_appears_once_there_are_levels_to_collapse( self, diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py new file mode 100644 index 000000000..39e4a123f --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py @@ -0,0 +1,318 @@ +from pathlib import Path +from typing import Dict, Final, List, Mapping, Sequence + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy + +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +STEM_C: Final[int] = 2 +FRAME_COUNT: Final[int] = 4 +PULSE_LEVEL: Final[float] = 3.0 +NOISE_LEVEL: Final[float] = 2.0 + +RECORDINGS: Final[Dict[int, Path]] = { + STEM_A: Path("/recordings/a.wav"), + STEM_B: Path("/recordings/b.wav"), + STEM_C: Path("/recordings/c.wav"), +} + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _noise() -> NoiseInstruction: + return NoiseInstruction(on=True, period=4, volume=8, short=False) + + +def _stems_config() -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry(id=STEM_A, channels=[ChannelName.PULSE1]), + StemEntry(id=STEM_B, channels=[ChannelName.PULSE1, ChannelName.NOISE]), + StemEntry(id=STEM_C, channels=[ChannelName.PULSE1]), + ], + hierarchy=StemsHierarchy( + levels=[[STEM_A], [STEM_B, STEM_C]], + mode=HierarchyMode.STRICT, + ), + channel_cap=1, + ) + + +def _reconstruction( + owners: Mapping[ChannelName, Sequence[int]], + *, + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + approximations: Mapping[ChannelName, np.ndarray], +) -> Reconstruction: + """A three-recording reconstruction whose frames are owned as ``owners`` states.""" + return Reconstruction.create( + approximation=np.zeros(0, dtype=np.float32), + approximations=approximations, + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=tuple(RECORDINGS[stem_id] for stem_id in (STEM_A, STEM_B, STEM_C)), + stems_data=StemsData( + config=_stems_config(), + assignments=[ + ChannelAssignment(channel_name=channel_name, stem_ids=list(stem_ids)) + for channel_name, stem_ids in owners.items() + ], + ), + ) + + +def _frame_length() -> int: + return Config().library.frame_length + + +def _audio(level: float) -> np.ndarray: + return np.full(FRAME_COUNT * _frame_length(), level, dtype=np.float32) + + +def _frame(audio: np.ndarray, index: int) -> np.ndarray: + frame_length = _frame_length() + return audio[index * frame_length : (index + 1) * frame_length] + + +def _sounding(reconstruction: Reconstruction, channel_name: ChannelName) -> List[bool]: + """Whether each frame of a channel states a sounding instruction.""" + return [instruction.on for instruction in reconstruction.instructions[channel_name]] + + +@pytest.fixture +def reconstruction() -> Reconstruction: + """Three recordings over two channels: the noise channel belongs to stem B throughout. + + Stem B holds the whole noise channel and the second pulse frame, so removing it both + releases single frames and empties a channel — the two outcomes a removal has to answer. + """ + return _reconstruction( + { + ChannelName.PULSE1: [STEM_A, STEM_B, STEM_C, RESTING_STEM_ID], + ChannelName.NOISE: [STEM_B] * FRAME_COUNT, + }, + instructions={ + ChannelName.PULSE1: [_pulse(60), _pulse(61), _pulse(62), _pulse(63)], + ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)], + }, + approximations={ + ChannelName.PULSE1: _audio(PULSE_LEVEL), + ChannelName.NOISE: _audio(NOISE_LEVEL), + }, + ) + + +class TestTheRecordedSetup: + def test_the_removed_recording_leaves_the_entries(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_C) + + assert [entry.id for entry in remaining.stems_data.config.entries] == [STEM_A, STEM_B] + + def test_the_removed_recording_leaves_its_level(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_C) + + assert remaining.stems_data.config.hierarchy.levels == [[STEM_A], [STEM_B]] + + def test_a_level_the_removal_empties_goes_along_with_it(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_A) + + assert remaining.stems_data.config.hierarchy.levels == [[STEM_B, STEM_C]] + + def test_the_picking_order_and_the_cap_carry_over(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_C) + + assert remaining.stems_data.config.hierarchy.mode == HierarchyMode.STRICT + assert remaining.stems_data.config.channel_cap == 1 + + def test_the_removed_recordings_source_path_goes_from_its_position( + self, + reconstruction: Reconstruction, + ) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert remaining.audio_filepath == (RECORDINGS[STEM_A], RECORDINGS[STEM_C]) + + def test_a_detached_reconstruction_stays_detached(self, reconstruction: Reconstruction) -> None: + reconstruction.detach_source() + + remaining = without_stem(reconstruction, STEM_B) + + assert remaining.audio_filepath == () + + +class TestTheReleasedFrames: + def test_the_frames_it_held_rest_in_the_assignment(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert remaining.stems_data.assignments_by_channel[ChannelName.PULSE1] == [ + STEM_A, + RESTING_STEM_ID, + STEM_C, + RESTING_STEM_ID, + ] + + def test_the_frames_it_held_state_silence(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert _sounding(remaining, ChannelName.PULSE1) == [True, False, True, True] + + def test_the_frames_it_held_lose_their_samples(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + audio = remaining.approximations[ChannelName.PULSE1] + np.testing.assert_array_equal(_frame(audio, 1), np.zeros(_frame_length(), dtype=np.float32)) + + def test_the_recordings_that_stay_keep_their_frames(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + audio = remaining.approximations[ChannelName.PULSE1] + kept = np.full(_frame_length(), PULSE_LEVEL, dtype=np.float32) + for index in (0, 2, 3): + np.testing.assert_array_equal(_frame(audio, index), kept) + + def test_the_channel_keeps_its_length(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert len(remaining.approximations[ChannelName.PULSE1]) == FRAME_COUNT * _frame_length() + + def test_a_channel_the_removal_leaves_alone_keeps_its_audio(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_C) + + np.testing.assert_array_equal( + remaining.approximations[ChannelName.NOISE], + _audio(NOISE_LEVEL), + ) + + +class TestAnEmptiedChannel: + def test_a_channel_left_entirely_released_stands_by(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert remaining.playing_channels == (ChannelName.PULSE1,) + + def test_a_channel_left_entirely_released_sounds_nothing(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + np.testing.assert_array_equal( + remaining.approximations[ChannelName.NOISE], + np.zeros(FRAME_COUNT * _frame_length(), dtype=np.float32), + ) + + def test_a_channel_left_entirely_released_rests_through_every_frame( + self, + reconstruction: Reconstruction, + ) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert remaining.stems_data.assignments_by_channel[ChannelName.NOISE] == [RESTING_STEM_ID] * FRAME_COUNT + + def test_a_channel_that_already_rested_throughout_keeps_its_stream(self) -> None: + """A removal reaching none of a channel's frames leaves that channel exactly as it stood.""" + reconstruction = _reconstruction( + { + ChannelName.PULSE1: [STEM_A, STEM_B, STEM_C, RESTING_STEM_ID], + ChannelName.NOISE: [RESTING_STEM_ID] * FRAME_COUNT, + }, + instructions={ + ChannelName.PULSE1: [_pulse(60), _pulse(61), _pulse(62), _pulse(63)], + ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)], + }, + approximations={ + ChannelName.PULSE1: _audio(PULSE_LEVEL), + ChannelName.NOISE: _audio(NOISE_LEVEL), + }, + ) + + remaining = without_stem(reconstruction, STEM_B) + + assert _sounding(remaining, ChannelName.NOISE) == [True] * FRAME_COUNT + np.testing.assert_array_equal(remaining.approximations[ChannelName.NOISE], _audio(NOISE_LEVEL)) + + def test_a_channel_an_edit_re_derived_keeps_its_stream(self) -> None: + """A channel the assignment says nothing about is the editor's, so a removal passes it by.""" + reconstruction = _reconstruction( + {ChannelName.PULSE1: [STEM_A, STEM_B, STEM_C, RESTING_STEM_ID]}, + instructions={ + ChannelName.PULSE1: [_pulse(60), _pulse(61), _pulse(62), _pulse(63)], + ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)], + }, + approximations={ + ChannelName.PULSE1: _audio(PULSE_LEVEL), + ChannelName.NOISE: _audio(NOISE_LEVEL), + }, + ) + + remaining = without_stem(reconstruction, STEM_B) + + assert _sounding(remaining, ChannelName.NOISE) == [True] * FRAME_COUNT + np.testing.assert_array_equal(remaining.approximations[ChannelName.NOISE], _audio(NOISE_LEVEL)) + + +class TestTheMixedApproximation: + def test_the_mix_is_summed_afresh_from_what_stays(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + expected = np.full(FRAME_COUNT * _frame_length(), PULSE_LEVEL, dtype=np.float32) + expected[_frame_length() : 2 * _frame_length()] = 0.0 + np.testing.assert_array_equal(remaining.approximation, expected) + + +class TestTheDocument: + def test_the_document_keeps_its_identity(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_C) + + assert remaining.id == reconstruction.id + assert remaining.config == reconstruction.config + assert remaining.coefficient == reconstruction.coefficient + assert remaining.metadata == reconstruction.metadata + + def test_the_source_reconstruction_is_left_as_it_stood(self, reconstruction: Reconstruction) -> None: + without_stem(reconstruction, STEM_B) + + assert [entry.id for entry in reconstruction.stems_data.config.entries] == [STEM_A, STEM_B, STEM_C] + assert reconstruction.stems_data.assignments_by_channel[ChannelName.NOISE] == [STEM_B] * FRAME_COUNT + np.testing.assert_array_equal(reconstruction.approximations[ChannelName.NOISE], _audio(NOISE_LEVEL)) + + +class TestARefusedRemoval: + def test_removing_an_unrecorded_stem_is_refused(self, reconstruction: Reconstruction) -> None: + with pytest.raises(ValueError, match="names no entry"): + without_stem(reconstruction, 7) + + def test_removing_the_last_recording_is_refused(self) -> None: + reconstruction = Reconstruction.create( + approximation=np.zeros(0, dtype=np.float32), + approximations={ChannelName.PULSE1: _audio(PULSE_LEVEL)}, + instructions={ChannelName.PULSE1: [_pulse(60)] * FRAME_COUNT}, + config=Config(), + coefficient=1.0, + audio_filepath=(RECORDINGS[STEM_A],), + stems_data=StemsData.single_entry( + [ChannelName.PULSE1], + [ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[STEM_A] * FRAME_COUNT, + ) + ], + ), + ) + + with pytest.raises(ValueError, match="at least one stem"): + without_stem(reconstruction, STEM_A) From 0874fb5ecdb505b55f6cd26b8a1bb28ff60990b1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 21:32:45 +0200 Subject: [PATCH 062/142] Showed: what an export is doing while it runs --- src/sampletones_application/application.py | 29 ++ .../categories/hierarchy.py | 1 + .../coordinators/export.py | 68 ++++ .../coordinators/project.py | 32 +- .../coordinators/tabs/reconstruction.py | 26 +- .../layout/settings/__init__.py | 2 + .../layout/settings/export.py | 7 + .../logic/export/__init__.py | 3 + .../logic/export/logic.py | 164 ++++++++++ .../logic/export/protocol.py | 19 ++ .../services/export/reporter.py | 13 +- .../services/export/result.py | 6 + src/sampletones_application/tags/settings.py | 48 +++ .../ui/panels/dialogs/export.py | 212 ++++++++++++ .../view_model/shared/export.py | 95 ++++++ .../boundaries/general.yaml | 1 + src/sampletones_config/lang/en.yaml | 6 + .../layout/settings/export.yaml | 3 + src/sampletones_core/exports/stage.py | 15 + .../coordinators/tabs/test_reconstruction.py | 16 +- .../logic/export/__init__.py | 0 .../logic/export/test_logic.py | 305 ++++++++++++++++++ .../ui/panels/dialogs/test_export.py | 144 +++++++++ .../view_model/shared/test_export.py | 92 ++++++ 24 files changed, 1281 insertions(+), 26 deletions(-) create mode 100644 src/sampletones_application/coordinators/export.py create mode 100644 src/sampletones_application/layout/settings/export.py create mode 100644 src/sampletones_application/logic/export/__init__.py create mode 100644 src/sampletones_application/logic/export/logic.py create mode 100644 src/sampletones_application/logic/export/protocol.py create mode 100644 src/sampletones_application/ui/panels/dialogs/export.py create mode 100644 src/sampletones_application/view_model/shared/export.py create mode 100644 src/sampletones_config/layout/settings/export.yaml create mode 100644 tests/unit/sampletones_application/logic/export/__init__.py create mode 100644 tests/unit/sampletones_application/logic/export/test_logic.py create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_export.py create mode 100644 tests/unit/sampletones_application/view_model/shared/test_export.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 86f07f335..3a6e3ebe7 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -16,6 +16,7 @@ from sampletones_application.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator from sampletones_application.coordinators.edit.router import EditRouter +from sampletones_application.coordinators.export import SongExportCoordinator from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -35,6 +36,7 @@ from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator from sampletones_application.exports import build_export_backends from sampletones_application.layout import LayoutConfig, load_layout_config +from sampletones_application.logic.export import SongExportLogic from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.instruction.library_manager import ( @@ -104,6 +106,7 @@ from sampletones_application.ui.panels.dialogs.display_settings import ( GUIDisplaySettingsWindow, ) +from sampletones_application.ui.panels.dialogs.export import GUIExportWindow from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow from sampletones_application.ui.panels.dialogs.project_properties import ( GUIProjectPropertiesWindow, @@ -148,6 +151,7 @@ from sampletones_core.exporters import Features from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.stage import ExportStage from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -309,6 +313,13 @@ def __init__( shortcut_source=self._shortcut_source, status_bar=self.status_bar, ) + self.export_window: GUIExportWindow = GUIExportWindow( + layout=self.layout.settings, + text_colors=self.layout.general.colors.text, + language_manager=self.language_manager, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + ) self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow( layout=self.layout.project_properties, language_manager=self.language_manager, @@ -516,6 +527,22 @@ def __init__( on_activity_changed=self._on_render_activity_changed, ) + self._export_logic = SongExportLogic( + self.export_service, + stage_labels={ + ExportStage.WALKING: self.language_manager["settings.export.label.stage_walking"], + ExportStage.COMPRESSING: self.language_manager["settings.export.label.stage_compressing"], + ExportStage.WRITING: self.language_manager["settings.export.label.stage_writing"], + }, + size_template=self.language_manager["settings.export.template.size"], + cancelling_label=self.language_manager["settings.export.message.status_cancelling"], + ) + + self._export_coordinator = SongExportCoordinator( + self._export_logic, + window=self.export_window, + ) + self._shell = ApplicationShell( session_manager=self.session_manager, language_manager=self.language_manager, @@ -1497,6 +1524,7 @@ def _is_project_open(self) -> bool: def _exit_application(self) -> None: self._render_coordinator.cleanup() + self._export_coordinator.cleanup() stop_background_workers() self._playback_router.shutdown() self._main_tab.cleanup() @@ -1554,6 +1582,7 @@ def run(self) -> None: return finally: self._render_coordinator.cleanup() + self._export_coordinator.cleanup() stop_background_workers() self._playback_router.shutdown() self._main_tab.cleanup() diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 8a2689e60..b404889cf 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -97,3 +97,4 @@ class Panel(StrEnum): KEYBINDINGS = auto() PROPERTIES = auto() RENDER = auto() + EXPORT = auto() diff --git a/src/sampletones_application/coordinators/export.py b/src/sampletones_application/coordinators/export.py new file mode 100644 index 000000000..87faca81d --- /dev/null +++ b/src/sampletones_application/coordinators/export.py @@ -0,0 +1,68 @@ +from typing import Optional + +from sampletones_application.logic.export import SongExportLogic +from sampletones_application.ui.panels.dialogs.export import GUIExportWindow +from sampletones_application.view_model.shared.export import SongExportViewModel + + +class SongExportCoordinator: + """Owns the screen a running export holds: the window opens on the run's first word and goes + when its outcome arrives. + + An export is started from wherever the reader asked for one — a menu, a panel, the system's + own save dialog — so nothing is orchestrated here on the way in. What is orchestrated is the + way out: the window leaves the screen the moment the run is over, which is what lets the + dialog reporting the outcome open onto a clear screen. + """ + + def __init__( + self, + export_logic: SongExportLogic, + *, + window: GUIExportWindow, + ) -> None: + self._logic = export_logic + self._window = window + self._view_model: Optional[SongExportViewModel] = None + self._window_open = False + + self._logic.on_view_changed = self._on_view_changed + self._logic.on_started = self._open + self._logic.on_finished = self._close + + self._window.on_cancel = self._logic.cancel + + @property + def is_active(self) -> bool: + """An export holds the screen from its first word until the outcome that ends it.""" + return self._logic.is_active + + def cleanup(self) -> None: + """Winds a running export down for application exit.""" + self._logic.cleanup() + + def _on_view_changed(self, view_model: SongExportViewModel) -> None: + """Keeps the open window standing at where the run has got to.""" + self._view_model = view_model + if self._window_open: + self._window.update_view(view_model) + + def _open(self) -> None: + self._window_open = True + self._window.open(self._require_view_model()) + + def _close(self) -> None: + self._window_open = False + self._view_model = None + self._window.hide() + + def _require_view_model(self) -> SongExportViewModel: + """The run the window opens on. + + Raises: + SystemError: when the window is raised before the logic offers a view. + """ + if self._view_model is None: + raise SystemError("The export window is opened over the view the logic emits") + + return self._view_model diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 1f66d7790..4eafc8432 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Dict, Optional, Tuple @@ -31,6 +32,7 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.scope import ExportScope @@ -295,27 +297,41 @@ def _save(self, filepath: Path) -> bool: return True def _on_export_result(self, result: ExportResult) -> None: - """Reports a finished project export in the words of the format it was written in.""" + """Reports a finished project export in the words of the format it was written in. + + A run long enough to watch held a window while it ran, and DearPyGui carries one modal at + a time, so the report waits for the frame that draws the screen without it. + """ match result: case ExportSuccess( kind=ExportKind.PROJECT, export_format=ExportFormat() as export_format, ): - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message), - self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), + self._present( + partial( + self._dialogs.show_info, + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message), + self._title(GlobalDialogTitleElements.PROJECT_EXPORTED), + ) ) case ExportError( kind=ExportKind.PROJECT, export_format=ExportFormat() as export_format, exception=exception, ): - self._dialogs.show_error( - exception, - self._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message), + self._present( + partial( + self._dialogs.show_error, + exception, + self._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message), + ) ) + def _present(self, raise_dialog: VoidCallback) -> None: + """Raises ``raise_dialog`` once the frame the export window left the screen in has finished.""" + FrameCallbackManager.set_frame_callback(raise_dialog) + def _guard_open( self, *, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index d2fa855a2..5d37f6dbb 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, Dict, Optional, Sequence, Tuple @@ -285,16 +286,21 @@ def __init__( ) def _on_export_result(self, result: ExportResult) -> None: + """Reports a finished export in the words of the artefact it produced. + + A run long enough to watch held a window while it ran, and DearPyGui carries one modal at + a time, so the report waits for the frame that draws the screen without it. + """ messages = self._export_messages match result: case ExportSuccess(kind=ExportKind.WAV, filepath=fp): - self._dialogs.show_message_with_path(messages.wav_title, messages.wav_success, fp) + self._present_path(messages.wav_title, messages.wav_success, fp) case ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=fp, truncation=truncation, ): - self._dialogs.show_message_with_path( + self._present_path( messages.status_title, self._export_message( messages.instrument_success, @@ -308,7 +314,7 @@ def _on_export_result(self, result: ExportResult) -> None: filepath=fp, truncation=truncation, ): - self._dialogs.show_message_with_path( + self._present_path( messages.status_title, self._export_message( messages.instruments_success, @@ -318,11 +324,19 @@ def _on_export_result(self, result: ExportResult) -> None: fp, ) case ExportError(kind=ExportKind.WAV, exception=exception): - self._dialogs.show_error(exception, messages.wav_failed) + self._present_error(exception, messages.wav_failed) case ExportError(kind=ExportKind.INSTRUMENT, exception=exception): - self._dialogs.show_error(exception, messages.instrument_failed) + self._present_error(exception, messages.instrument_failed) case ExportError(kind=ExportKind.SAMPLE, exception=exception): - self._dialogs.show_error(exception, messages.instruments_failed) + self._present_error(exception, messages.instruments_failed) + + def _present_path(self, title: str, message: str, filepath: Path) -> None: + """Reports a written file once the frame the export window left the screen in has finished.""" + FrameCallbackManager.set_frame_callback(partial(self._dialogs.show_message_with_path, title, message, filepath)) + + def _present_error(self, exception: Exception, message: str) -> None: + """Reports a failure once the frame the export window left the screen in has finished.""" + FrameCallbackManager.set_frame_callback(partial(self._dialogs.show_error, exception, message)) def _export_message( self, diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index 99c11b68b..f95124389 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -2,6 +2,7 @@ from sampletones_application.layout.settings.audio import AudioSettingsLayout from sampletones_application.layout.settings.display import DisplaySettingsLayout +from sampletones_application.layout.settings.export import ExportSettingsLayout from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout from sampletones_application.layout.settings.render import RenderSettingsLayout @@ -19,3 +20,4 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True): display: DisplaySettingsLayout keybindings: KeybindingsSettingsLayout render: RenderSettingsLayout + export: ExportSettingsLayout diff --git a/src/sampletones_application/layout/settings/export.py b/src/sampletones_application/layout/settings/export.py new file mode 100644 index 000000000..5c4d289a9 --- /dev/null +++ b/src/sampletones_application/layout/settings/export.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from sampletones_application.layout.primitives import Dimensions + + +class ExportSettingsLayout(BaseModel, extra="forbid", frozen=True): + window: Dimensions diff --git a/src/sampletones_application/logic/export/__init__.py b/src/sampletones_application/logic/export/__init__.py new file mode 100644 index 000000000..eedfdff7e --- /dev/null +++ b/src/sampletones_application/logic/export/__init__.py @@ -0,0 +1,3 @@ +from .logic import SongExportLogic + +__all__ = ["SongExportLogic"] diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py new file mode 100644 index 000000000..a4d528d45 --- /dev/null +++ b/src/sampletones_application/logic/export/logic.py @@ -0,0 +1,164 @@ +from typing import Callable, Dict, Final, List, Optional + +from sampletones_application.services.export.result import ( + ExportError, + ExportResult, + ExportSuccess, +) +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceProgress, + ServiceStarted, +) +from sampletones_application.view_model.shared.export import ( + NO_PROGRESS, + NOTHING_MEASURED, + ExportPhase, + SongExportViewModel, +) +from sampletones_core.exports.stage import TRAVELLING_STAGES, ExportStage +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +from .protocol import ExportProgressServiceProtocol + +NO_FIGURE: Final[str] = "" + + +class SongExportLogic(CallbackMixin): + """Owns what a running export looks like to the reader: which stages it has reached, and + where the one under way stands. + + A format states its own stages as it reaches them, so the run's shape is learned rather than + declared: a tracker instrument reports one stage and a console program three. What the stage + under way has covered is read in the unit that stage counts in — a fraction where it arrives + at an end, and the bytes against the room there is where it does not. + + An export holds the application while it writes, so the dialog stands from the first word the + run says until the outcome that ends it, and cancelling is answered at the next point the + format looks up. + """ + + def __init__( + self, + export_service: ExportProgressServiceProtocol, + *, + stage_labels: Dict[ExportStage, str], + size_template: str, + cancelling_label: str, + ) -> None: + self._service = export_service + self._stage_labels = stage_labels + self._size_template = size_template + self._cancelling_label = cancelling_label + + self._phase: ExportPhase = ExportPhase.IDLE + self._stages: List[ExportStage] = [] + self._figure: str = NO_FIGURE + self._progress: float = NO_PROGRESS + self._travelling: bool = False + + self._service.subscribe(self._on_service_result) + + self.on_view_changed: Optional[Callable[[SongExportViewModel], None]] = None + self.on_started: Optional[VoidCallback] = None + self.on_finished: Optional[VoidCallback] = None + + @property + def is_active(self) -> bool: + """An export holds the dialog from its first word until the outcome that ends it.""" + return self._phase != ExportPhase.IDLE + + def stage_label(self, stage: ExportStage) -> str: + """What the reader knows ``stage`` by.""" + return self._stage_labels[stage] + + def cancel(self) -> None: + """Asks a running export to stop at the next point the format looks up.""" + if not self._service.is_running(): + return + + self._phase = ExportPhase.CANCELLING + self._figure = self._cancelling_label + self._travelling = False + self._emit_view() + self._service.cancel() + + def cleanup(self) -> None: + """Winds a running export down for application exit.""" + self._service.shutdown() + + def _on_service_result(self, result: ExportResult) -> None: + match result: + case ServiceStarted(): + self._on_started() + case ServiceProgress() as progress: + self._on_progress(progress) + case ExportSuccess() | ExportError() | ServiceCancelled(): + self._on_finished() + + def _on_started(self) -> None: + self._phase = ExportPhase.EXPORTING + self._stages = [] + self._figure = NO_FIGURE + self._progress = NO_PROGRESS + self._travelling = True + self._emit_view() + self.call(self.on_started) + + def _on_progress(self, progress: ServiceProgress[ExportStage]) -> None: + """Puts the stage's own reading on screen, holding what a stop was asked under.""" + if self._phase == ExportPhase.CANCELLING: + return + + stage = progress.current_item + if stage is None: + return + + self._reach(stage) + self._travelling = stage in TRAVELLING_STAGES + self._progress = self._fraction(progress) + self._figure = self._figure_text(progress) + self._emit_view() + + def _reach(self, stage: ExportStage) -> None: + if stage not in self._stages: + self._stages.append(stage) + + def _fraction(self, progress: ServiceProgress[ExportStage]) -> float: + if progress.total <= NOTHING_MEASURED: + return NO_PROGRESS + + return progress.completed / progress.total + + def _figure_text(self, progress: ServiceProgress[ExportStage]) -> str: + """What the stage under way has covered, stated where the stage travels toward no end. + + A stage arriving at an end carries its own bar and the percentage written over it, so the + figure is spelled out for the one that does not: what the song takes so far against what + the console has room for, which is the answer the reader is waiting on. + """ + if self._travelling or progress.total <= NOTHING_MEASURED: + return NO_FIGURE + + return self._size_template.format( + completed=progress.completed, + total=progress.total, + ) + + def _on_finished(self) -> None: + self._phase = ExportPhase.IDLE + self._emit_view() + self.call(self.on_finished) + + def _emit_view(self) -> None: + self.call(self.on_view_changed, self._view_model()) + + def _view_model(self) -> SongExportViewModel: + return SongExportViewModel( + phase=self._phase, + stages=tuple(self._stages), + figure=self._figure, + progress=self._progress, + travelling=self._travelling, + ) diff --git a/src/sampletones_application/logic/export/protocol.py b/src/sampletones_application/logic/export/protocol.py new file mode 100644 index 000000000..cd991128c --- /dev/null +++ b/src/sampletones_application/logic/export/protocol.py @@ -0,0 +1,19 @@ +from typing import Callable, Protocol + +from sampletones_application.services.export.result import ExportResult + + +class ExportProgressServiceProtocol(Protocol): + """The slice of the export service the progress dialog's logic drives. + + Typing the collaborator structurally keeps the logic layer independent of the service + implementation; the composition root supplies the real service. + """ + + def subscribe(self, handler: Callable[[ExportResult], None]) -> None: ... + + def cancel(self) -> None: ... + + def is_running(self) -> bool: ... + + def shutdown(self) -> None: ... diff --git a/src/sampletones_application/services/export/reporter.py b/src/sampletones_application/services/export/reporter.py index c9834525c..6633e7767 100644 --- a/src/sampletones_application/services/export/reporter.py +++ b/src/sampletones_application/services/export/reporter.py @@ -1,16 +1,9 @@ -from typing import Callable, Final, FrozenSet, Optional +from typing import Callable, Optional from sampletones_application.services.progress import UNMEASURED, StageProgress from sampletones_application.services.result import ServiceProgress from sampletones_core.exports.progress import ExportProgress -from sampletones_core.exports.stage import ExportStage - -ESTIMATED_STAGES: Final[FrozenSet[ExportStage]] = frozenset( - { - ExportStage.WALKING, - ExportStage.WRITING, - } -) +from sampletones_core.exports.stage import TRAVELLING_STAGES, ExportStage class ExportProgressReporter: @@ -56,7 +49,7 @@ def _limiter(self, progress: ExportProgress) -> StageProgress[ExportStage]: progress.stage, UNMEASURED if progress.total is None else progress.total, emit=self._emit, - estimates=progress.stage in ESTIMATED_STAGES, + estimates=progress.stage in TRAVELLING_STAGES, ) return self._progress diff --git a/src/sampletones_application/services/export/result.py b/src/sampletones_application/services/export/result.py index 14d010780..e88ee5674 100644 --- a/src/sampletones_application/services/export/result.py +++ b/src/sampletones_application/services/export/result.py @@ -16,3 +16,9 @@ ExportError, ServiceCancelled, ] + +__all__ = [ + "ExportError", + "ExportResult", + "ExportSuccess", +] diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index 8a48c2f2e..5c84aee27 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -390,3 +390,51 @@ Widget.BUTTON, "cancel", ) +TAG_SETTINGS_EXPORT_WINDOW = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.WINDOW, + "export", +) +TAG_SETTINGS_EXPORT_GROUP_STAGES = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.GROUP, + "stages", +) +TAG_SETTINGS_EXPORT_GROUP_MEASURED = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.GROUP, + "measured", +) +TAG_SETTINGS_EXPORT_GROUP_WORKING = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.GROUP, + "working", +) +TAG_SETTINGS_EXPORT_TEXT_STAGE = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.TEXT, + "stage", +) +TAG_SETTINGS_EXPORT_TEXT_FIGURE = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.TEXT, + "figure", +) +TAG_SETTINGS_EXPORT_PROGRESS = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.PROGRESS, + "export", +) +TAG_SETTINGS_EXPORT_BUTTON_CANCEL = TagName( + Page.SETTINGS, + Panel.EXPORT, + Widget.BUTTON, + "cancel", +) diff --git a/src/sampletones_application/ui/panels/dialogs/export.py b/src/sampletones_application/ui/panels/dialogs/export.py new file mode 100644 index 000000000..0fa69707d --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/export.py @@ -0,0 +1,212 @@ +from typing import Any, Dict, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.text import TextColors +from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.settings import ( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + TAG_SETTINGS_EXPORT_GROUP_MEASURED, + TAG_SETTINGS_EXPORT_GROUP_STAGES, + TAG_SETTINGS_EXPORT_GROUP_WORKING, + TAG_SETTINGS_EXPORT_PROGRESS, + TAG_SETTINGS_EXPORT_TEXT_FIGURE, + TAG_SETTINGS_EXPORT_TEXT_STAGE, + TAG_SETTINGS_EXPORT_WINDOW, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.view_model.shared.export import SongExportViewModel +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.types.callback import VoidCallback + + +class GUIExportWindow(GUIDialogWindow): + """Modal report over an export while it runs. + + The file itself was named in the system's own save dialog, so this window has one face: what + the run is doing. The stages appear as the format reaches them, the one under way sitting at + the foot of the list, and it carries either a bar filling toward its end or the turning + indicator of work whose length the data decides. + + Cancelling is offered for as long as the run can still answer one. + """ + + _fits_content = True + + def __init__( + self, + *, + layout: SettingsLayout, + text_colors: TextColors, + language_manager: LanguageManager, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._language_manager = language_manager + self._text_colors = text_colors + self._view_model: SongExportViewModel = SongExportViewModel.idle() + + self.on_cancel: Optional[VoidCallback] = None + + self._stage_labels: Dict[ExportStage, str] = { + ExportStage.WALKING: language_manager["settings.export.label.stage_walking"], + ExportStage.COMPRESSING: language_manager["settings.export.label.stage_compressing"], + ExportStage.WRITING: language_manager["settings.export.label.stage_writing"], + } + + super().__init__( + tag=TAG_SETTINGS_EXPORT_WINDOW, + width=layout.export.window.width, + height=layout.export.window.height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open(self, view_model: SongExportViewModel) -> None: + """Shows the window over the run that has just begun.""" + self._view_model = view_model + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The drawn values are seeded by :meth:`open` before the tree rebuilds.""" + + def update_view(self, view_model: SongExportViewModel) -> None: + """Re-draws the open window from where the run stands.""" + self._view_model = view_model + self._render() + + def create_window(self) -> None: + with self.dialog_window( + label=self._language_manager["settings.export.title.window_title"], + on_close=None, + ): + self._create_stages() + self._create_measured() + self._create_working() + dpg.add_separator() + self._create_cancel() + + self._render() + self._install_navigation( + [ + FocusStop.button( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + self._request_cancel, + ) + ], + on_escape=self._request_cancel, + ) + + def _create_stages(self) -> None: + with dpg.group(tag=TAG_SETTINGS_EXPORT_GROUP_STAGES): + for stage in ExportStage: + tag = self._stage_tag(stage) + dpg.add_text( + self._stage_labels[stage], + tag=tag, + show=False, + ) + FontRegistry.bind_to_item(tag, Font.REGULAR) + + def _create_measured(self) -> None: + with dpg.group( + tag=TAG_SETTINGS_EXPORT_GROUP_MEASURED, + show=False, + ): + dpg.add_progress_bar( + tag=TAG_SETTINGS_EXPORT_PROGRESS, + default_value=0.0, + width=-1, + ) + FontRegistry.bind_to_item( + TAG_SETTINGS_EXPORT_PROGRESS, + Font.MONO, + ) + + def _create_working(self) -> None: + """The reading of a stage whose length the data decides: what it holds, and that it turns. + + A bar would have to state a fraction of something, and this stage travels toward nothing, + so what stands here is the figure it does know beside a symbol that keeps moving. + """ + with dpg.group( + tag=TAG_SETTINGS_EXPORT_GROUP_WORKING, + show=False, + horizontal=True, + ): + dpg.add_loading_indicator( + style=1, + radius=2.0, + thickness=1.5, + ) + dpg.add_text("", tag=TAG_SETTINGS_EXPORT_TEXT_FIGURE) + FontRegistry.bind_to_item( + TAG_SETTINGS_EXPORT_TEXT_FIGURE, + Font.MONO_SMALL, + ) + + def _create_cancel(self) -> None: + GUIButton( + tag=TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_cancel, + width=-1, + ) + + def _render(self) -> None: + """Draws the run as it stands: what it has been through, and where the latest stage is.""" + view_model = self._view_model + self._render_stages(view_model) + dpg_configure_item( + TAG_SETTINGS_EXPORT_GROUP_MEASURED, + show=view_model.progress_visible, + ) + dpg_set_value(TAG_SETTINGS_EXPORT_PROGRESS, view_model.progress) + dpg_configure_item( + TAG_SETTINGS_EXPORT_PROGRESS, + overlay=view_model.progress_overlay, + ) + dpg_configure_item( + TAG_SETTINGS_EXPORT_GROUP_WORKING, + show=view_model.working_visible, + ) + dpg_set_value(TAG_SETTINGS_EXPORT_TEXT_FIGURE, view_model.figure) + dpg_configure_item( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + enabled=view_model.cancel_enabled, + ) + + def _render_stages(self, view_model: SongExportViewModel) -> None: + """Lists what the run has reached, the stage under way reading ahead of the ones behind.""" + for stage in ExportStage: + tag = self._stage_tag(stage) + dpg_configure_item(tag, show=view_model.stage_visible(stage)) + dpg_set_palette_color(tag, self._stage_color(view_model, stage)) + + def _stage_color( + self, + view_model: SongExportViewModel, + stage: ExportStage, + ) -> BaseColor: + if view_model.stage_reached(stage): + return self._text_colors.disabled + + return self._text_colors.default + + def _stage_tag(self, stage: ExportStage) -> str: + return compose_tag(TAG_SETTINGS_EXPORT_TEXT_STAGE, stage.value) + + def _request_cancel(self) -> None: + if self._view_model.cancel_enabled: + self.call(self.on_cancel) diff --git a/src/sampletones_application/view_model/shared/export.py b/src/sampletones_application/view_model/shared/export.py new file mode 100644 index 000000000..40d9fd6bc --- /dev/null +++ b/src/sampletones_application/view_model/shared/export.py @@ -0,0 +1,95 @@ +from enum import StrEnum +from typing import Final, FrozenSet, Optional, Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.percent import format_percent +from sampletones_core.exports.stage import ExportStage + +NO_PROGRESS: Final[float] = 0.0 +NOTHING_MEASURED: Final[int] = 0 + + +class ExportPhase(StrEnum): + IDLE = "idle" + EXPORTING = "exporting" + CANCELLING = "cancelling" + + +ACTIVE_PHASES: Final[FrozenSet[ExportPhase]] = frozenset( + { + ExportPhase.EXPORTING, + ExportPhase.CANCELLING, + } +) + + +class SongExportViewModel(BaseModel, frozen=True): + """What the export dialog draws: the stages a run has reached, and where the latest one is. + + A run's shape is its format's own — a tracker instrument is written and done with, while a + program is played out, compressed and then written — so the stages are listed as they are + reached rather than laid out in advance. The one at the end of the list is the one under way, + and the stages before it are behind. + + Attributes: + phase: Where the run stands, from the dialog opening to the outcome that closes it. + stages: The stages the run has reached, in the order it reached them. + figure: What the stage under way has covered, in the words its own unit is stated in. + progress: How far the stage under way has got, from 0 to 1, where it travels to an end. + travelling: Whether the stage under way arrives at what it is measured against. + """ + + phase: ExportPhase + stages: Tuple[ExportStage, ...] + figure: str + progress: float + travelling: bool + + @classmethod + def idle(cls) -> "SongExportViewModel": + """The dialog with no run behind it, which is what the window opens on.""" + return cls( + phase=ExportPhase.IDLE, + stages=(), + figure="", + progress=NO_PROGRESS, + travelling=False, + ) + + @property + def is_active(self) -> bool: + return self.phase in ACTIVE_PHASES + + @property + def stage(self) -> Optional[ExportStage]: + """The stage under way, and ``None`` before the run names its first.""" + return self.stages[-1] if self.stages else None + + @property + def progress_visible(self) -> bool: + """Whether a bar stands, which a stage arriving at an end is what earns.""" + return self.travelling + + @property + def working_visible(self) -> bool: + """Whether the turning indicator stands, which is how a stage without an end reads.""" + return not self.travelling + + @property + def progress_overlay(self) -> str: + """The percentage label rendered over the progress bar, derived from the fraction.""" + return format_percent(self.progress) + + @property + def cancel_enabled(self) -> bool: + """Whether a running export still takes a stop, which one already stopping has taken.""" + return self.phase == ExportPhase.EXPORTING + + def stage_visible(self, stage: ExportStage) -> bool: + """Whether ``stage`` is listed, which the run reaching it is what decides.""" + return stage in self.stages + + def stage_reached(self, stage: ExportStage) -> bool: + """Whether ``stage`` is behind the run, which is what dims it in the list.""" + return self.stage_visible(stage) and stage != self.stage diff --git a/src/sampletones_config/boundaries/general.yaml b/src/sampletones_config/boundaries/general.yaml index 92755314b..5c84c680d 100644 --- a/src/sampletones_config/boundaries/general.yaml +++ b/src/sampletones_config/boundaries/general.yaml @@ -7,4 +7,5 @@ groups: service_contracts: - sampletones_application.services.result - sampletones_application.services.render.result + - sampletones_application.services.export.result - sampletones_application.services.song_player.result diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 18b610694..3b83e1f86 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -765,6 +765,12 @@ settings.render.message.status_completed: "Render complete." settings.render.message.status_failed: "Render failed." settings.render.message.rendered: "The song was rendered successfully." settings.render.message.render_failed: "Failed to render the song." +settings.export.title.window_title: "Exporting" +settings.export.label.stage_walking: "Playing the song out" +settings.export.label.stage_compressing: "Compressing the song" +settings.export.label.stage_writing: "Writing the file" +settings.export.template.size: "{completed} of {total} bytes" +settings.export.message.status_cancelling: "Stopping the export..." settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" diff --git a/src/sampletones_config/layout/settings/export.yaml b/src/sampletones_config/layout/settings/export.yaml new file mode 100644 index 000000000..6843f19d3 --- /dev/null +++ b/src/sampletones_config/layout/settings/export.yaml @@ -0,0 +1,3 @@ +window: + width: 460 + height: 120 diff --git a/src/sampletones_core/exports/stage.py b/src/sampletones_core/exports/stage.py index 7db1eba0d..874e71ec6 100644 --- a/src/sampletones_core/exports/stage.py +++ b/src/sampletones_core/exports/stage.py @@ -1,4 +1,5 @@ from enum import StrEnum +from typing import Final, FrozenSet class ExportStage(StrEnum): @@ -8,8 +9,22 @@ class ExportStage(StrEnum): reaches that point through two longer passes: the song is played out tick by tick, and the result is compressed to what the console has room for. Each stage counts in its own unit, so what a report means is read from the stage it names. + + A stage either travels toward what it is measured against or is merely measured against it. + Walking arrives at the song's last tick and writing at its last file, so how far each has come + is how far it has to go. Compressing ends when the song offers no further phrase that pays for + itself, so its bytes are measured against the room the console has and reach it only by + overflowing; :data:`TRAVELLING_STAGES` is what separates the two. """ WALKING = "walking" COMPRESSING = "compressing" WRITING = "writing" + + +TRAVELLING_STAGES: Final[FrozenSet[ExportStage]] = frozenset( + { + ExportStage.WALKING, + ExportStage.WRITING, + } +) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index 7b80d538a..d8fe9fab9 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -6,6 +6,7 @@ from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.tabs import reconstruction as reconstruction_module from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) @@ -23,6 +24,7 @@ LoadReconstructionError, UnhandledReconstructionError, ) +from sampletones_shared.types.callback import VoidCallback from tests.suite.language import FakeLanguageManager FILE_NOT_FOUND_KEY: Final[str] = "reconstructions.browser.message.file_not_found" @@ -248,8 +250,18 @@ def test_request_remove_directory_prompts_with_path( @pytest.fixture -def export_coordinator() -> ReconstructionTabCoordinator: - """A coordinator with only the collaborators ``_on_export_result`` touches.""" +def export_coordinator(monkeypatch: pytest.MonkeyPatch) -> ReconstructionTabCoordinator: + """A coordinator with only the collaborators ``_on_export_result`` touches. + + A report waits for the frame the export window leaves the screen in, so the wait is run + through at once and what the coordinator reports stays observable from the call that asks. + """ + + def run_now(callback: VoidCallback, frame_count: int = 1) -> None: + callback() + + monkeypatch.setattr(reconstruction_module.FrameCallbackManager, "set_frame_callback", run_now) + instance = object.__new__(ReconstructionTabCoordinator) instance._dialogs = MagicMock() instance._export_messages = ExportMessages.build(LanguageManager(LANG_EN)) diff --git a/tests/unit/sampletones_application/logic/export/__init__.py b/tests/unit/sampletones_application/logic/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py new file mode 100644 index 000000000..3877dd4a1 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -0,0 +1,305 @@ +from typing import Callable, Final, List, Optional + +import pytest + +from sampletones_application.logic.export import SongExportLogic +from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.result import ExportResult, ExportSuccess +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceProgress, + ServiceStarted, +) +from sampletones_application.view_model.shared.export import ( + ExportPhase, + SongExportViewModel, +) +from sampletones_core.exports.stage import ExportStage + +WALKING_LABEL: Final[str] = "Playing the song out" +COMPRESSING_LABEL: Final[str] = "Compressing the song" +WRITING_LABEL: Final[str] = "Writing the file" +SIZE_TEMPLATE: Final[str] = "{completed} of {total} bytes" +CANCELLING_LABEL: Final[str] = "Stopping the export..." +NOTHING_MEASURED: Final[int] = 0 +PROGRAM_AREA: Final[int] = 32429 +SONG_TICKS: Final[int] = 14400 +REACHED_SIZE: Final[int] = 8761 +WALKED_TICKS: Final[int] = 7200 + + +class FakeExportService: + """The export service as the dialog's logic drives it, holding what it was asked to do.""" + + def __init__(self, *, running: bool = True) -> None: + self.running = running + self.cancels: int = 0 + self.shutdowns: int = 0 + self._handler: Optional[Callable[[ExportResult], None]] = None + + def subscribe(self, handler: Callable[[ExportResult], None]) -> None: + self._handler = handler + + def cancel(self) -> None: + self.cancels += 1 + + def is_running(self) -> bool: + return self.running + + def shutdown(self) -> None: + self.shutdowns += 1 + + def deliver(self, result: ExportResult) -> None: + assert self._handler is not None + self._handler(result) + + +def progress( + stage: ExportStage, + completed: int, + total: int, +) -> ServiceProgress[ExportStage]: + return ServiceProgress(completed=completed, total=total, current_item=stage) + + +@pytest.fixture(name="service") +def service_fixture() -> FakeExportService: + return FakeExportService() + + +@pytest.fixture(name="logic") +def logic_fixture(service: FakeExportService) -> SongExportLogic: + return SongExportLogic( + service, + stage_labels={ + ExportStage.WALKING: WALKING_LABEL, + ExportStage.COMPRESSING: COMPRESSING_LABEL, + ExportStage.WRITING: WRITING_LABEL, + }, + size_template=SIZE_TEMPLATE, + cancelling_label=CANCELLING_LABEL, + ) + + +@pytest.fixture(name="views") +def views_fixture(logic: SongExportLogic) -> List[SongExportViewModel]: + views: List[SongExportViewModel] = [] + logic.on_view_changed = views.append + return views + + +def finished() -> ExportSuccess: + from pathlib import Path + + return ExportSuccess( + kind=ExportKind.SAMPLE, + filepath=Path("song.nsf"), + export_format=None, + truncation=None, + ) + + +class TestFollowingARun: + """The stages are listed as the format reaches them, so the run's shape is learned.""" + + def test_a_start_puts_the_dialog_on_screen( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + opened: List[bool] = [] + logic.on_started = lambda: opened.append(True) + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + assert opened == [True] + + def test_a_stage_joins_the_list_when_the_run_reaches_it( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS)) + service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) + assert views[-1].stages == (ExportStage.WALKING, ExportStage.COMPRESSING) + + def test_a_stage_reported_again_is_listed_once( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) + service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE - 100, PROGRAM_AREA)) + assert views[-1].stages == (ExportStage.COMPRESSING,) + + def test_a_second_run_starts_the_list_over( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.WRITING, 1, 1)) + service.deliver(finished()) + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + assert views[-1].stages == () + + +class TestHowEachStageReads: + """A stage travelling to an end is a fraction; one measured against a limit is a figure.""" + + def test_a_travelling_stage_carries_its_share( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS)) + assert views[-1].progress == pytest.approx(WALKED_TICKS / SONG_TICKS) + + def test_a_travelling_stage_states_no_figure( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS)) + assert views[-1].figure == "" + + def test_a_stage_measured_against_a_limit_spells_out_what_it_holds( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) + assert views[-1].figure == f"{REACHED_SIZE} of {PROGRAM_AREA} bytes" + + def test_a_stage_measured_against_a_limit_carries_no_bar( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) + assert views[-1].travelling is False + + def test_a_stage_with_nothing_to_measure_against_stands_at_the_start( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(progress(ExportStage.WALKING, NOTHING_MEASURED, NOTHING_MEASURED)) + assert views[-1].progress == 0.0 + + +class TestStoppingARun: + """A stop reaches the service, and what the dialog says holds until the outcome arrives.""" + + def test_a_stop_reaches_the_service( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + logic.cancel() + assert service.cancels == 1 + + def test_a_stop_says_so( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + logic.cancel() + assert views[-1].figure == CANCELLING_LABEL + + def test_a_report_arriving_after_a_stop_leaves_the_message_standing( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + logic.cancel() + service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) + assert views[-1].figure == CANCELLING_LABEL + + def test_a_stop_asked_of_nothing_reaches_no_service( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + service.running = False + logic.cancel() + assert service.cancels == 0 + + def test_exiting_winds_a_running_export_down( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + logic.cleanup() + assert service.shutdowns == 1 + + +class TestTheOutcomeThatCloses: + """Whatever the run answered with, the dialog hands the screen back.""" + + def test_a_finished_run_takes_the_dialog_off_screen( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + closed: List[bool] = [] + logic.on_finished = lambda: closed.append(True) + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(finished()) + assert closed == [True] + + def test_a_cancelled_run_takes_the_dialog_off_screen( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + closed: List[bool] = [] + logic.on_finished = lambda: closed.append(True) + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(ServiceCancelled()) + assert closed == [True] + + def test_a_run_that_ended_holds_the_screen_no_longer( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(finished()) + assert logic.is_active is False + + def test_a_running_export_holds_the_screen( + self, + logic: SongExportLogic, + service: FakeExportService, + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + assert logic.is_active is True + + def test_the_phase_returns_to_idle( + self, + logic: SongExportLogic, + service: FakeExportService, + views: List[SongExportViewModel], + ) -> None: + service.deliver(ServiceStarted(total=NOTHING_MEASURED)) + service.deliver(finished()) + assert views[-1].phase == ExportPhase.IDLE diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py new file mode 100644 index 000000000..c6e2b2f30 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -0,0 +1,144 @@ +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + TAG_SETTINGS_EXPORT_GROUP_MEASURED, + TAG_SETTINGS_EXPORT_GROUP_WORKING, + TAG_SETTINGS_EXPORT_PROGRESS, + TAG_SETTINGS_EXPORT_TEXT_FIGURE, + TAG_SETTINGS_EXPORT_TEXT_STAGE, +) +from sampletones_application.ui.panels.dialogs.export import GUIExportWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.export import ( + ExportPhase, + SongExportViewModel, +) +from sampletones_core.exports.stage import ExportStage +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +HALFWAY: Final[float] = 0.5 +SIZE: Final[str] = "8761 of 32429 bytes" + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIExportWindow: + return GUIExportWindow( + layout=layout_config.settings, + text_colors=layout_config.general.colors.text, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def render( + window: GUIExportWindow, + *, + stages: Tuple[ExportStage, ...] = (ExportStage.WALKING,), + phase: ExportPhase = ExportPhase.EXPORTING, + figure: str = "", + progress: float = HALFWAY, + travelling: bool = True, +) -> None: + """Builds the widget tree and draws the given state, the way an open window is kept up to date.""" + window.create_window() + window.update_view( + SongExportViewModel( + phase=phase, + stages=stages, + figure=figure, + progress=progress, + travelling=travelling, + ) + ) + + +def stage_tag(stage: ExportStage) -> str: + return compose_tag(TAG_SETTINGS_EXPORT_TEXT_STAGE, stage.value) + + +def shown(tag: str) -> bool: + return bool(dpg.get_item_configuration(tag)["show"]) + + +class TestTheStageList: + """A stage appears once the run has reached it, and stays as the run moves on.""" + + def test_a_reached_stage_is_listed(self, window: GUIExportWindow) -> None: + render(window, stages=(ExportStage.WALKING,)) + assert shown(stage_tag(ExportStage.WALKING)) + + def test_a_stage_the_run_never_reached_is_left_off(self, window: GUIExportWindow) -> None: + render(window, stages=(ExportStage.WALKING,)) + assert not shown(stage_tag(ExportStage.WRITING)) + + def test_every_stage_reached_stays_on_the_list(self, window: GUIExportWindow) -> None: + render(window, stages=(ExportStage.WALKING, ExportStage.COMPRESSING, ExportStage.WRITING)) + listed: List[ExportStage] = [stage for stage in ExportStage if shown(stage_tag(stage))] + assert listed == [ExportStage.WALKING, ExportStage.COMPRESSING, ExportStage.WRITING] + + def test_the_reader_finds_each_stage_under_its_own_name(self, window: GUIExportWindow) -> None: + render(window, stages=(ExportStage.COMPRESSING,)) + assert dpg.get_value(stage_tag(ExportStage.COMPRESSING)) == "Compressing the song" + + +class TestHowTheStageUnderWayReads: + """A stage arriving at an end carries a bar; one measured against a limit carries a figure.""" + + def test_a_travelling_stage_shows_its_bar(self, window: GUIExportWindow) -> None: + render(window, travelling=True) + assert shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED) + assert not shown(TAG_SETTINGS_EXPORT_GROUP_WORKING) + + def test_a_bar_stands_where_the_stage_has_reached(self, window: GUIExportWindow) -> None: + render(window, travelling=True, progress=HALFWAY) + assert dpg.get_value(TAG_SETTINGS_EXPORT_PROGRESS) == pytest.approx(HALFWAY) + + def test_a_bar_is_labelled_with_the_share_it_has_covered(self, window: GUIExportWindow) -> None: + render(window, travelling=True, progress=HALFWAY) + assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_PROGRESS)["overlay"] == "50%" + + def test_a_stage_without_an_end_shows_what_it_holds(self, window: GUIExportWindow) -> None: + render(window, travelling=False, figure=SIZE) + assert shown(TAG_SETTINGS_EXPORT_GROUP_WORKING) + assert not shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED) + + def test_the_figure_reaches_the_reader(self, window: GUIExportWindow) -> None: + render(window, travelling=False, figure=SIZE) + assert dpg.get_value(TAG_SETTINGS_EXPORT_TEXT_FIGURE) == SIZE + + +class TestStoppingARun: + """Cancel stands while the run can still answer it, and reports the ask once.""" + + def test_a_running_export_offers_a_stop(self, window: GUIExportWindow) -> None: + render(window, phase=ExportPhase.EXPORTING) + assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"] + + def test_an_export_already_stopping_offers_no_further_stop(self, window: GUIExportWindow) -> None: + render(window, phase=ExportPhase.CANCELLING) + assert not dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"] + + def test_pressing_cancel_asks_the_run_to_stop(self, window: GUIExportWindow) -> None: + asked: List[bool] = [] + window.on_cancel = lambda: asked.append(True) + render(window, phase=ExportPhase.EXPORTING) + dpg.get_item_callback(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON))() + assert asked == [True] + + def test_a_run_already_stopping_takes_no_second_ask(self, window: GUIExportWindow) -> None: + asked: List[bool] = [] + window.on_cancel = lambda: asked.append(True) + render(window, phase=ExportPhase.CANCELLING) + dpg.get_item_callback(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON))() + assert asked == [] diff --git a/tests/unit/sampletones_application/view_model/shared/test_export.py b/tests/unit/sampletones_application/view_model/shared/test_export.py new file mode 100644 index 000000000..d803a5950 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_export.py @@ -0,0 +1,92 @@ +from typing import Final, Tuple + +from sampletones_application.view_model.shared.export import ( + ExportPhase, + SongExportViewModel, +) +from sampletones_core.exports.stage import ExportStage + +HALFWAY: Final[float] = 0.5 +NO_PROGRESS: Final[float] = 0.0 +SIZE: Final[str] = "8761 of 32429 bytes" + + +def view_model( + *, + phase: ExportPhase = ExportPhase.EXPORTING, + stages: Tuple[ExportStage, ...] = (ExportStage.WALKING,), + figure: str = "", + progress: float = HALFWAY, + travelling: bool = True, +) -> SongExportViewModel: + return SongExportViewModel( + phase=phase, + stages=stages, + figure=figure, + progress=progress, + travelling=travelling, + ) + + +class TestWhatTheDialogOpensOn: + """A window with no run behind it draws nothing and offers no stop.""" + + def test_an_idle_dialog_lists_no_stage(self) -> None: + assert SongExportViewModel.idle().stages == () + + def test_an_idle_dialog_is_not_active(self) -> None: + assert SongExportViewModel.idle().is_active is False + + def test_an_idle_dialog_offers_no_stop(self) -> None: + assert SongExportViewModel.idle().cancel_enabled is False + + +class TestWhereTheRunStands: + """The stage at the foot of the list is the one under way; the rest are behind it.""" + + def test_the_last_stage_reached_is_the_one_under_way(self) -> None: + reached = (ExportStage.WALKING, ExportStage.COMPRESSING) + assert view_model(stages=reached).stage == ExportStage.COMPRESSING + + def test_a_run_that_has_named_nothing_has_no_stage_under_way(self) -> None: + assert view_model(stages=()).stage is None + + def test_a_stage_the_run_has_left_reads_as_behind(self) -> None: + reached = (ExportStage.WALKING, ExportStage.COMPRESSING) + assert view_model(stages=reached).stage_reached(ExportStage.WALKING) is True + + def test_the_stage_under_way_does_not_read_as_behind(self) -> None: + reached = (ExportStage.WALKING, ExportStage.COMPRESSING) + assert view_model(stages=reached).stage_reached(ExportStage.COMPRESSING) is False + + def test_a_stage_the_run_never_reached_is_left_off_the_list(self) -> None: + assert view_model().stage_visible(ExportStage.WRITING) is False + + +class TestHowTheStageUnderWayReads: + """A stage arriving at an end carries a bar; one that does not carries the turning symbol.""" + + def test_a_travelling_stage_shows_its_bar(self) -> None: + assert view_model(travelling=True).progress_visible is True + + def test_a_travelling_stage_hides_the_turning_symbol(self) -> None: + assert view_model(travelling=True).working_visible is False + + def test_a_stage_without_an_end_shows_the_turning_symbol(self) -> None: + assert view_model(travelling=False, figure=SIZE).working_visible is True + + def test_a_bar_is_labelled_with_the_share_it_has_covered(self) -> None: + assert view_model(progress=HALFWAY).progress_overlay == "50%" + + +class TestStoppingARun: + """A stop is offered until it is taken.""" + + def test_a_running_export_takes_a_stop(self) -> None: + assert view_model(phase=ExportPhase.EXPORTING).cancel_enabled is True + + def test_an_export_already_stopping_takes_no_further_stop(self) -> None: + assert view_model(phase=ExportPhase.CANCELLING).cancel_enabled is False + + def test_an_export_being_stopped_still_holds_the_screen(self) -> None: + assert view_model(phase=ExportPhase.CANCELLING).is_active is True From 47a1dc89e43e107e7c5b18d879cf433b32143260 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 21:33:26 +0200 Subject: [PATCH 063/142] Added: the converter's overwrite confirmation --- docs/guide/interface.md | 6 + .../categories/elements/main.py | 3 + .../coordinators/tabs/main.py | 17 +++ .../logic/main/converter.py | 26 +++- src/sampletones_application/tags/main.py | 6 + src/sampletones_config/lang/en.yaml | 3 + .../converter/plan/directory.py | 6 +- .../reconstructions/converter/plan/group.py | 5 + .../converter/plan/protocol.py | 11 +- .../coordinators/tabs/test_main.py | 36 ++++++ .../logic/main/test_converter.py | 116 +++++++++++++++++- .../converter/plan/test_plans.py | 63 ++++++++++ 12 files changed, 292 insertions(+), 6 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9d2bfb904..f140e4ead 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -25,6 +25,12 @@ clicking either path shows it in your file manager. When a run writes one reconstruction, **Load** opens it on the **Reconstructions** tab; a whole folder of them offers **Open** instead. **Cancel** stops a run, and only one runs at a time. +Converting one file or one stems mix writes a reconstruction of a settled name, so +where one of that name already stands the application asks before writing over it — +choose **Convert anyway** to go ahead. Converting a folder needs no such question: +it converts the recordings still to be done and keeps the reconstructions already +made, so a repeated run picks up where the last one stopped. + **Stems mode** turns the card into a list of the recordings mixed into one reconstruction. Tick it and click each recording in the browser, or right-click one and choose **Add as stem** — that starts a stems conversion from a classic one in a diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 7a7d4ca00..11817b2e7 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -87,6 +87,9 @@ class ConverterElements(AbstractElement): DISCARD_STEMS_PROMPT = "discard_stems_prompt" DISCARD_STEMS_BUTTON = "discard_stems_button" KEEP_STEMS_BUTTON = "keep_stems_button" + OVERWRITE_TARGET_DIALOG = "overwrite_target_dialog" + OVERWRITE_TARGET_PROMPT = "overwrite_target_prompt" + OVERWRITE_TARGET_BUTTON = "overwrite_target_button" STEM_SELECTION_DIALOG = "stem_selection_dialog" STEM_SELECTION_PROMPT = "stem_selection_prompt" STEM_SELECTION_LIMIT = "stem_selection_limit" diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 2045a4916..ae78d9229 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -34,6 +34,7 @@ TAG_MAIN_CONVERTER_DIALOG_CANCEL, TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, TAG_MAIN_CONVERTER_DIALOG_LOAD, + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, TAG_MAIN_EXPLORER_PANEL, @@ -253,6 +254,7 @@ def __init__( _msg_no_generators, self._ttl_progress, ) + self._converter_logic.on_target_exists = self._confirm_overwriting_target self._converter_logic.is_library_available = library_manager.is_library_available_for_config self._converter_logic.cancel_library_generation = library_manager.cancel_generation self._converter_logic.on_load_file = on_load_file @@ -335,6 +337,21 @@ def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None: on_cancel=self._converter_logic.refresh_view, ) + def _confirm_overwriting_target(self, target: Path) -> None: + """Asks before a conversion writes over the reconstruction already standing at its target. + + A batch keeps what it finds and converts the rest, so this reaches the reader for a + single conversion — the one run whose output would replace a file already made. + """ + self._dialogs.show_confirmation( + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, + self._language_manager["main.converter.message.overwrite_target_prompt"], + self._language_manager["main.converter.title.overwrite_target_dialog"], + lambda: self._converter_logic.start_conversion(confirmed=True), + ok_label=self._language_manager["main.converter.label.overwrite_target_button"], + path=target, + ) + def _notify_converter_running(self) -> bool: if not self._is_operation_active(): return False diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 688459137..0d91ded2c 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -123,6 +123,7 @@ def __init__( self.on_error: Optional[Callable[[Exception], None]] = None self.on_no_files_to_process: Optional[VoidCallback] = None self.on_no_generators: Optional[VoidCallback] = None + self.on_target_exists: Optional[PathCallback] = None self.on_load_file: Optional[PathCallback] = None self.on_load_directory: Optional[VoidCallback] = None self.on_cancelled: Optional[VoidCallback] = None @@ -265,7 +266,12 @@ def set_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: self._hierarchy_mode = hierarchy_mode self._refresh_setup() - def start_conversion(self) -> None: + def start_conversion(self, confirmed: bool = False) -> None: + """Starts the run the current setup describes, asking first where it would write over work. + + ``confirmed`` states that the reader has already answered for the file standing at the + target, which is what lets the prompt's answer come back and run. + """ if self._is_operation_active(): logger.warning("A conversion or library generation is already in progress") return @@ -274,6 +280,11 @@ def start_conversion(self) -> None: self.call(self.on_no_generators) return + standing_target = self._standing_target() + if standing_target is not None and not confirmed: + self.call(self.on_target_exists, standing_target) + return + self._phase = ConversionPhase.WAITING self._emit_view_model(self._language_manager["main.converter.message.status_waiting"], 0.0) self.call(self.generate_library) @@ -418,6 +429,19 @@ def _start_conversion(self) -> None: self._system_progress.initialize() self._service.start(config, self._conversion_plan(config, self._input_path)) + def _standing_target(self) -> Optional[Path]: + """The reconstruction this run would write over, where one stands. + + A batch converts what is still to be written and keeps the rest, so it puts nothing to + the reader; a single conversion writes one file, and that is the one worth asking about. + """ + if self._input_path is None: + return None + + config = self._config_manager.config + targets = self._conversion_plan(config, self._input_path).existing_targets(config) + return targets[0] if targets else None + def _conversion_plan(self, config: Config, input_path: Path) -> ConversionPlan: """What the request amounts to: one reconstruction from the recordings listed or the file selected, or one per audio file the selected directory holds.""" diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 3431e5065..71650ce53 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -304,6 +304,12 @@ Widget.DIALOG, "discard_stems", ) +TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.DIALOG, + "overwrite_target", +) TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION = TagName( Page.MAIN, Panel.CONVERTER, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 64cb8edb4..105f4a618 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -381,14 +381,17 @@ main.converter.label.convert_stems_button: "Convert stems" main.converter.label.discard_stems_button: "Keep the first" main.converter.label.keep_stems_button: "Stay in stems mode" main.converter.label.add_stems_button: "Add" +main.converter.label.overwrite_target_button: "Convert anyway" main.converter.message.stems_mode_tooltip: "Mix several recordings into one reconstruction, each holding the channels you give it." main.converter.message.channel_cap_tooltip: "How many channels one recording may hold in a single frame." main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round; strict fills a level before the next one picks." main.converter.message.stems_empty_hint: "Click recordings in the browser to gather the sources of one reconstruction." main.converter.message.discard_stems_prompt: "Leaving stems mode keeps the first recording and drops the rest. Continue?" +main.converter.message.overwrite_target_prompt: "A reconstruction of this name already stands here. Converting writes over it." main.converter.message.stem_selection_prompt: "Pick the recordings to add." main.converter.message.status_stems_mode: "Mix several recordings into one reconstruction." main.converter.title.discard_stems_dialog: "Leave stems mode?" +main.converter.title.overwrite_target_dialog: "Write over it?" main.converter.title.stem_selection_dialog: "Add recordings" main.converter.template.stem_selection_limit: "Room for {} more of the {} recordings found." main.converter.label.context_move_up: "Move up" diff --git a/src/sampletones_core/reconstructions/converter/plan/directory.py b/src/sampletones_core/reconstructions/converter/plan/directory.py index eb8383741..c27a0abdf 100644 --- a/src/sampletones_core/reconstructions/converter/plan/directory.py +++ b/src/sampletones_core/reconstructions/converter/plan/directory.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import List +from typing import List, Tuple from sampletones_core.configs import Config from sampletones_core.reconstructions.converter.job import ConversionJob @@ -39,6 +39,10 @@ def jobs(self, config: Config) -> List[ConversionJob]: return [self._job(audio_file, output_path) for audio_file in audio_files] + def existing_targets(self, _config: Config) -> Tuple[Path, ...]: + """The empty tuple: the scan converts what is still to be written and keeps the rest.""" + return () + def _job(self, audio_file: Path, output_path: Path) -> ConversionJob: return ConversionJob( sources=(audio_file,), diff --git a/src/sampletones_core/reconstructions/converter/plan/group.py b/src/sampletones_core/reconstructions/converter/plan/group.py index eb86f0ebb..e04989b2e 100644 --- a/src/sampletones_core/reconstructions/converter/plan/group.py +++ b/src/sampletones_core/reconstructions/converter/plan/group.py @@ -28,5 +28,10 @@ def jobs(self, config: Config) -> List[ConversionJob]: ) ] + def existing_targets(self, config: Config) -> Tuple[Path, ...]: + """The one file this conversion writes, where it already stands.""" + output_path = self._output_path(config) + return (output_path,) if output_path.is_file() else () + def _output_path(self, config: Config) -> Path: return group_output_path(config, self.sources) diff --git a/src/sampletones_core/reconstructions/converter/plan/protocol.py b/src/sampletones_core/reconstructions/converter/plan/protocol.py index e22e20bff..287f4daa3 100644 --- a/src/sampletones_core/reconstructions/converter/plan/protocol.py +++ b/src/sampletones_core/reconstructions/converter/plan/protocol.py @@ -1,4 +1,5 @@ -from typing import List, Protocol +from pathlib import Path +from typing import List, Protocol, Tuple from sampletones_core.configs import Config from sampletones_core.reconstructions.converter.job import ConversionJob @@ -14,3 +15,11 @@ class ConversionPlan(Protocol): """ def jobs(self, config: Config) -> List[ConversionJob]: ... + + def existing_targets(self, config: Config) -> Tuple[Path, ...]: + """The reconstructions already standing where this plan would write. + + A caller asks this ahead of the run, on its own thread, so the answer stays cheap: a + run that would write over work already done is one the reader gets to answer for. A + plan that keeps standing files as it goes answers with none, having settled it already. + """ diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index f0f164a7f..92d020a78 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -10,6 +10,7 @@ from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, TAG_MAIN_CONVERTER_DIALOG_LOAD, + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, ) from tests.suite.language import FakeLanguageManager @@ -335,6 +336,41 @@ def test_a_busy_application_leaves_the_click_alone(self) -> None: assert _stems_coordinator(operation_active=True)._can_add_stems() is False +OVERWRITE_TARGET_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_target_prompt" +OVERWRITE_TARGET_BUTTON_KEY: Final[str] = "main.converter.label.overwrite_target_button" + + +class TestOverwritePrompt: + """A conversion that would replace a reconstruction already made is put to the reader first.""" + + def test_the_prompt_names_the_file_it_would_replace(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator() + target = tmp_path / "song.stn" + + coordinator._confirm_overwriting_target(target) + + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[0] == TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET + assert args[1] == OVERWRITE_TARGET_PROMPT_KEY + assert kwargs["ok_label"] == OVERWRITE_TARGET_BUTTON_KEY + assert kwargs["path"] == target + + def test_confirming_runs_the_conversion_it_asked_about(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator() + + coordinator._confirm_overwriting_target(tmp_path / "song.stn") + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._converter_logic.start_conversion.assert_called_once_with(confirmed=True) + + def test_declining_converts_nothing(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator() + + coordinator._confirm_overwriting_target(tmp_path / "song.stn") + + coordinator._converter_logic.start_conversion.assert_not_called() + + class TestReconstructLeavesStemsMode: """A Reconstruct names what a classic conversion converts, so a gathered list is asked about.""" diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index cc28f0c43..15b698ec4 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -32,10 +32,24 @@ } +def _config_writing_under(reconstructions_directory: Path) -> Config: + """A configuration whose reconstructions are written under ``reconstructions_directory``.""" + config = Config() + general = config.general.model_copy(update={"reconstructions_directory": str(reconstructions_directory)}) + return config.model_copy(update={"general": general}) + + @pytest.fixture -def converter_logic() -> ConverterLogic: +def converter_logic(tmp_path: Path) -> ConverterLogic: + """A converter reading a real configuration, so resolving where a run writes answers as it does live. + + The configuration writes under the test's own directory, which keeps a target this converter + resolves within the test rather than in the reconstructions the developer holds. + """ + reconstructions_directory = tmp_path / "reconstructions" config_manager = MagicMock() - config_manager.get_reconstructions_directory.return_value = Path("/tmp/reconstructions") + config_manager.config = _config_writing_under(reconstructions_directory) + config_manager.get_reconstructions_directory.return_value = reconstructions_directory service = MagicMock() service.is_running.return_value = False scheduling = MagicMock( @@ -117,7 +131,10 @@ def test_no_generators_notifies_and_does_not_start( self, converter_logic: ConverterLogic, ) -> None: - converter_logic._config_manager.config.generation.channels = [] + config = converter_logic._config_manager.config + converter_logic._config_manager.config = config.model_copy( + update={"generation": config.generation.model_copy(update={"channels": []})} + ) on_no_generators = MagicMock() converter_logic.on_no_generators = on_no_generators @@ -128,6 +145,99 @@ def test_no_generators_notifies_and_does_not_start( assert converter_logic._phase == ConversionPhase.IDLE +class TestOverwriteGuard: + """A single conversion writes one named file, so a run that would replace one asks first. + + A batch settles the question itself — it converts what is still to be written — so the + prompt reaches the reader for the single-file and stems runs alone. + """ + + @staticmethod + def _aimed_at(converter_logic: ConverterLogic, path: Path) -> Path: + """Points the converter at ``path`` and answers where its run would write.""" + converter_logic.set_input_path(path) + config = converter_logic._config_manager.config + return GroupConversion(sources=(path,), stems=StemsConfig()).jobs(config)[0].output_path + + @staticmethod + def _standing(target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + target.touch() + + def test_a_standing_target_is_put_to_the_reader_and_nothing_starts( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = tmp_path / "song.wav" + source.touch() + target = self._aimed_at(converter_logic, source) + self._standing(target) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): + converter_logic.start_conversion() + + on_target_exists.assert_called_once_with(target) + converter_logic.generate_library.assert_not_called() + assert converter_logic._phase == ConversionPhase.IDLE + + def test_a_confirmed_run_goes_ahead( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = tmp_path / "song.wav" + source.touch() + self._standing(self._aimed_at(converter_logic, source)) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): + converter_logic.start_conversion(confirmed=True) + + on_target_exists.assert_not_called() + converter_logic.generate_library.assert_called_once() + assert converter_logic._phase == ConversionPhase.WAITING + + def test_a_target_still_to_be_written_starts_straight_away( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = tmp_path / "song.wav" + source.touch() + self._aimed_at(converter_logic, source) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert converter_logic._phase == ConversionPhase.WAITING + + def test_a_batch_starts_without_asking( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """The scan keeps every reconstruction already written, so a standing file stops nothing.""" + sources = tmp_path / "sources" + sources.mkdir() + (sources / "song.wav").touch() + converter_logic.set_input_path(sources) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert converter_logic._phase == ConversionPhase.WAITING + + class TestActivePhases: """``is_active`` reports a conversion occupying resources for every non-idle, non-terminal phase — covering the WAITING preparation that runs before the service starts.""" diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index 907ea5162..57fe42d78 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -127,6 +127,69 @@ def test_a_directory_holding_nothing_to_convert_raises( DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) +def _config_writing_under(reconstructions_directory: Path) -> Config: + """A configuration whose reconstructions are written under ``reconstructions_directory``.""" + config = Config() + general = config.general.model_copy(update={"reconstructions_directory": str(reconstructions_directory)}) + return config.model_copy(update={"general": general}) + + +class TestExistingTargets: + """What a plan would write over, which is what a caller settles before starting a run.""" + + def test_a_group_conversion_names_the_target_already_standing( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + plan = GroupConversion(sources=(source,), stems=stems) + target = plan.jobs(config)[0].output_path + target.parent.mkdir(parents=True) + target.touch() + + assert plan.existing_targets(config) == (target,) + + def test_a_target_still_to_be_written_leaves_the_answer_empty( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + + assert GroupConversion(sources=(source,), stems=stems).existing_targets(config) == () + + def test_a_directory_sitting_at_the_target_path_leaves_the_answer_empty( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + """A target is a file, so a directory of that name is a different matter the run reports itself.""" + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + plan = GroupConversion(sources=(source,), stems=stems) + plan.jobs(config)[0].output_path.mkdir(parents=True) + + assert plan.existing_targets(config) == () + + def test_a_directory_conversion_settles_the_question_itself( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + """The scan converts what is still to be written, so a standing output puts nothing to the reader.""" + config = _config_writing_under(tmp_path / "out") + _write_audio_files(tmp_path, ["a.wav"]) + plan = DirectoryConversion(directory=tmp_path, stems=stems) + target = plan.jobs(config)[0].output_path + target.parent.mkdir(parents=True) + target.touch() + + assert plan.existing_targets(config) == () + + class TestGroupOutputPath: def test_one_source_names_the_file_after_itself(self, config: Config, tmp_path: Path) -> None: source = tmp_path / "song.wav" From 26cfcc0b3e771cb681c56144a7800398ed3f1cf4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 21:56:39 +0200 Subject: [PATCH 064/142] Fixed: the frozen application behind a dialog raised between frames --- docs/development/architecture.md | 2 + .../ui/elements/graphs/graph.py | 11 ++++- .../ui/elements/window.py | 20 ++++----- .../ui/elements/graphs/test_graph.py | 45 +++++++++++++++++++ .../ui/elements/test_window.py | 39 ++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/graphs/test_graph.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 126fcc46b..5c6a58d5d 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -69,6 +69,8 @@ This decouples widget construction (which happens during `create_panel()`) from Services execute long-running work on background threads. Their results are posted to `CallbackQueue` with a priority, and the main-thread render loop drains the due results each frame within a per-frame time budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across frames while rendering continues. Draining on the render thread keeps every callback's DPG work on the thread that owns the context. This is the only mechanism for crossing the thread boundary; applying a background result to UI state directly from the worker thread is forbidden. +**The drain runs between frames, so a callback waits for none.** The render thread is inside the drain rather than inside a frame, which makes the next frame the drain's own to reach: `dpg.split_frame` there waits for what the wait itself prevents, and the application stops for good. Work that needs a drawn frame — reading a laid-out size, letting a configuration take effect — is scheduled through `FrameCallbackManager` and picked up when that frame arrives. + ### 7. Construction flows from the composition root `Application.__init__` constructs the application graph — managers, controllers, shared services, coordinators, the shell — and wires their callbacks. A tab coordinator in turn constructs the panels, logic objects, and tab-scoped services it owns. Beyond these two sites, no component constructs another major component: every dependency arrives as a constructor argument, and none is obtained through a global lookup. diff --git a/src/sampletones_application/ui/elements/graphs/graph.py b/src/sampletones_application/ui/elements/graphs/graph.py index 19e9c21ce..94e628363 100644 --- a/src/sampletones_application/ui/elements/graphs/graph.py +++ b/src/sampletones_application/ui/elements/graphs/graph.py @@ -17,6 +17,7 @@ from sampletones_application.ui.elements.graphs.layers.type import LayerT from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.types.application import Sender @@ -145,7 +146,15 @@ def _update_axes_limits(self) -> None: dpg.set_axis_limits_constraints(self.y_axis_tag, *self.y_range) def _release_axes_limits(self) -> None: - dpg.split_frame() + """Hands the axes back to the data once the frame carrying the new locks has been drawn. + + A lock reaches an axis when the frame stating it renders, so the release follows a frame + behind it. It is scheduled rather than waited for, which leaves the render thread free to + draw that frame. + """ + FrameCallbackManager.set_frame_callback(self._set_axes_auto) + + def _set_axes_auto(self) -> None: dpg.set_axis_limits_auto(self.x_axis_tag) dpg.set_axis_limits_auto(self.y_axis_tag) diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index 7016fcb53..4c7a1d50f 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -7,7 +7,7 @@ from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG_WINDOW from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.align import center_item, center_when_settled +from sampletones_application.utils.gui.align import center_when_settled from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.types.callback import VoidCallback @@ -34,9 +34,6 @@ class GUIWindow(GUIPanel, ABC): _fits_content: bool = False - def center(self) -> None: - center_item(self.tag) - def yield_to(self, raise_modal: VoidCallback) -> None: """Steps off screen and runs ``raise_modal`` a frame later, so what it raises can open. @@ -95,16 +92,19 @@ def dialog_window( yield def show(self, *args: Any, **kwargs: Any) -> None: + """Builds this appearance's tree and centres it once the layout has measured it. + + A window's size is known to DearPyGui only after a frame has drawn it, so the centre + waits for that frame to arrive on its own. Waiting for it in place would hold the render + thread, and a window is raised from wherever a result reaches the screen — including the + callback drain that runs between frames, where the frame being waited for is the one this + call is standing in the way of. + """ self.hide() self.prepare(*args, **kwargs) self.create_window() ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG_WINDOW).bind_to_item(self.tag) - if self._fits_content: - center_when_settled(self.tag) - return - - dpg.split_frame() - self.center() + center_when_settled(self.tag) def hide(self) -> None: self._teardown() diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py new file mode 100644 index 000000000..2ffdc02d3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py @@ -0,0 +1,45 @@ +from typing import Final +from unittest.mock import patch + +from sampletones_application.ui.elements.graphs import graph as graph_module +from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph + +MODULE: Final[str] = "sampletones_application.ui.elements.graphs.graph" +X_AXIS: Final[str] = "graph.x" +Y_AXIS: Final[str] = "graph.y" + + +def _graph() -> GUIWaveformGraph: + graph = GUIWaveformGraph.__new__(GUIWaveformGraph) + graph.x_axis_tag = X_AXIS + graph.y_axis_tag = Y_AXIS + return graph + + +class TestReleasingTheAxes: + """The release follows the frame that states the locks, and waits on no frame to do it.""" + + def test_the_release_waits_on_no_frame(self) -> None: + graph = _graph() + + with ( + patch(f"{MODULE}.FrameCallbackManager"), + patch.object(graph_module.dpg, "split_frame") as split_frame, + ): + graph._release_axes_limits() + + split_frame.assert_not_called() + + def test_the_release_is_scheduled_for_the_following_frame(self) -> None: + graph = _graph() + + with ( + patch(f"{MODULE}.FrameCallbackManager") as frame, + patch.object(graph_module.dpg, "set_axis_limits_auto") as set_auto, + ): + graph._release_axes_limits() + set_auto.assert_not_called() + frame.set_frame_callback.assert_called_once_with(graph._set_axes_auto) + frame.set_frame_callback.call_args.args[0]() + + assert [call.args[0] for call in set_auto.call_args_list] == [X_AXIS, Y_AXIS] diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py index 127570864..8cbd7c507 100644 --- a/tests/unit/sampletones_application/ui/elements/test_window.py +++ b/tests/unit/sampletones_application/ui/elements/test_window.py @@ -116,3 +116,42 @@ def test_the_widget_tree_survives_the_hand_off(self, dpg_context: None) -> None: window.yield_to(MagicMock()) assert dpg.get_item_children(TAG, 1) + + +class TestRaisingAWindow: + """A window is raised from wherever a result reaches the screen, the callback drain between + frames included, so opening one waits on no frame.""" + + def test_opening_waits_on_no_frame(self, dpg_context: None) -> None: + window = ProbeWindow(on_close=None) + + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "split_frame") as split_frame, + ): + window.show() + + split_frame.assert_not_called() + + def test_opening_centres_the_window_once_it_has_been_measured(self, dpg_context: None) -> None: + window = ProbeWindow(on_close=None) + + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled") as center_when_settled, + ): + window.show() + + center_when_settled.assert_called_once_with(TAG) + + def test_opening_builds_the_tree(self, dpg_context: None) -> None: + window = ProbeWindow(on_close=None) + + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + ): + window.show() + + assert dpg.get_item_children(TAG, 1) From 5fe407631649338e2280a1d5bc62c65ebcbd0b9c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 21:57:29 +0200 Subject: [PATCH 065/142] Simplified: the interface guide and removed stale documentation links --- docs/concepts/stems.md | 9 +- docs/guide/interface.md | 315 +++++++++++++++++++++------------------- docs/index.md | 1 - 3 files changed, 171 insertions(+), 154 deletions(-) diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index 5d67637f5..783bc7d4b 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -174,11 +174,10 @@ directory fall back to the `.stn` filename. The reconstruction tab names every recorded path on the Stems card, one row per stem, each row carrying its own full-path tooltip and revealing its recording on a click. The Audio source panel keeps the reconstruction's own file and the -choice between the two waveforms. Locating reveals every recorded path according -to the capability matrix in -[Desktop capabilities](../development/desktop-capabilities.md): one file-manager -window with every stem selected where the file manager supports it, one window -per directory otherwise. +choice between the two waveforms. Locating reveals every recorded path at once: +a Linux file manager offering `org.freedesktop.FileManager1` opens one window +with every stem selected, and every other environment opens one window per +directory holding them. ## The stems card diff --git a/docs/guide/interface.md b/docs/guide/interface.md index f140e4ead..529eda0a2 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -1,195 +1,214 @@ # The interface -_SampleToNES_ is a single window: a menu bar at the top, four tabs, and a status -bar along the bottom. Within a tab, work generally flows left to right — you pick -something on the left, act on it in the centre, and inspect or refine it on the -right. +_SampleToNES_ is one window: a menu bar at the top, four tabs, and a status bar +at the bottom. Each tab works left to right — pick something on the left, set it +up in the centre, refine it on the right. -This page covers the **Main**, **Instructions**, and **Reconstructions** tabs and -the menus around them. The **Sequencer** has [its own page](sequencer.md). +This page covers the **Main**, **Instructions**, and **Reconstructions** tabs +and the menus around them. The **Sequencer** has [its own page](sequencer.md). ## Main The **Main** tab turns an audio file into a -[reconstruction](../concepts/reconstruction.md), and it is where most sessions -begin. +[reconstruction](../concepts/reconstruction.md). Most sessions start here. Pick an audio file — or a whole folder — in the **Filesystem** browser on the -left, set up how the reconstruction is done in the centre, and click **Convert -sample** (or **Convert directory** for a folder). The browser opens the folders you -were last working in, and **Collapse all** folds them away again. The -[instruction library](../concepts/instruction-library.md) for your settings is -built automatically the first time it is needed, so you can convert straight away. -While it runs, the panel names the file going in and where the result is going, and -clicking either path shows it in your file manager. When a run writes one -reconstruction, **Load** opens it on the **Reconstructions** tab; a whole folder of -them offers **Open** instead. **Cancel** stops a run, and only one runs at a time. - -Converting one file or one stems mix writes a reconstruction of a settled name, so -where one of that name already stands the application asks before writing over it — -choose **Convert anyway** to go ahead. Converting a folder needs no such question: -it converts the recordings still to be done and keeps the reconstructions already -made, so a repeated run picks up where the last one stopped. - -**Stems mode** turns the card into a list of the recordings mixed into one -reconstruction. Tick it and click each recording in the browser, or right-click one -and choose **Add as stem** — that starts a stems conversion from a classic one in a -single step. Ctrl-clicking a recording does the same, and Ctrl-clicking a folder -offers everything in it, as **Add folder as stems** does; where a folder holds more -recordings than the list has room for, you pick which ones. - -Each row names its recording and carries a checkbox per channel that recording may -use. Untick them all and the row greys out: that recording takes no part in the -conversion, and its row stays listed so you can bring it back. - -The rows sit under **level** bands, and a level is a turn to choose: every recording -on level 1 picks its channels before any on level 2, so a lead can take what it needs -before a pad does. Drag a row onto another row to share that row's level, or onto -the gap between two levels to give it a level of its own. -Right-clicking a row names the same moves in words, alongside the recording's own -actions — its name or path to the clipboard, and the file shown in your file -manager. **Order** decides how the levels take turns — round by round, or one level -filled before the next picks — and **x** takes a row out. Untick **Stems mode** and -the first recording stays as your single selection. - -**Channels per source** caps how many channels one recording may hold in a single -frame, and it applies to every conversion — one file, a whole folder, or a stems -mix. Leaving it at one channel per source gives each recording a single voice. - -Reconstructing a file or a folder from the browser converts that one thing, so while -you are gathering stems it asks before dropping the list. +left, set up the conversion in the centre, and click **Convert sample** (or +**Convert directory** for a folder). The browser reopens the folders you were +last working in, and **Collapse all** folds them away again. The [instruction +library](../concepts/instruction-library.md) your settings need is built the +first time you convert, so you can start straight away. + +While a run goes on, the panel shows the file going in and the file coming out; +click either path to open it in your file manager. Afterwards, **Load** opens +the new reconstruction on the **Reconstructions** tab — after a folder run the +button reads **Open** instead. **Cancel** stops a run, and only one runs at a +time. + +Converting one file or one stems mix always writes to the same filename. If a +reconstruction of that name is already there, the app asks first; click +**Convert anyway** to replace it. Converting a folder starts straight away: it +converts the recordings that still need a reconstruction and leaves the ones +already made, so you can rerun it to carry on where you stopped. + +### Stems mode + +**Stems mode** turns the card into a list of recordings to mix into one +reconstruction. Tick it, then click each recording in the browser. You can also +start from a classic conversion in one step: right-click a recording and choose +**Add as stem**, or Ctrl-click it. Ctrl-clicking a folder offers everything +inside it, as **Add folder as stems** does; if the folder holds more recordings +than the list has room for, you pick which ones. + +Each row shows one recording and a checkbox per channel it may use. Untick them +all and the row greys out: that recording sits out of the conversion, and stays +in the list so you can bring it back. + +Rows sit in **level** bands. A level is a turn to choose: every recording on +level 1 picks its channels before any on level 2, so a lead can take what it +needs before a pad does. Drag a row onto another row to join that row's level, +or into the gap between two levels to give it a level of its own. Right-clicking a +row lists the same moves as menu items, alongside the recording's own actions — +copy its name or path, or show the file in your file manager. + +**Order** sets how the levels take turns: round by round, or one level filled +before the next picks. **x** takes a row out. Untick **Stems mode** and the +first recording stays as your single selection. + +**Channels per source** caps how many channels one recording may hold in a +single frame, and it applies to every conversion — one file, a whole folder, or +a stems mix. Set to 1, each recording gets a single voice. + +Reconstructing a file or a folder from the browser converts that one thing, so +while you are gathering stems it asks before dropping the list. + +### Settings A few settings are worth knowing before you convert. Under **Reconstructor settings**, the **Channels** toggles choose which channels take part — at least -one must be on — and **Drive** sets how hard they are pushed. **General settings** -holds the analysis options: sample rate, NES frequency, generation method, and -feature scaling. The rest, including the worker count and the output and library -folders, sit under **Advanced settings**, which **View ▸ Show advanced settings** -reveals. [Configuration](configuration.md) explains each one. +one must be on — and **Drive** sets how hard they are pushed. **General +settings** holds the analysis options: sample rate, NES frequency, generation +method, and feature scaling. The rest, including the worker count and the output +and library folders, sit under **Advanced settings**, which **View ▸ Show +advanced settings** reveals. [Configuration](configuration.md) explains each +one. ## Reconstructions -The **Reconstructions** tab is where you audition a reconstruction against the +The **Reconstructions** tab is where you compare a reconstruction with the original, fine-tune it, and export it. -Open a saved reconstruction from the **Browser** on the left, which offers the -same files two ways: **By configuration** groups them by the settings they were -made with, and **By sample** gathers every version of one source audio together. -If the current reconstruction has unsaved edits, you are asked whether to save it -first. You can play it back and -switch **Play audio source:** between **Reconstruction** and **Original audio** to -compare the two, and **Locate original audio** re-links the source files if they -have moved. +Open a saved reconstruction from the **Browser** on the left. It shows the same +files two ways: **By configuration** groups them by the settings they were made +with, and **By sample** gathers every version of one source audio together. If +the reconstruction you have open has unsaved edits, you are asked whether to +save it first. Play it back and switch **Play audio source:** between +**Reconstruction** and **Original audio** to compare the two. **Locate original +audio** re-links the source files if they have moved. + +### Finding your way around the browser To keep the reconstructions you return to within reach, right-click one — or a -whole folder — and choose **Mark as favorite**, which highlights it in both views. -Tick **Favorites only** under the search box to narrow the browser to your -favorites and everything inside them. The browser keeps the folders you had open -while it narrows, so switching the tick on and off leaves the tree as you left it. -If you would rather it opened its way down to each favorite for you, turn that on -under **View ▸ Auto-expand favorites**, which answers for reconstructions and for -folders separately. It opens the way down each time you tick **Favorites only**, -and unticking folds those rows back. - -**Collapse all**, beside the refresh button, folds the whole tree away in one -click. Whatever you leave open is remembered, so the tree comes back the way you -left it the next time you start the application. - -A reconstruction mixed from several recordings carries a **Stems** card listing -each of them under the level it was picked on, the way the converter's list showed -them while you were gathering. Every row offers a coloured box on each channel the -recording actually took, and the box at the front of the row moves all of them at -once. Untick one and those frames fall silent everywhere — in the waveform, in -playback, in the original audio, and in a WAV export — so you can hear what each -recording contributed, channel by channel. A channel you have switched off under -the waveform shows its column greyed while your ticks stay where you put them. +whole folder — and choose **Mark as favorite**, which highlights it in both +views. Tick **Favorites only** under the search box to narrow the browser to +your favorites and everything inside them. + +Narrowing keeps whatever folders you had open, so ticking the box on and off +leaves the tree as you left it. To have the browser open its way down to each +favorite instead, turn on **View ▸ Auto-expand favorites**, which you can set +for reconstructions and for folders separately. It expands each time you tick +**Favorites only**, and unticking folds those rows back. + +**Collapse all**, beside the refresh button, folds the whole tree in one click. +Whatever you leave open is remembered for the next time you start the app. + +### The Stems card + +A reconstruction mixed from several recordings has a **Stems** card. It lists +each recording under the level it was picked on — the same list the converter +showed you while you were gathering. + +Each row has a coloured box for every channel that recording actually took, and +a box at the front that moves all of them at once. Untick one and those frames +go silent everywhere: in the waveform, in playback, in the original audio, and +in a WAV export. That is how you hear what each recording contributed, channel +by channel. A channel you have switched off under the waveform shows its column +greyed, and your ticks stay where you put them. + Click a row to show its recording in your file browser, and tick **Collapse -levels** to read the whole list as one table. The ticks are yours for the session; -saving records the assignment, never the selection. +levels** to read the whole list as one table. These ticks last for the session: +saving records which recording owns which frame, not what you were listening to. + +**x** at the end of a row removes the recording from the reconstruction for +good, so the app asks first. Its frames go silent and its row disappears, and +the rest play as they did. One recording always stays, so the last row's **x** +is greyed out. -**x** at the end of a row is a different matter: it takes the recording out of -the reconstruction for good, so the application asks first. The frames it held -fall silent and its row goes, leaving the rest playing as they did. One recording -always stays, so the last row keeps its **x** greyed out. +### Exporting To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase -presets...** writes the same as `.json`, **NSF program...** writes a single `.nsf` -that plays the whole reconstruction on a NES, and **Export to WAV...** renders the -audio. To use the reconstruction in a song, right-click it and choose **Add to -Sequencer** (see the [sequencer guide](sequencer.md)). +presets...** writes the same as `.json`, **NSF program...** writes a single +`.nsf` that plays the whole reconstruction on a NES, and **Export to WAV...** +renders the audio. To use the reconstruction in a song, right-click it and +choose **Add to Sequencer** (see the [sequencer guide](sequencer.md)). + +### Editing instruments For finer control, the **Instruments** panel on the right shows each channel's -instrument — its pitch, volume, arpeggio, and duty sequences — which you can edit -by dragging the bars or typing values. Clearing a sequence hands that dimension to -the channel, so an instrument with no volume sequence plays at whatever level its -channel carries. Beside each channel is the room its instrument takes on the NES, -with the whole sample's above them, so you can see what an edit costs. The figures -are in bytes, and they count what a FamiTracker export saves, so clearing a -sequence brings them down. **Export instrument...** writes the channel on show, for -whichever tracker the save dialog's file type names — see [where your files +instrument — its pitch, volume, arpeggio, and duty sequences — which you can +edit by dragging the bars or typing values. Clearing a sequence hands that +dimension back to the channel, so an instrument with its volume sequence cleared +plays at whatever volume the channel is set to. + +Beside each channel is the room its instrument takes on the NES, with the whole +sample's above them, so you can see what an edit costs. The figures are in bytes +and count what a FamiTracker export saves, so clearing a sequence brings them +down. **Export instrument...** writes the channel you are looking at, in +whichever tracker format you pick in the save dialog — see [where your files live](files.md#exported-files). ## Instructions -The **Instructions** tab generates and browses the -[instruction library](../concepts/instruction-library.md) for your current -settings, and lets you inspect individual instructions. +The **Instructions** tab builds and browses the [instruction +library](../concepts/instruction-library.md) for your current settings, and lets +you inspect single instructions. + +You will rarely come here just to build a library — converting on the **Main** +or **Reconstructions** tab builds the matching one for you. It is useful for +building one ahead of time, or for exploring what a configuration can produce: +pick an instruction and its waveform and spectrum appear with a player, so you +can hear a single NES tone on its own. -You will rarely come here just to build a library — reconstructing on the **Main** -or **Reconstructions** tab builds the matching one automatically. It earns its -place for building one ahead of time, or for exploring what a configuration can -produce: pick an instruction and its waveform and spectrum appear with a player, -so you can hear a single NES tone on its own. Click **Generate library** to build -the library for the current settings (if one already exists, _SampleToNES_ asks -**Regenerate library?**), **Cancel generation** to stop, and **Refresh -instructions data** to re-read the catalogue; selecting an entry in the -**Libraries** tree loads it. +**Generate library** builds the library for the current settings; if one already +exists, _SampleToNES_ asks **Regenerate library?** first. **Cancel generation** +stops it, **Refresh instructions data** re-reads the catalogue, and selecting an +entry in the **Libraries** tree loads it. ## Around the app The menu bar and status bar sit outside the tabs. -Each menu covers one kind of work: **File** for projects, **Edit** for undo, redo, -and what you can do where your cursor stands, **Reconstruction** for the current +Each menu covers one kind of work: **File** for projects, **Edit** for undo, +redo, and whatever your cursor is on, **Reconstruction** for the current reconstruction and its exports, **Playback** for playing and for muting the sequencer's channels, **View** for settings and the window, and **Help** for -**About**. What **Edit** offers below undo and redo follows your cursor: the block -actions of the sequencer grid you are in, or the actions of the sample you have -picked in the **Samples** list. - -Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** -for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for -the sequencer's whole song, as a WAV or an MP3 — -[rendering to audio](sequencer.md#rendering-to-audio) covers the options it offers. - -Two other items are easy to miss. **View ▸ Show advanced settings** reveals the extra -options on the **Main** tab. **Playback ▸ Audio settings...** picks the playback -device, sample rate, and buffer size; these change what you hear, while the -**Sample rate** and **NES frequency** on the **Main** tab change how audio is -reconstructed. - -`F1` to `F4` bring up the four tabs in order — **Main**, **Reconstruction**, -**Sequencer**, and **Instructions** — and work while you are typing, so any tab is -one key away. - -`1` to `4` toggle the four NES channels on the tab in front of you: the -the channels on **Main**, the channels drawn on **Reconstructions**, and the song's -mix on the **Sequencer**. In the sequencer's grids the digits type values into the -cell you are on, so use the channel names or the **Playback ▸ Channels** menu to -mute there. +**About**. What **Edit** offers below undo and redo follows your cursor: the +block actions of the sequencer grid you are in, or the actions of the sample you +have picked in the **Samples** list. + +Two items write audio you can play anywhere: **Reconstruction ▸ Export to +WAV...** for the reconstruction you have open, and **File ▸ Render song...** +(`Ctrl+Shift+E`) for the sequencer's whole song, as a WAV or an MP3 — [rendering +to audio](sequencer.md#rendering-to-audio) covers its options. + +Two other items are easy to miss. **View ▸ Show advanced settings** reveals the +extra options on the **Main** tab. **Playback ▸ Audio settings...** picks the +playback device, sample rate, and buffer size; these change what you hear, while +the **Sample rate** and **NES frequency** on the **Main** tab change how audio +is reconstructed. + +`F1` to `F4` switch tabs in order — **Main**, **Reconstructions**, +**Sequencer**, and **Instructions**. They work while you are typing, so any tab +is one key away. + +`1` to `4` toggle the four NES channels on the tab in front of you: the channels +that take part on **Main**, the channels drawn on **Reconstructions**, and the +song's mix anywhere else. In the sequencer's grids the digits type values into +the cell you are on, so mute there with the channel names or the **Playback ▸ +Channels** menu. ### Keyboard shortcuts **View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists everything you can do from the keyboard and lets you change any of it. Click an action's shortcut and press the -keys you want, or type them into the box below the list. If another action already -uses those keys, the app names it and asks whether to hand them over. **Reset to -defaults** puts everything back, and your changes take effect when you press -**OK**. +keys you want, or type them into the box below the list. If another action +already uses those keys, the app tells you which one and asks whether to hand +them over. **Reset to defaults** puts everything back, and your changes take +effect when you press **OK**. On macOS the shortcuts use Command where other platforms use Control. What you change is saved with your settings and is there the next time you start. -Project properties belong to a project and are covered in the -[sequencer guide](sequencer.md). +Project properties belong to a project and are covered in the [sequencer +guide](sequencer.md). diff --git a/docs/index.md b/docs/index.md index 57e145fd1..5b24118a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,7 +65,6 @@ The [**development**](development/) section is for contributors. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. - [Bugs and to-dos](development/bugs-and-todos.md) — the working ledger of known gaps. -- [Bitphase integration status](development/bitphase-integration-status.md) — what the Bitphase export covers and what is left to verify. ## Glossary From 7cd1a6f9ac5e3b10fdbaaa4886f8cf4f8294f141 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 22 Aug 2026 23:58:35 +0200 Subject: [PATCH 066/142] Taught: the console to play compressed channel planes. --- docs/development/packages.md | 2 +- src/sampletones_config/boundaries/graphs.yaml | 2 +- src/sampletones_player/compression/admit.py | 159 +++++++++ .../compression/compressed.py | 18 + src/sampletones_player/compression/encode.py | 59 +++- src/sampletones_player/compression/entries.py | 31 ++ .../compression/matches/cache.py | 7 +- .../compression/matches/shift.py | 19 + .../compression/planes/order.py | 7 +- .../compression/tokens/span.py | 61 ++++ .../driver/assembly/include/song.inc | 48 ++- .../driver/assembly/source/channels.s | 324 ++++++++++++++---- .../driver/assembly/source/clock.s | 73 ++-- .../driver/assembly/source/driver.s | 29 +- .../driver/binary/driver.bin | Bin 339 -> 584 bytes src/sampletones_player/nsf/layout.py | 109 ++++++ src/sampletones_player/nsf/song.py | 104 +++--- .../specification/compression.py | 16 +- src/sampletones_player/specification/song.py | 11 +- src/sampletones_shared/constants/general.py | 1 + tests/integration/nsf/console/session.py | 62 +++- tests/integration/nsf/test_driver_trace.py | 68 ++++ tests/integration/nsf/test_nsf_pipeline.py | 7 +- tests/integration/nsf/test_song_export.py | 34 +- .../services/test_export.py | 13 +- tests/suite/player.py | 63 +++- .../compression/test_admit.py | 125 +++++++ .../compression/test_entries.py | 42 +++ .../compression/tokens/test_span.py | 128 +++++++ .../driver/test_song_include.py | 147 ++++++++ .../unit/sampletones_player/nsf/test_file.py | 87 ++++- .../sampletones_player/nsf/test_layout.py | 107 ++++++ .../unit/sampletones_player/nsf/test_song.py | 171 +++++++-- tests/unit/sampletones_player/test_export.py | 43 ++- 34 files changed, 1887 insertions(+), 290 deletions(-) create mode 100644 src/sampletones_player/compression/admit.py create mode 100644 src/sampletones_player/compression/entries.py create mode 100644 src/sampletones_player/compression/tokens/span.py create mode 100644 src/sampletones_player/nsf/layout.py create mode 100644 tests/unit/sampletones_player/compression/test_admit.py create mode 100644 tests/unit/sampletones_player/compression/test_entries.py create mode 100644 tests/unit/sampletones_player/compression/tokens/test_span.py create mode 100644 tests/unit/sampletones_player/driver/test_song_include.py create mode 100644 tests/unit/sampletones_player/nsf/test_layout.py diff --git a/docs/development/packages.md b/docs/development/packages.md index 81934dd5e..767e8f97a 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -88,7 +88,7 @@ them. | `song.py` | `Song` — the compressed planes, the timer table, the schedule and the loop point as one value | `clock/`, `registers/`, `compression/` | | `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded, its planes compressed and its rate scheduled | `song.py`, `registers/`, `clock/`, `compression/` | | `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` | -| `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `registers/`, `specification/`, `driver/` | +| `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `specification/`, `compression/`, `driver/` | | `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` | | `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` | | `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries and saying which stage a run is in | `builder.py`, `nsf/`, `driver/`, `compression/` | diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml index 46eb3c3a9..eda8d30e2 100644 --- a/src/sampletones_config/boundaries/graphs.yaml +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -21,7 +21,7 @@ player: song.py: [clock, registers, compression] builder.py: [song.py, registers, clock, compression] trace: [song.py, specification] - nsf: [song.py, registers, specification, driver] + nsf: [song.py, specification, compression, driver] export.py: [builder.py, nsf, driver, compression] driver: [specification] driver/assembler: [driver, specification] diff --git a/src/sampletones_player/compression/admit.py b/src/sampletones_player/compression/admit.py new file mode 100644 index 000000000..9cca4fd97 --- /dev/null +++ b/src/sampletones_player/compression/admit.py @@ -0,0 +1,159 @@ +from typing import Final, List, NamedTuple, Sequence, Set, Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase, phrase_entry_size +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.matches.cache import MIN_PHRASE_TICKS, MatchCache +from sampletones_player.compression.matches.shift import NO_SHIFT, asked_shift +from sampletones_player.compression.options import CodecOptions +from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.tokens.sizes import phrase_size +from sampletones_player.specification.compression import MAX_PHRASE_IDS, PHRASE_ID_ESCAPE +from sampletones_shared.logger import logger + +CROWDED_PHRASE_ID: Final[int] = PHRASE_ID_ESCAPE +UNSHIFTED_TOKEN_TRANSPOSE: Final[int] = 0 +SHIFTED_TOKEN_TRANSPOSE: Final[int] = 1 + + +class _Weighed(NamedTuple): + payment: int + order: int + phrase: Phrase + + +class _Plays(NamedTuple): + """What one plane pays a phrase, and how many tokens name it there.""" + + paid: int + tokens: int + + +def _distinct(seeds: Sequence[Phrase]) -> Tuple[Phrase, ...]: + kept: List[Phrase] = [] + seen: Set[bytes] = set() + for seed in seeds: + if seed.body not in seen: + seen.add(seed.body) + kept.append(seed) + + return tuple(kept) + + +def _plays( + cache: MatchCache, + plane: int, + phrase: Phrase, + baseline: Parse, + *, + transposition: bool, +) -> _Plays: + """What one plane pays a phrase, and how many tokens it names it in.""" + index = cache.index(plane) + ticks = cache.reading(plane, phrase).ticks + costs = baseline.costs + origin = phrase.body[0] + paid = 0 + played = 0 + position = 0 + while position < index.ticks: + reach = ticks[position] + shifted = asked_shift(index.plane[position], origin) != NO_SHIFT + if reach >= MIN_PHRASE_TICKS and (transposition or not shifted): + paid += costs[position + reach] - costs[position] + played += 1 + position += reach + else: + position += 1 + + return _Plays(paid=paid, tokens=played) + + +def _payment( + cache: MatchCache, + phrase: Phrase, + baseline: Sequence[Parse], + options: CodecOptions, +) -> int: + """The bytes a phrase spares the song, its own entry and the tokens naming it taken off. + + A table crowded past its ids names most of its phrases through the escape byte, so every + seed is weighed at what one of those tokens costs, which is the price they all compete at. + """ + paid = 0 + played = 0 + for plane in range(len(cache.indices)): + plays = _plays( + cache, + plane, + phrase, + baseline[plane], + transposition=options.transposition, + ) + paid += plays.paid + played += plays.tokens + + if played == 0: + return 0 + + stated = phrase_size(CROWDED_PHRASE_ID, UNSHIFTED_TOKEN_TRANSPOSE) + shifted = phrase_size(CROWDED_PHRASE_ID, SHIFTED_TOKEN_TRANSPOSE) + spent = stated + shifted * (played - 1) + phrase_entry_size(phrase.length) + return paid - spent + + +def _best_paying( + cache: MatchCache, + seeds: Sequence[Phrase], + baseline: Sequence[Parse], + options: CodecOptions, +) -> Tuple[Phrase, ...]: + weighed = [ + _Weighed( + payment=_payment(cache, seed, baseline, options), + order=order, + phrase=seed, + ) + for order, seed in enumerate(seeds) + ] + weighed.sort(key=lambda entry: (-entry.payment, entry.order)) + return tuple(entry.phrase for entry in weighed[:MAX_PHRASE_IDS]) + + +def admit_seeds( + cache: MatchCache, + seeds: Sequence[Phrase], + baseline: Sequence[Parse], + options: CodecOptions, +) -> PhraseTable: + """Seeds the dictionary with the phrases a project's instruments offer. + + A token names one of a fixed number of phrases, and a project of many samples offers more + shapes than that. The ones kept are the ones sparing the streams most, measured against a + reading of the song that names no phrase at all, so a slot goes to the shape a song leans on + rather than to whichever instrument the table happens to list first. + + Args: + cache: The planes the song covers, alongside what each phrase plays against them. + seeds: The phrases the song's instruments offer, in instrument-table order. + baseline: The reading each plane takes when its tokens name no phrase. + options: Which of the codec's layers the encoding is built from. + + Returns: + PhraseTable: The seeds the dictionary takes. + """ + offered = _distinct(seeds) + if len(offered) <= MAX_PHRASE_IDS: + return phrase_table(offered) + + logger.warning( + f"the song's instruments offer {len(offered)} phrases and a dictionary holds " + f"{MAX_PHRASE_IDS}, so the {MAX_PHRASE_IDS} sparing the streams most are kept" + ) + return phrase_table( + _best_paying( + cache, + offered, + baseline, + options, + ) + ) diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py index d4302cea0..796cc84fb 100644 --- a/src/sampletones_player/compression/compressed.py +++ b/src/sampletones_player/compression/compressed.py @@ -1,8 +1,11 @@ from __future__ import annotations +from typing import Tuple + from pydantic import BaseModel, ConfigDict, model_validator from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.entries import stream_entry from sampletones_player.compression.planes.order import PlaneOrder @@ -32,3 +35,18 @@ def _validate_the_song_lasts(self) -> CompressedPlanes: def size(self) -> int: """The bytes the dictionary and the eight streams take together.""" return self.phrases.size + sum(len(stream) for stream in self.streams) + + def entries(self, tick: int) -> Tuple[int, ...]: + """The byte each stream is re-entered at, for a song returning to ``tick``. + + Args: + tick: The tick the song returns to. + + Returns: + Tuple[int, ...]: One byte offset per plane, each counted from its own stream's start, + in the order the song block writes them. + + Raises: + ValueError: If a stream spans ``tick`` rather than starting a token there. + """ + return tuple(stream_entry(stream, tick) for stream in self.streams) diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index a6749fc27..1078bebd3 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -1,6 +1,7 @@ from dataclasses import replace from typing import Dict, Final, FrozenSet, Iterable, Sequence, Tuple +from sampletones_player.compression.admit import admit_seeds from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.dictionary.prune import prune @@ -13,7 +14,10 @@ from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.progress.monitor import CodecMonitor -from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter +from sampletones_player.compression.progress.report import ( + SILENT_REPORTER, + CodecReporter, +) from sampletones_player.compression.search import search_phrases from sampletones_player.compression.tokens.hold import HoldToken from sampletones_player.compression.tokens.literal import LiteralToken @@ -90,14 +94,8 @@ def _settle( options: CodecOptions, boundaries: FrozenSet[int], monitor: CodecMonitor, + baseline: Sequence[Parse], ) -> Tuple[PhraseTable, Tuple[Parse, ...]]: - baseline = parse_planes( - cache, - phrase_table(()), - replace(options, phrases=False), - boundaries, - monitor, - ) parses = parse_planes(cache, table, options, boundaries, monitor) for _ in range(SETTLING_ROUNDS): pruned = prune( @@ -125,10 +123,12 @@ def encode_planes( ) -> CompressedPlanes: """Compresses a song's eight planes into the dictionary and streams the driver reads. - The instruments seed the dictionary, the search fills what they leave behind, and the table - then settles: phrases the parse names keep their place in the order they are leaned on, and - the parse runs again over the ids that frees, which is what puts the busiest phrases inside - the opcodes that name them. + Every layer is weighed against one reading of the song naming no phrase at all: the seeds a + dictionary crowded past its ids keeps, and the bytes each phrase spares once the table + settles. The instruments seed the dictionary, the search fills what they leave behind, and + the table then settles — phrases the parse names keep their place in the order they are + leaned on, and the parse runs again over the ids that frees, which is what puts the busiest + phrases inside the opcodes that name them. Args: planes: The eight planes, two per channel. @@ -146,11 +146,40 @@ def encode_planes( cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes.planes) monitor = CodecMonitor(report) entries = boundaries | {STREAM_START} - table = phrase_table(seeds) if options.phrases else phrase_table(()) + baseline = parse_planes( + cache, + phrase_table(()), + replace(options, phrases=False), + entries, + monitor, + ) + table = ( + admit_seeds( + cache, + seeds, + baseline, + options, + ) + if options.phrases + else phrase_table(()) + ) if options.phrases and options.search: - table = search_phrases(cache, table, options, entries, monitor) + table = search_phrases( + cache, + table, + options, + entries, + monitor, + ) - table, parses = _settle(cache, table, options, entries, monitor) + table, parses = _settle( + cache, + table, + options, + entries, + monitor, + baseline, + ) compressed = CompressedPlanes( phrases=table, streams=PlaneOrder.across(emit(parse.tokens) for parse in parses), diff --git a/src/sampletones_player/compression/entries.py b/src/sampletones_player/compression/entries.py new file mode 100644 index 000000000..84dac3509 --- /dev/null +++ b/src/sampletones_player/compression/entries.py @@ -0,0 +1,31 @@ +from sampletones_player.compression.tokens.span import token_span + + +def stream_entry(stream: bytes, tick: int) -> int: + """The byte of ``stream`` the token covering ``tick`` begins at. + + A song that repeats re-enters its streams partway through, and what the driver needs to + resume there is where each plane's next opcode lies. The encoder holds a token boundary at + the tick a song returns to, so the walk lands on it exactly. + + Args: + stream: The plane's token stream. + tick: The tick the stream is re-entered at. + + Returns: + int: The byte the token covering ``tick`` begins at, counted from the stream's own start. + + Raises: + ValueError: If the stream spans ``tick`` rather than starting a token there. + """ + position = 0 + reached = 0 + while reached < tick: + span = token_span(stream, position) + reached += span.ticks + position += span.size + + if reached != tick: + raise ValueError(f"the stream spans tick {tick} rather than starting a token there") + + return position diff --git a/src/sampletones_player/compression/matches/cache.py b/src/sampletones_player/compression/matches/cache.py index 95e3ed378..e844a6d3b 100644 --- a/src/sampletones_player/compression/matches/cache.py +++ b/src/sampletones_player/compression/matches/cache.py @@ -5,15 +5,14 @@ from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.played import played_ticks from sampletones_player.compression.matches.reading import PhraseReading -from sampletones_player.compression.matches.shift import translation -from sampletones_player.specification.compression import BYTE_VALUES, MAX_PHRASE_TICKS +from sampletones_player.compression.matches.shift import NO_SHIFT, asked_shift, translation +from sampletones_player.specification.compression import MAX_PHRASE_TICKS KEY_LENGTH: Final[int] = 2 TICKS_TYPECODE: Final[str] = "H" TICKS_ENTRY_SIZE: Final[int] = 2 NO_MATCH: Final[int] = 0 MIN_PHRASE_TICKS: Final[int] = 2 -NO_SHIFT: Final[int] = 0 class MatchCache: @@ -81,7 +80,7 @@ def _measure(self, plane: int, phrase: Phrase) -> PhraseReading: shifted = False unshifted = False for position in self._offered(plane, phrase): - transpose = (index.plane[position] - origin) % BYTE_VALUES + transpose = asked_shift(index.plane[position], origin) ticks = played_ticks( index, position, diff --git a/src/sampletones_player/compression/matches/shift.py b/src/sampletones_player/compression/matches/shift.py index 4de706e6d..fda242cda 100644 --- a/src/sampletones_player/compression/matches/shift.py +++ b/src/sampletones_player/compression/matches/shift.py @@ -1,7 +1,26 @@ from functools import lru_cache +from typing import Final from sampletones_player.specification.compression import BYTE_VALUES +NO_SHIFT: Final[int] = 0 + + +def asked_shift(value: int, origin: int) -> int: + """The shift a plane standing at ``value`` asks a phrase beginning at ``origin`` for. + + A phrase is stored at one pitch and played at any, so what a position asks of it is the step + from the phrase's own first value to the one standing there, taken within the byte. + + Args: + value: The value the plane holds at the position the phrase is offered. + origin: The phrase's own first value. + + Returns: + int: The shift the phrase is played at from there. + """ + return (value - origin) % BYTE_VALUES + @lru_cache(maxsize=BYTE_VALUES) def translation(transpose: int) -> bytes: diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py index a8121eeb3..9a019ad46 100644 --- a/src/sampletones_player/compression/planes/order.py +++ b/src/sampletones_player/compression/planes/order.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable, NamedTuple +from typing import Iterable, NamedTuple, Tuple from sampletones_player.specification.compression import PLANE_COUNT @@ -33,6 +33,11 @@ class PlaneOrder(NamedTuple): noise_control: bytes noise_value: bytes + @classmethod + def names(cls) -> Tuple[str, ...]: + """The planes' names, in the order the song block writes them.""" + return cls._fields + @classmethod def across(cls, planes: Iterable[bytes]) -> PlaneOrder: """Gathers a song's planes under the names the song block writes them by. diff --git a/src/sampletones_player/compression/tokens/span.py b/src/sampletones_player/compression/tokens/span.py new file mode 100644 index 000000000..bc67a7f5a --- /dev/null +++ b/src/sampletones_player/compression/tokens/span.py @@ -0,0 +1,61 @@ +from typing import NamedTuple + +from sampletones_player.specification.compression import ( + OPCODE_SIZE, + PHRASE_COUNT_SIZE, + PHRASE_ESCAPE_SIZE, + PHRASE_ID_ESCAPE, + TOKEN_OPERAND_MASK, + TOKEN_TAG_MASK, + TRANSPOSE_SIZE, + TokenTag, +) + + +class TokenSpan(NamedTuple): + """What one written token occupies in a stream and covers in the song. + + Both readings of a token stream ask this: the driver to know where the next opcode lies, and + the writer to find the byte a loop re-enters the stream at. Neither needs the dictionary, so + the two figures follow from the opcode and the bytes behind it alone. + + Attributes: + size: The bytes the token takes. + ticks: The ticks the token covers. + """ + + size: int + ticks: int + + +def _phrase_span(data: bytes, position: int, *, transposed: bool) -> TokenSpan: + named = data[position] & TOKEN_OPERAND_MASK + escape = PHRASE_ESCAPE_SIZE if named == PHRASE_ID_ESCAPE else 0 + count = position + OPCODE_SIZE + escape + shift = TRANSPOSE_SIZE if transposed else 0 + return TokenSpan( + size=OPCODE_SIZE + escape + PHRASE_COUNT_SIZE + shift, + ticks=data[count] + 1, + ) + + +def token_span(data: bytes, position: int) -> TokenSpan: + """Reads the token written at ``position``, answering what it takes and what it covers. + + Args: + data: The plane's token stream. + position: The byte the token's opcode lies at. + + Returns: + TokenSpan: The bytes the token takes and the ticks it covers. + """ + operand = data[position] & TOKEN_OPERAND_MASK + match TokenTag(data[position] & TOKEN_TAG_MASK): + case TokenTag.HOLD: + return TokenSpan(size=OPCODE_SIZE, ticks=operand + 1) + case TokenTag.LITERAL: + return TokenSpan(size=OPCODE_SIZE + operand + 1, ticks=operand + 1) + case TokenTag.PHRASE: + return _phrase_span(data, position, transposed=False) + case TokenTag.TRANSPOSED_PHRASE: + return _phrase_span(data, position, transposed=True) diff --git a/src/sampletones_player/driver/assembly/include/song.inc b/src/sampletones_player/driver/assembly/include/song.inc index e24a5ec6f..781b61fa1 100644 --- a/src/sampletones_player/driver/assembly/include/song.inc +++ b/src/sampletones_player/driver/assembly/include/song.inc @@ -1,17 +1,51 @@ WORD_SIZE = 2 +PLANE_COUNT = 8 STEP_WHOLE_OFFSET = 0 STEP_FRACTION_OFFSET = STEP_WHOLE_OFFSET + 1 TOTAL_TICKS_OFFSET = STEP_FRACTION_OFFSET + WORD_SIZE LOOP_TICK_OFFSET = TOTAL_TICKS_OFFSET + WORD_SIZE -STREAM_OFFSETS_OFFSET = LOOP_TICK_OFFSET + WORD_SIZE +TIMER_TABLE_OFFSET = LOOP_TICK_OFFSET + WORD_SIZE +PHRASE_TABLE_OFFSET = TIMER_TABLE_OFFSET + WORD_SIZE +STREAM_OFFSETS_OFFSET = PHRASE_TABLE_OFFSET + WORD_SIZE +LOOP_ENTRIES_OFFSET = STREAM_OFFSETS_OFFSET + WORD_SIZE * PLANE_COUNT NO_LOOP = $FFFF -PULSE1_STREAM = 0 * WORD_SIZE -PULSE2_STREAM = 1 * WORD_SIZE -TRIANGLE_STREAM = 2 * WORD_SIZE -NOISE_STREAM = 3 * WORD_SIZE +TICK_FINISHED = 0 +TICK_PLAYS = 1 +TICK_REPEATED = 2 -TONE_RECORD_SIZE = 3 -NOISE_RECORD_SIZE = 2 +PITCH_COUNT = 104 + +TOKEN_TAG_MASK = $C0 +TOKEN_OPERAND_MASK = $3F +TAG_HOLD = $00 +TAG_LITERAL = $40 +TAG_PHRASE = $80 +TAG_TRANSPOSED_PHRASE = $C0 + +OPCODE_SIZE = 1 +PHRASE_ID_ESCAPE = $3F +PHRASE_TABLE_COUNT_SIZE = 1 +PHRASE_TABLE_ENTRY_SIZE = 2 +PHRASE_LENGTH_SIZE = 1 + +PLANE_STATE_SIZE = 8 +PLANE_SOURCE = 0 +PLANE_PHRASE = 2 +PLANE_PHRASE_TICKS = 4 +PLANE_TOKEN_TICKS = 5 +PLANE_VALUE = 6 +PLANE_SHIFT = 7 + +PLANE_STATE_BYTES = PLANE_COUNT * PLANE_STATE_SIZE + +PULSE1_CONTROL_PLANE = 0 * PLANE_STATE_SIZE +PULSE1_VALUE_PLANE = 1 * PLANE_STATE_SIZE +PULSE2_CONTROL_PLANE = 2 * PLANE_STATE_SIZE +PULSE2_VALUE_PLANE = 3 * PLANE_STATE_SIZE +TRIANGLE_CONTROL_PLANE = 4 * PLANE_STATE_SIZE +TRIANGLE_VALUE_PLANE = 5 * PLANE_STATE_SIZE +NOISE_CONTROL_PLANE = 6 * PLANE_STATE_SIZE +NOISE_VALUE_PLANE = 7 * PLANE_STATE_SIZE diff --git a/src/sampletones_player/driver/assembly/source/channels.s b/src/sampletones_player/driver/assembly/source/channels.s index c271c1629..dcee633ca 100644 --- a/src/sampletones_player/driver/assembly/source/channels.s +++ b/src/sampletones_player/driver/assembly/source/channels.s @@ -4,113 +4,307 @@ .include "song.inc" .export channels_reset -.export channels_write_tick +.export channels_rewind +.export channels_advance +.export channels_write .import song_data -.importzp current_tick SHADOW_UNWRITTEN = $FF .segment "ZEROPAGE" pointer: .res 2 -record_offset: .res 2 +entry: .res 2 +opcode: .res 1 +phrase_table: .res 2 +timer_table: .res 2 +plane_state: .res PLANE_STATE_BYTES timer_high_shadows: .res TRIANGLE_REGISTERS + 1 .segment "CODE" +.assert OPCODE_SIZE = 1, error, "a token's operands follow its opcode by one byte" +.assert PHRASE_TABLE_ENTRY_SIZE = 2, error, "a table entry is reached by one doubling" +.assert PHRASE_LENGTH_SIZE = 1, error, "a phrase body follows its length by one byte" + +; Readies the tables the planes read through and points all eight at their own first token. channels_reset: lda #SHADOW_UNWRITTEN sta timer_high_shadows + PULSE1_REGISTERS sta timer_high_shadows + PULSE2_REGISTERS sta timer_high_shadows + TRIANGLE_REGISTERS - rts -channels_write_tick: - jsr set_tone_offset - ldx #PULSE1_REGISTERS - ldy #PULSE1_STREAM - jsr write_tone_channel - ldx #PULSE2_REGISTERS - ldy #PULSE2_STREAM - jsr write_tone_channel - ldx #TRIANGLE_REGISTERS - ldy #TRIANGLE_STREAM - jsr write_tone_channel + clc + lda song_data + TIMER_TABLE_OFFSET + adc #song_data + sta timer_table + 1 - jsr set_noise_offset - ldx #NOISE_REGISTERS - ldy #NOISE_STREAM - jmp write_noise_channel + clc + lda song_data + PHRASE_TABLE_OFFSET + adc #<(song_data + PHRASE_TABLE_COUNT_SIZE) + sta phrase_table + lda song_data + PHRASE_TABLE_OFFSET + 1 + adc #>(song_data + PHRASE_TABLE_COUNT_SIZE) + sta phrase_table + 1 + + lda #<(song_data + STREAM_OFFSETS_OFFSET) + sta pointer + lda #>(song_data + STREAM_OFFSETS_OFFSET) + sta pointer + 1 + jmp seed_planes -.assert NOISE_RECORD_SIZE = 2, error, "the noise record is reached by one doubling" -.assert TONE_RECORD_SIZE = 3, error, "the tone record is reached by a doubling and one more tick" +; Brings every plane back to the token its stream is re-entered at, which is the whole of what a +; song coming round restores: the token the loop tick starts is one the plane reads outright. +channels_rewind: + lda #<(song_data + LOOP_ENTRIES_OFFSET) + sta pointer + lda #>(song_data + LOOP_ENTRIES_OFFSET) + sta pointer + 1 + jmp seed_planes -set_noise_offset: - lda current_tick - asl - sta record_offset - lda current_tick + 1 - rol - sta record_offset + 1 +; Points each plane at the offset the header holds for it at (pointer), and empties what it plays. +seed_planes: + ldx #$00 + ldy #$00 +@next: + clc + lda (pointer),y + adc #song_data + sta plane_state + PLANE_SOURCE + 1,x + iny + + lda #$00 + sta plane_state + PLANE_PHRASE_TICKS,x + sta plane_state + PLANE_TOKEN_TICKS,x + sta plane_state + PLANE_VALUE,x + sta plane_state + PLANE_SHIFT,x + + txa + clc + adc #PLANE_STATE_SIZE + tax + cpx #PLANE_STATE_BYTES + bne @next rts -set_tone_offset: - jsr set_noise_offset +; Advances every plane by one tick of the song. +channels_advance: + ldx #$00 +@next: + jsr plane_advance + txa clc - lda record_offset - adc current_tick - sta record_offset - lda record_offset + 1 - adc current_tick + 1 - sta record_offset + 1 + adc #PLANE_STATE_SIZE + tax + cpx #PLANE_STATE_BYTES + bne @next rts -; Points at the current tick's record in the stream whose header offset lies at Y. -set_pointer: +; Advances the plane whose state lies at X by one tick, leaving the value it plays in that state. +; A token states the ticks it covers beyond the one it is fetched for, so a plane reaches for the +; next token the moment the one it stands on has none left. +; +; The values a tick plays come from wherever the token put them: a phrase body, the bytes spelled +; out behind a literal, or the value the plane already reached. A body played out holds its last +; value onwards, which is what carries a note whose envelope has finished. +plane_advance: + lda plane_state + PLANE_TOKEN_TICKS,x + bne @within + jsr fetch_token + jmp @plays +@within: + dec plane_state + PLANE_TOKEN_TICKS,x +@plays: + lda plane_state + PLANE_PHRASE_TICKS,x + beq @held + lda (plane_state + PLANE_PHRASE,x) clc - lda song_data + STREAM_OFFSETS_OFFSET,y - adc record_offset + adc plane_state + PLANE_SHIFT,x + sta plane_state + PLANE_VALUE,x + dec plane_state + PLANE_PHRASE_TICKS,x + beq @held + inc plane_state + PLANE_PHRASE,x + bne @held + inc plane_state + PLANE_PHRASE + 1,x +@held: + rts + +; Reads the token the plane at X stands on into that plane's own state. +fetch_token: + lda plane_state + PLANE_SOURCE,x sta pointer - lda song_data + STREAM_OFFSETS_OFFSET + 1,y - adc record_offset + 1 + lda plane_state + PLANE_SOURCE + 1,x sta pointer + 1 + ldy #$00 + lda (pointer),y + sta opcode + iny + + and #TOKEN_TAG_MASK + beq @holds + cmp #TAG_LITERAL + beq @spells + jmp @plays_phrase + +@holds: + lda opcode + and #TOKEN_OPERAND_MASK + sta plane_state + PLANE_TOKEN_TICKS,x + lda #$00 + sta plane_state + PLANE_PHRASE_TICKS,x + jmp advance_source + +@spells: + lda opcode + and #TOKEN_OPERAND_MASK + sta plane_state + PLANE_TOKEN_TICKS,x + clc + adc #$01 + sta plane_state + PLANE_PHRASE_TICKS,x + + lda #$00 + sta plane_state + PLANE_SHIFT,x + clc lda pointer - adc #song_data - sta pointer + 1 - rts + adc #$00 + sta plane_state + PLANE_PHRASE + 1,x -; Writes one tick to a channel, with the channel's register base in X and its header offset in Y. -; A timer's high half reaches the register only where it differs from the last one written, since -; storing it restarts a pulse waveform and reloads the triangle's counter. -write_tone_channel: - jsr set_pointer - ldy #$00 + clc + tya + adc plane_state + PLANE_PHRASE_TICKS,x + tay + jmp advance_source + +@plays_phrase: + lda opcode + and #TOKEN_OPERAND_MASK + cmp #PHRASE_ID_ESCAPE + bne @named lda (pointer),y - sta CHANNEL_CONTROL,x iny +@named: + jsr set_phrase lda (pointer),y - sta CHANNEL_TIMER_LOW,x iny + sta plane_state + PLANE_TOKEN_TICKS,x + lda #$00 + sta plane_state + PLANE_SHIFT,x + bit opcode + bvc advance_source lda (pointer),y - cmp timer_high_shadows,x - beq @held - sta timer_high_shadows,x - sta CHANNEL_TIMER_HIGH,x -@held: + iny + sta plane_state + PLANE_SHIFT,x + +; Moves the plane's source on by the Y bytes its token took. +advance_source: + clc + tya + adc plane_state + PLANE_SOURCE,x + sta plane_state + PLANE_SOURCE,x + bcc @done + inc plane_state + PLANE_SOURCE + 1,x +@done: rts -write_noise_channel: - jsr set_pointer +; Points the plane at X at the body of the phrase whose id lies in A, and states how many of that +; body's own values are left to play. +set_phrase: + asl + sta entry + lda #$00 + rol + sta entry + 1 + + clc + lda entry + adc phrase_table + sta entry + lda entry + 1 + adc phrase_table + 1 + sta entry + 1 + + tya + pha ldy #$00 - lda (pointer),y - sta CHANNEL_CONTROL,x + clc + lda (entry),y + adc #song_data + sta plane_state + PLANE_PHRASE + 1,x + + lda plane_state + PLANE_PHRASE,x + sta entry + lda plane_state + PLANE_PHRASE + 1,x + sta entry + 1 + ldy #$00 + lda (entry),y + sta plane_state + PLANE_PHRASE_TICKS,x + + inc plane_state + PLANE_PHRASE,x + bne @body + inc plane_state + PLANE_PHRASE + 1,x +@body: + pla + tay + rts + +; Writes what every plane last played to the registers its channel owns. +channels_write: + lda plane_state + PULSE1_CONTROL_PLANE + PLANE_VALUE + sta CHANNEL_CONTROL + PULSE1_REGISTERS + ldx #PULSE1_REGISTERS + ldy plane_state + PULSE1_VALUE_PLANE + PLANE_VALUE + jsr write_timer + + lda plane_state + PULSE2_CONTROL_PLANE + PLANE_VALUE + sta CHANNEL_CONTROL + PULSE2_REGISTERS + ldx #PULSE2_REGISTERS + ldy plane_state + PULSE2_VALUE_PLANE + PLANE_VALUE + jsr write_timer + + lda plane_state + TRIANGLE_CONTROL_PLANE + PLANE_VALUE + sta CHANNEL_CONTROL + TRIANGLE_REGISTERS + ldx #TRIANGLE_REGISTERS + ldy plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + jsr write_timer + + lda plane_state + NOISE_CONTROL_PLANE + PLANE_VALUE + sta CHANNEL_CONTROL + NOISE_REGISTERS + lda plane_state + NOISE_VALUE_PLANE + PLANE_VALUE + sta CHANNEL_TIMER_LOW + NOISE_REGISTERS + rts + +; Writes the timer the pitch at Y sounds at to the channel whose register base lies in X. The +; table holds every low byte and then every high byte, so one pointer reaches both halves. A high +; half reaches the register only where it differs from the last one written, since storing it +; restarts a pulse waveform and reloads the triangle's counter. +write_timer: + lda (timer_table),y sta CHANNEL_TIMER_LOW,x + tya + clc + adc #PITCH_COUNT + tay + lda (timer_table),y + cmp timer_high_shadows,x + beq @held + sta timer_high_shadows,x + sta CHANNEL_TIMER_HIGH,x +@held: rts diff --git a/src/sampletones_player/driver/assembly/source/clock.s b/src/sampletones_player/driver/assembly/source/clock.s index 8cdd9e086..8e8f3abe6 100644 --- a/src/sampletones_player/driver/assembly/source/clock.s +++ b/src/sampletones_player/driver/assembly/source/clock.s @@ -4,7 +4,7 @@ .export clock_reset .export clock_advance -.exportzp current_tick +.export clock_step .import song_data @@ -25,12 +25,12 @@ clock_reset: sta finished rts -; Advances the stream by one play call's worth of ticks. -; Answers with A = 0 where the console is to be left alone, either because the stream holds its -; tick through this call or because the song has ended. +; Answers with the ticks this play call advances the streams by, which the accumulator reads off +; the top of a step added once a call. A song standing still between its own ticks, and one that +; has ended, both answer with none. clock_advance: lda finished - bne @hold + bne @none clc lda accumulator @@ -41,62 +41,45 @@ clock_advance: sta accumulator + 1 lda song_data + STEP_WHOLE_OFFSET adc #$00 - beq @hold - - clc - adc current_tick - sta current_tick - bcc @wrap - inc current_tick + 1 -@wrap: - jsr wrap_tick - lda finished - bne @hold - - lda #$01 rts -@hold: +@none: lda #$00 rts -; Brings a tick that has run past the song's end back to where the song repeats, or marks the song -; finished where it has no loop. Each pass takes off the whole of the looping part, so a call that -; advances by more ticks than the loop is long still lands inside it. -wrap_tick: +; Moves the clock on by a single tick and answers what the channels are to do with it: play it +; where the song still runs, play it from the loop entry where the song has just come round, or +; leave the console alone where the song has ended. +clock_step: + inc current_tick + bne @reached + inc current_tick + 1 +@reached: lda current_tick + 1 cmp song_data + TOTAL_TICKS_OFFSET + 1 - bcc @within - bne @past + bcc @plays + bne @ended lda current_tick cmp song_data + TOTAL_TICKS_OFFSET - bcc @within -@past: + bcc @plays +@ended: lda song_data + LOOP_TICK_OFFSET cmp #NO_LOOP - bne @rewind + bne @repeats lda #$01 sta finished + lda #TICK_FINISHED rts -@rewind: - sec - lda current_tick - sbc song_data + TOTAL_TICKS_OFFSET - sta current_tick - lda current_tick + 1 - sbc song_data + TOTAL_TICKS_OFFSET + 1 - sta current_tick + 1 - - clc - lda current_tick - adc song_data + LOOP_TICK_OFFSET +@repeats: + lda song_data + LOOP_TICK_OFFSET sta current_tick - lda current_tick + 1 - adc song_data + LOOP_TICK_OFFSET + 1 + lda song_data + LOOP_TICK_OFFSET + 1 sta current_tick + 1 - jmp wrap_tick -@within: + lda #TICK_REPEATED + rts +@plays: + lda #TICK_PLAYS rts diff --git a/src/sampletones_player/driver/assembly/source/driver.s b/src/sampletones_player/driver/assembly/source/driver.s index 533e17ed3..775715dc0 100644 --- a/src/sampletones_player/driver/assembly/source/driver.s +++ b/src/sampletones_player/driver/assembly/source/driver.s @@ -1,6 +1,7 @@ .setcpu "6502" .include "nes.inc" +.include "song.inc" .export nsf_init .export nsf_play @@ -8,8 +9,15 @@ .import clock_reset .import clock_advance +.import clock_step .import channels_reset -.import channels_write_tick +.import channels_rewind +.import channels_advance +.import channels_write + +.segment "ZEROPAGE" + +pending_ticks: .res 1 .segment "CODE" @@ -31,12 +39,27 @@ start_song: sta NOISE_LENGTH_COUNTER jsr clock_reset jsr channels_reset - jmp channels_write_tick + jsr channels_advance + jmp channels_write +; Advances the streams by the ticks this call is due and writes the tick they land on. A call +; crossing the song's end either brings the planes back to where the song repeats and plays on, +; or leaves the console holding what the final tick wrote. advance_song: jsr clock_advance beq @held - jmp channels_write_tick + sta pending_ticks +@next: + jsr clock_step + beq @held + cmp #TICK_REPEATED + bne @plays + jsr channels_rewind +@plays: + jsr channels_advance + dec pending_ticks + bne @next + jmp channels_write @held: rts diff --git a/src/sampletones_player/driver/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin index b327ac9bf4ec2dfad4db02dc401d782bc09e28ba..f48183e6ccf36e4540ba13dea85778ecb0892393 100644 GIT binary patch literal 584 zcmX|6KWGzC7=QQwk|w!aE{V4&hv-nXi{RiOeT$IRtEqPAdZ2~g(6Rg7gKrTIvOEYv zpdNXUC)|+X7F}GFWGLKXN9j}?6mF2{R^MrL`F?-C-^cfTz8?AaB5yn5R?@>9C)Y4H z6A338<|whSw;y@$BkxA+E5v&ll`bGJib{Ja7mD|-YJ5V)chL+uO3?)2ujk$3mk=O< z3<{{A1xzb?E|ZS72uMsm+9n`p0}>*U3NDmFzEGKZ>XXsk!eGkj6EeD)?U3bV(aN4y zU~maE5fvsV!UC1c0ha=P2}9U}eK#ADZ~)D0mxKgbZuX3XTdWlRcrLn|i+kYLi`uZmAR!jVMSHcY?>IkRr zGF?!c86lz_cYN&3t}@HbLYeCe?_(_LpXvUs`mR=qzQ|eB8p*46PzRHvJ5Y0(d9Vg1 zs~t3;HXc+jn&L%!A%M_MZiAV3peBwpsq^>Z!&$(33BoCwUia=3b~{mVs!lg5{va`N jYEBqqTt{+t`Xe3 literal 339 zcmZXPtxp3%5XJX)Z||<8q#-~!xdu@rL0||Q^tnPmz^pk@asNTZju?4K4YJAQk`XH^ zkd-)S(&P*tMG!Qp16Q_K%>44+eB@0Q*{o~TxpkfLSUX*wXqS|ME~R#?>}Yju)w9i( zR%Lc0JdI;Dn=$?Qe$@PUfeZuWC@?e>HrWO?y7tLAt=7yd=Kv19B4U0iZ~U7sEeUjJ(VyKF$_f zxQ_=$ecPH{G>*2;vVuC<>CSI l1f!JXxQCLWF<@j=0u_6b{_7WC`L4dTHuAAG6Z&7%`~p6xdl~=$ diff --git a/src/sampletones_player/nsf/layout.py b/src/sampletones_player/nsf/layout.py new file mode 100644 index 000000000..70ae9baca --- /dev/null +++ b/src/sampletones_player/nsf/layout.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Sequence, Tuple + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.song import Song +from sampletones_player.specification.compression import ( + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_COUNT_SIZE, + PHRASE_TABLE_ENTRY_SIZE, +) +from sampletones_player.specification.song import SONG_HEADER_SIZE + +FIRST_TICK: Final[int] = 0 +NAME_SEPARATOR: Final[str] = "_" + + +def _running(start: int, sizes: Sequence[int]) -> Tuple[int, ...]: + offsets = [] + offset = start + for size in sizes: + offsets.append(offset) + offset += size + + return tuple(offsets) + + +@dataclass(frozen=True) +class SongLayout: + """Where each part of a song block begins, every offset counted from the block's first byte. + + The block is written and read through these offsets alone, which is what lets each part grow + with the song it carries: a header states where the tables and the streams lie, a phrase + entry states where its body lies, and the driver reaches any of them by adding the offset to + the address the song loaded at. + + Attributes: + timer_table: Where the timer each pitch sounds at begins. + phrase_table: Where the dictionary's count and entries begin. + bodies: Where each phrase's length byte lies, in id order. + streams: Where each plane's token stream begins, in song-block order. + loop_entries: Where each plane's stream is re-entered once the song repeats. + size: The bytes the whole block takes. + """ + + timer_table: int + phrase_table: int + bodies: Tuple[int, ...] + streams: Tuple[int, ...] + loop_entries: Tuple[int, ...] + size: int + + @classmethod + def of(cls, song: Song) -> SongLayout: + """Lays a song out into the block the driver plays it from. + + Args: + song: The compressed planes, the timer table and the clock to lay out. + + Returns: + SongLayout: Where each part of the block begins. + + Raises: + ValueError: If a stream spans the song's loop tick rather than starting a token there. + """ + phrases = song.planes.phrases + streams = song.planes.streams + body_sizes = [PHRASE_LENGTH_SIZE + phrase.length for phrase in phrases.phrases] + stream_sizes = [len(stream) for stream in streams] + + timer_table = SONG_HEADER_SIZE + phrase_table = timer_table + len(song.pitches.data) + bodies = phrase_table + cls._table_size(phrases) + stream_start = bodies + sum(body_sizes) + + stream_offsets = _running(stream_start, stream_sizes) + entered = song.planes.entries(FIRST_TICK if song.loop_tick is None else song.loop_tick) + return cls( + timer_table=timer_table, + phrase_table=phrase_table, + bodies=_running(bodies, body_sizes), + streams=stream_offsets, + loop_entries=tuple(offset + entry for offset, entry in zip(stream_offsets, entered)), + size=stream_start + sum(stream_sizes), + ) + + @staticmethod + def _table_size(phrases: PhraseTable) -> int: + return PHRASE_TABLE_COUNT_SIZE + PHRASE_TABLE_ENTRY_SIZE * len(phrases) + + @property + def stated(self) -> Tuple[Tuple[str, int], ...]: + """Every offset the block states, each under the name of what it points at.""" + return ( + ("timer table", self.timer_table), + ("phrase table", self.phrase_table), + *((f"phrase {phrase_id}", offset) for phrase_id, offset in enumerate(self.bodies)), + *self._named(self.streams, "stream"), + *self._named(self.loop_entries, "loop entry"), + ) + + @staticmethod + def _named(offsets: Sequence[int], part: str) -> Tuple[Tuple[str, int], ...]: + return tuple( + (f"{plane.replace(NAME_SEPARATOR, ' ')} {part}", offset) + for plane, offset in zip(PlaneOrder.names(), offsets) + ) diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py index 26f3db60f..e96d2ace2 100644 --- a/src/sampletones_player/nsf/song.py +++ b/src/sampletones_player/nsf/song.py @@ -1,90 +1,96 @@ -from typing import Sequence, Tuple - -from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.binary import BinaryWriter -from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.nsf.layout import SongLayout from sampletones_player.song import Song -from sampletones_player.specification.song import ( - MAX_STREAM_OFFSET, - NO_LOOP, - SONG_HEADER_SIZE, -) +from sampletones_player.specification.song import MAX_BLOCK_OFFSET, NO_LOOP from sampletones_shared.exceptions import SongTooLargeError -def _stream_to_bytes(stream: Sequence[ChannelRegisters]) -> bytes: - writer = BinaryWriter() - for registers in stream: - for value in registers.values: - writer.write_uint8(value) - - return writer.data - - -def _stream_offsets(bodies: Sequence[bytes]) -> Tuple[int, ...]: - offsets = [] - offset = SONG_HEADER_SIZE - for body in bodies: - offsets.append(offset) - offset += len(body) - - return tuple(offsets) - - def _write_header( writer: BinaryWriter, song: Song, - offsets: Sequence[int], + layout: SongLayout, ) -> None: step = song.schedule.fixed_point_step writer.write_uint8(step.whole) writer.write_uint16(step.fraction) writer.write_uint16(song.ticks) writer.write_uint16(NO_LOOP if song.loop_tick is None else song.loop_tick) - for offset in offsets: + writer.write_uint16(layout.timer_table) + writer.write_uint16(layout.phrase_table) + for offset in layout.streams: + writer.write_uint16(offset) + + for offset in layout.loop_entries: writer.write_uint16(offset) +def _write_timer_table(writer: BinaryWriter, pitches: PitchTable) -> None: + writer.write_bytes(pitches.data) + + +def _write_phrase_table( + writer: BinaryWriter, + phrases: PhraseTable, + layout: SongLayout, +) -> None: + writer.write_uint8(len(phrases)) + for offset in layout.bodies: + writer.write_uint16(offset) + + for phrase in phrases.phrases: + writer.write_uint8(phrase.length) + writer.write_bytes(phrase.body) + + +def _write_streams(writer: BinaryWriter, streams: PlaneOrder) -> None: + for stream in streams: + writer.write_bytes(stream) + + def _validate_space(size: int, available_bytes: int) -> None: if size > available_bytes: raise SongTooLargeError(f"the song takes {size} bytes and {available_bytes} are free") -def _validate_offsets(offsets: Sequence[int]) -> None: - for channel, offset in zip(ChannelName.items(), offsets): - if offset > MAX_STREAM_OFFSET: +def _validate_offsets(layout: SongLayout) -> None: + for part, offset in layout.stated: + if offset > MAX_BLOCK_OFFSET: raise SongTooLargeError( - f"the {channel.value} stream starts {offset} bytes into the song " - f"and its header states at most {MAX_STREAM_OFFSET}", + f"the {part} lies {offset} bytes into the song " f"and its header states at most {MAX_BLOCK_OFFSET}", ) def song_to_bytes(song: Song, available_bytes: int) -> bytes: """Serializes a song to the bytes the driver reads it from. - The header states the clock and the length, then names where each channel's stream begins as - a distance from the song's own first byte, so the whole block plays from wherever the file - loads it and each channel can later be compressed on its own. + The header states the clock, the length and where each of the song's parts begins, so the + whole block plays from wherever the file loads it: the timer every pitch sounds at, the + dictionary the tokens name, and the eight token streams the channels decode a tick at a + time. A song that repeats also states the byte each stream is re-entered at, which is the + whole of what a loop restores. Args: - song: The streams, the clock and the loop point to write. + song: The compressed planes, the timer table, the clock and the loop point to write. available_bytes: The space the song has to fit in. Returns: - bytes: The song header followed by the four channel streams. + bytes: The song header, the timer table, the dictionary and the eight token streams. Raises: SongTooLargeError: If the song takes more than ``available_bytes``, or reaches further - into itself than a stream offset states. + into itself than an offset it states. + ValueError: If a stream spans the song's loop tick rather than starting a token there. """ - bodies = tuple(_stream_to_bytes(stream) for stream in song.streams.padded) - offsets = _stream_offsets(bodies) - _validate_space(SONG_HEADER_SIZE + sum(len(body) for body in bodies), available_bytes) - _validate_offsets(offsets) + layout = SongLayout.of(song) + _validate_space(layout.size, available_bytes) + _validate_offsets(layout) writer = BinaryWriter() - _write_header(writer, song, offsets) - for body in bodies: - writer.write_bytes(body) - + _write_header(writer, song, layout) + _write_timer_table(writer, song.pitches) + _write_phrase_table(writer, song.planes.phrases, layout) + _write_streams(writer, song.planes.streams) return writer.data diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py index 9a29fdc99..d26d70b18 100644 --- a/src/sampletones_player/specification/compression.py +++ b/src/sampletones_player/specification/compression.py @@ -1,8 +1,10 @@ from enum import IntEnum +from math import ceil from typing import Final from sampletones_core.constants.enums import ChannelName from sampletones_player.specification.binary import WORD_SIZE +from sampletones_shared.constants.general import BITS_PER_BYTE class TokenTag(IntEnum): @@ -21,6 +23,9 @@ class TokenTag(IntEnum): TRANSPOSED_PHRASE = 0xC0 +BYTE_VALUES: Final[int] = 256 +MAX_BYTE_VALUE: Final[int] = BYTE_VALUES - 1 + TOKEN_TAG_MASK: Final[int] = 0xC0 TOKEN_OPERAND_MASK: Final[int] = 0x3F @@ -31,20 +36,17 @@ class TokenTag(IntEnum): MAX_HOLD_TICKS: Final[int] = TOKEN_OPERAND_MASK + 1 MAX_LITERAL_BYTES: Final[int] = TOKEN_OPERAND_MASK + 1 -MAX_PHRASE_TICKS: Final[int] = 256 +MAX_PHRASE_TICKS: Final[int] = BYTE_VALUES PHRASE_ID_ESCAPE: Final[int] = TOKEN_OPERAND_MASK CHEAP_PHRASE_IDS: Final[int] = PHRASE_ID_ESCAPE -MAX_PHRASE_IDS: Final[int] = 256 -MAX_PHRASE_LENGTH: Final[int] = 255 +MAX_PHRASE_IDS: Final[int] = MAX_BYTE_VALUE +MAX_PHRASE_LENGTH: Final[int] = MAX_BYTE_VALUE -PHRASE_TABLE_COUNT_SIZE: Final[int] = 1 +PHRASE_TABLE_COUNT_SIZE: Final[int] = ceil(MAX_PHRASE_IDS.bit_length() / BITS_PER_BYTE) PHRASE_TABLE_ENTRY_SIZE: Final[int] = WORD_SIZE PHRASE_LENGTH_SIZE: Final[int] = 1 -BYTE_VALUES: Final[int] = 256 -MAX_BYTE_VALUE: Final[int] = BYTE_VALUES - 1 - INITIAL_PLANE_VALUE: Final[int] = 0 PLANES_PER_CHANNEL: Final[int] = 2 diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py index 03ab89293..ea9f706a2 100644 --- a/src/sampletones_player/specification/song.py +++ b/src/sampletones_player/specification/song.py @@ -1,14 +1,17 @@ from typing import Final -from sampletones_core.constants.enums import ChannelName from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import PLANE_COUNT STEP_WHOLE_OFFSET: Final[int] = 0 STEP_FRACTION_OFFSET: Final[int] = STEP_WHOLE_OFFSET + 1 TOTAL_TICKS_OFFSET: Final[int] = STEP_FRACTION_OFFSET + WORD_SIZE LOOP_TICK_OFFSET: Final[int] = TOTAL_TICKS_OFFSET + WORD_SIZE -STREAM_OFFSETS_OFFSET: Final[int] = LOOP_TICK_OFFSET + WORD_SIZE -SONG_HEADER_SIZE: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * len(ChannelName) +TIMER_TABLE_OFFSET: Final[int] = LOOP_TICK_OFFSET + WORD_SIZE +PHRASE_TABLE_OFFSET: Final[int] = TIMER_TABLE_OFFSET + WORD_SIZE +STREAM_OFFSETS_OFFSET: Final[int] = PHRASE_TABLE_OFFSET + WORD_SIZE +LOOP_ENTRIES_OFFSET: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * PLANE_COUNT +SONG_HEADER_SIZE: Final[int] = LOOP_ENTRIES_OFFSET + WORD_SIZE * PLANE_COUNT NO_LOOP: Final[int] = 0xFFFF -MAX_STREAM_OFFSET: Final[int] = 0xFFFF +MAX_BLOCK_OFFSET: Final[int] = 0xFFFF diff --git a/src/sampletones_shared/constants/general.py b/src/sampletones_shared/constants/general.py index aa60b07bd..18fd8cb33 100644 --- a/src/sampletones_shared/constants/general.py +++ b/src/sampletones_shared/constants/general.py @@ -1,3 +1,4 @@ from typing import Final HEXADECIMAL_BASE: Final[int] = 16 +BITS_PER_BYTE: Final[int] = 8 diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py index 34c4ddb8f..ea98a6975 100644 --- a/tests/integration/nsf/console/session.py +++ b/tests/integration/nsf/console/session.py @@ -22,7 +22,14 @@ def play_calls_covering(song: Song) -> int: Returns: int: The number of play calls the run covers. + + Raises: + ValueError: If the song repeats, which leaves it no last tick to reach past. A run over + one covers the calls :func:`play_calls_reaching` measures. """ + if song.loop_tick is not None: + raise ValueError(f"a song repeating from tick {song.loop_tick} runs for as long as it is called") + calls = 0 while song.tick_at(calls) is not None: calls += 1 @@ -30,6 +37,26 @@ def play_calls_covering(song: Song) -> int: return calls + TRAILING_CALLS +def play_calls_reaching(song: Song, ticks: int) -> int: + """How many play calls carry a song's streams past ``ticks`` of its own time. + + A song that repeats runs on for as long as it is called, so a run over one is measured by the + ticks it is to cover rather than by where the streams end. + + Args: + song: The song the driver plays. + ticks: The ticks the run is to reach past. + + Returns: + int: The number of play calls the run covers. + """ + calls = 0 + while song.schedule.ticks_at(calls) <= ticks: + calls += 1 + + return calls + + def captured_file_trace(data: bytes, song: Song) -> RegisterTrace: """Runs an exported file on a 6502 and answers with every APU write it made. @@ -37,22 +64,53 @@ def captured_file_trace(data: bytes, song: Song) -> RegisterTrace: data: The whole ``.nsf`` file, header included. song: The song the file plays, which states how far the run reaches. + Returns: + RegisterTrace: The writes of the initialisation and of every play call in the run. + """ + return captured_run(data, play_calls_covering(song)) + + +def captured_run(data: bytes, play_calls: int) -> RegisterTrace: + """Runs an exported file for a stated number of calls and answers with every APU write. + + Args: + data: The whole ``.nsf`` file, header included. + play_calls: How many play calls the run covers. + Returns: RegisterTrace: The writes of the initialisation and of every play call in the run. """ image = DriverImage.load() console = Console(data, image.addresses) - return console.trace(play_calls_covering(song)) + return console.trace(play_calls) def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: """Exports a song, runs the file on a 6502 and answers with every APU write it made. Args: - song: The song to export and play. + song: The song to export and play, which states how far the run reaches. information: The text the exported header carries. Returns: RegisterTrace: The writes of the initialisation and of every play call in the run. """ return captured_file_trace(nsf_to_bytes(song, information, DriverImage.load()), song) + + +def captured_trace_over( + song: Song, + information: NSFInformation, + play_calls: int, +) -> RegisterTrace: + """Exports a song and runs the file for ``play_calls`` calls, a repeating song included. + + Args: + song: The song to export and play. + information: The text the exported header carries. + play_calls: How many play calls the run covers. + + Returns: + RegisterTrace: The writes of the initialisation and of every play call in the run. + """ + return captured_run(nsf_to_bytes(song, information, DriverImage.load()), play_calls) diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py index 035f638e4..c66eeaa5c 100644 --- a/tests/integration/nsf/test_driver_trace.py +++ b/tests/integration/nsf/test_driver_trace.py @@ -15,7 +15,9 @@ from tests.integration.nsf.console.session import ( TRAILING_CALLS, captured_trace, + captured_trace_over, play_calls_covering, + play_calls_reaching, ) from tests.integration.nsf.exports import exported_information from tests.suite.base import BaseTestSuite @@ -23,6 +25,9 @@ HALF_RATE: Final[int] = 30 DOUBLE_RATE: Final[int] = 120 +NTSC_RATE: Final[int] = 60 +FAST_RATE: Final[int] = 300 +ROUNDS: Final[int] = 4 @pytest.fixture @@ -115,3 +120,66 @@ def test_the_rate_reaches_the_console_as_the_step_alone( assert block[STEP_FRACTION_OFFSET + WORD_SIZE :] == reclocked_block[STEP_FRACTION_OFFSET + WORD_SIZE :] assert block[:STEP_WHOLE_OFFSET] == reclocked_block[:STEP_WHOLE_OFFSET] + + +class TestARepeatingSongComesRoundWhereTheModelSaysItDoes(BaseTestSuite): + """A song that repeats re-enters its streams partway through. + + What the driver restores at the loop is the byte each of the eight planes resumes at, which + the header states, so a plane comes back holding nothing of the run that led up to it. The + tick the loop returns to therefore starts a token of its own on every plane. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + name: str + nes_frequency: int + remaining: int + + @property + def label(self) -> str: + return self.name + + test_cases = ( + TestCase(name="half-the-song", nes_frequency=NTSC_RATE, remaining=0, expected=ROUNDS), + TestCase(name="one-tick-loop", nes_frequency=NTSC_RATE, remaining=1, expected=ROUNDS), + TestCase(name="one-tick-loop-fast", nes_frequency=FAST_RATE, remaining=1, expected=ROUNDS), + TestCase(name="half-the-song-fast", nes_frequency=FAST_RATE, remaining=0, expected=ROUNDS), + ) + + @staticmethod + def repeating(sample: Sample, test_case: "TestCase") -> Song: + reclocked = sample.reconstruction.with_nes_frequency(test_case.nes_frequency) + ticks = song_from_reconstruction(reclocked, loop_tick=None).ticks + loop_tick = ticks - test_case.remaining if test_case.remaining else ticks // 2 + return song_from_reconstruction(reclocked, loop_tick=loop_tick) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_driver_writes_what_the_model_states( + self, + test_case: TestCase, + sample: Sample, + ) -> None: + song = self.repeating(sample, test_case) + assert song.loop_tick is not None + covered = song.ticks + test_case.expected * (song.ticks - song.loop_tick) + calls = play_calls_reaching(song, covered) + + trace = captured_trace_over(song, exported_information(sample.name), calls) + assert trace == RegisterTrace.from_song(song, calls) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_run_keeps_sounding_past_the_songs_end( + self, + test_case: TestCase, + sample: Sample, + ) -> None: + """A song without a loop falls silent where it ends, and one with a loop plays on.""" + song = self.repeating(sample, test_case) + assert song.loop_tick is not None + covered = song.ticks + test_case.expected * (song.ticks - song.loop_tick) + calls = play_calls_reaching(song, covered) + + trace = captured_trace_over(song, exported_information(sample.name), calls) + assert any(writes for writes in trace.play_calls[-TRAILING_CALLS:]) diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py index 3ec153f30..09dbeb669 100644 --- a/tests/integration/nsf/test_nsf_pipeline.py +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -13,6 +13,7 @@ from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import PLANE_COUNT from sampletones_player.specification.nsf import ( HEADER_SIZE, NSF_MAGIC, @@ -40,7 +41,7 @@ def read_word(data: bytes, offset: int) -> int: def stream_offsets(block: bytes) -> Tuple[int, ...]: - return tuple(read_word(block, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(ChannelName))) + return tuple(read_word(block, STREAM_OFFSETS_OFFSET + WORD_SIZE * plane) for plane in range(PLANE_COUNT)) @pytest.fixture @@ -121,10 +122,10 @@ def test_every_stream_begins_inside_the_block( ) -> None: block = song_block(exported, driver_image) offsets = stream_offsets(block) - assert offsets[0] == SONG_HEADER_SIZE + assert offsets[0] > SONG_HEADER_SIZE assert all(offset < len(block) for offset in offsets) - def test_the_streams_stand_in_channel_order( + def test_the_streams_stand_in_plane_order( self, exported: bytes, driver_image: DriverImage, diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py index 8c0fe139b..f6f30e904 100644 --- a/tests/integration/nsf/test_song_export.py +++ b/tests/integration/nsf/test_song_export.py @@ -1,3 +1,5 @@ +import struct + import pytest from sampletones_core.project.project import Project @@ -6,14 +8,17 @@ from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song -from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_player.specification.song import ( + SONG_HEADER_SIZE, + TOTAL_TICKS_OFFSET, +) from sampletones_shared.exceptions import SongTooLargeError from sampletones_shared.music import Tuning -from tests.integration.nsf.songs import ( - RECORD_BYTES_PER_TICK, - available_bytes, - lengthened, -) +from tests.integration.nsf.songs import RECORD_BYTES_PER_TICK, available_bytes + + +def read_word(data: bytes, offset: int) -> int: + return int(struct.unpack_from(" None: + """The block states the whole arrangement, in less room than a record a tick would take.""" block = song_to_bytes(project_song, available_bytes(driver_image)) - assert len(block) == SONG_HEADER_SIZE + RECORD_BYTES_PER_TICK * project_song.ticks + assert read_word(block, TOTAL_TICKS_OFFSET) == project_song.ticks + assert len(block) < SONG_HEADER_SIZE + RECORD_BYTES_PER_TICK * project_song.ticks class TestTheProgramAreaBoundsTheSong: - """Where a record per tick stops fitting behind the driver.""" + """A block outgrowing the room behind the driver is named rather than written short.""" - def test_a_song_outgrowing_the_program_area_is_refused( + def test_a_song_the_space_cannot_hold_is_refused( self, - integration_project: Project, + project_song: Song, driver_image: DriverImage, ) -> None: """The exporter names the overflow rather than writing a file the console truncates.""" - space = available_bytes(driver_image) - groove = SongTiming.from_project(integration_project).groove() - frames = space // (RECORD_BYTES_PER_TICK * groove.total_ticks) + 2 - song = song_from_project(lengthened(integration_project, frames), Tuning(), loop_tick=None) + block = song_to_bytes(project_song, available_bytes(driver_image)) with pytest.raises(SongTooLargeError): - song_to_bytes(song, space) + song_to_bytes(project_song, len(block) - 1) diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 169204d07..06d9c632b 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -15,10 +15,10 @@ from sampletones_player.export import NSFBackend from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE from sampletones_shared.music import Tuning +from tests.suite.player import varied_features NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 -MAX_VOLUME: Final[int] = 15 def outcome(results: List[Any]) -> Any: @@ -37,15 +37,8 @@ def console_backend_fixture() -> NSFBackend: def overlong_features(initial_pitch: int) -> Features: - """Envelopes running longer than the console's program area has room for.""" - return Features( - initial_pitch=initial_pitch, - volume=np.full(PROGRAM_SIZE, MAX_VOLUME, dtype=int), - arpeggio=np.zeros(PROGRAM_SIZE, dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=np.zeros(PROGRAM_SIZE, dtype=int), - ) + """Envelopes turning over at every tick for longer than the program area has room for.""" + return varied_features(PROGRAM_SIZE, initial_pitch, duty_cycle=True) def instrument_export(name: str, features: Features) -> InstrumentExport: diff --git a/tests/suite/player.py b/tests/suite/player.py index 2b564c11b..ae53b48f9 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -6,18 +6,28 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import DUTY_CYCLES from sampletones_core.exporters import Features from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule -from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.encode import emit +from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.streams import ChannelStreams from sampletones_player.registers.triangle import TriangleRegisters from sampletones_player.song import Song +from sampletones_player.specification.compression import ( + MAX_LITERAL_BYTES, + PLANE_COUNT, +) from sampletones_player.specification.registers import ( DUTY_CYCLE_SHIFT, MAX_REGISTER_VALUE, @@ -139,6 +149,35 @@ def silent_pulse() -> PulseInstruction: return PulseInstruction.null_instruction() +PLAYER_VARIED_SEED: Final[int] = 7 + + +def spelled_song(ticks: int, nes_frequency: int) -> Song: + """A song whose every plane spells its values out, which is the most room a song can take. + + A block reaching past what the console holds is what a refusal is measured on, and a plane + the codec finds nothing in is where a song takes most room: one byte a tick and an opcode + every sixty-four. Building the streams outright states that shape exactly. + """ + values = bytes(tick % PITCH_COUNT for tick in range(ticks)) + stream = emit( + [ + LiteralToken(values=values[start : start + MAX_LITERAL_BYTES]) + for start in range(0, len(values), MAX_LITERAL_BYTES) + ] + ) + return Song( + planes=CompressedPlanes( + phrases=PhraseTable(phrases=()), + streams=PlaneOrder.across((stream,) * PLANE_COUNT), + ticks=ticks, + ), + pitches=PLAYER_PITCHES, + schedule=PlaySchedule.from_parameters(nes_frequency), + loop_tick=None, + ) + + PLAYER_APPROXIMATION_SAMPLES: Final[int] = 64 @@ -180,6 +219,28 @@ def player_features( ) +def varied_features( + frames: int, + pitch: int, + *, + duty_cycle: bool, +) -> Features: + """Envelopes turning over at every tick, the shape a plane holds least to repeat. + + A song outgrowing the console is a song the codec finds little in, so the envelopes are + drawn at random from a stated seed: the same shape every run, and one that repeats nowhere. + """ + generator = np.random.default_rng(PLAYER_VARIED_SEED) + return Features( + initial_pitch=pitch, + volume=generator.integers(0, PLAYER_FULL_VOLUME + 1, frames), + arpeggio=generator.integers(-OCTAVE_SEMITONES, OCTAVE_SEMITONES + 1, frames), + pitch=None, + hi_pitch=None, + duty_cycle=generator.integers(0, len(DUTY_CYCLES), frames) if duty_cycle else None, + ) + + def player_instrument( name: str, channel: ChannelName, diff --git a/tests/unit/sampletones_player/compression/test_admit.py b/tests/unit/sampletones_player/compression/test_admit.py new file mode 100644 index 000000000..d54b4b2c3 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_admit.py @@ -0,0 +1,125 @@ +import logging +from dataclasses import replace +from typing import Final, List, Tuple + +import pytest + +from sampletones_player.compression.admit import admit_seeds +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.matches.cache import MatchCache +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.options import EVERY_LAYER +from sampletones_player.compression.parse.result import Parse +from sampletones_player.compression.parse.song import parse_planes +from sampletones_player.compression.progress.monitor import CodecMonitor +from sampletones_player.compression.progress.report import SILENT_REPORTER +from sampletones_player.specification.compression import BYTE_VALUES, MAX_PHRASE_IDS + +STREAM_START: Final[frozenset] = frozenset({0}) +LEANED_ON: Final[bytes] = b"\x10\x18\x14\x22\x1c\x30\x11\x19\x15\x23\x1d\x31\x12\x1a\x16\x24" +PLAYED_SELDOM: Final[bytes] = b"\x60\x63\x67\x6c\x72\x79\x61\x64\x68\x6d\x73\x7a\x62\x65\x69\x6e" +LEANED_ON_REPEATS: Final[int] = 12 +SELDOM_REPEATS: Final[int] = 4 +RESTING_TICKS: Final[int] = 20 +UNPLAYED_STEPS: Final[Tuple[int, ...]] = (0x40, 0x50) +UNPLAYED_PER_STEP: Final[int] = 160 +UNRELATED: Final[bytes] = b"\x77\x77\x77" + +PLANE: Final[bytes] = LEANED_ON * LEANED_ON_REPEATS + PLAYED_SELDOM * SELDOM_REPEATS + bytes(RESTING_TICKS) + + +def unplayed_seeds() -> Tuple[Phrase, ...]: + """Phrases whose shape the plane holds nowhere, so none of them pays anything.""" + seeds: List[Phrase] = [] + for step in UNPLAYED_STEPS: + for value in range(UNPLAYED_PER_STEP): + seeds.append( + Phrase( + body=bytes( + [ + value, + (value + step) % BYTE_VALUES, + (value + 2 * step) % BYTE_VALUES, + ] + ) + ) + ) + + return tuple(seeds) + + +@pytest.fixture(name="cache") +def cache_fixture() -> MatchCache: + return MatchCache([PlaneIndex.from_plane(PLANE)]) + + +@pytest.fixture(name="baseline") +def baseline_fixture(cache: MatchCache) -> Tuple[Parse, ...]: + """The reading the plane takes when its tokens name no phrase at all.""" + return parse_planes( + cache, + phrase_table(()), + replace(EVERY_LAYER, phrases=False), + STREAM_START, + CodecMonitor(SILENT_REPORTER), + ) + + +class TestSeedsTheDictionaryHasRoomFor: + """Where every seed fits, every seed is offered and the settling decides what stays.""" + + def test_every_seed_is_taken(self, cache: MatchCache, baseline: Tuple[Parse, ...]) -> None: + seeds = (Phrase(body=LEANED_ON), Phrase(body=UNRELATED)) + table = admit_seeds(cache, seeds, baseline, EVERY_LAYER) + assert table.phrases == seeds + + def test_a_shape_offered_twice_is_held_once(self, cache: MatchCache, baseline: Tuple[Parse, ...]) -> None: + seed = Phrase(body=LEANED_ON) + table = admit_seeds(cache, (seed, seed), baseline, EVERY_LAYER) + assert table.phrases == (seed,) + + +class TestSeedsBeyondTheIdsATokenReaches: + """Where the instruments offer more shapes than a token can name, payment decides.""" + + def test_the_table_holds_the_ids_a_token_reaches( + self, + cache: MatchCache, + baseline: Tuple[Parse, ...], + ) -> None: + table = admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER) + assert len(table) == MAX_PHRASE_IDS + + def test_the_shape_the_plane_leans_on_is_kept( + self, + cache: MatchCache, + baseline: Tuple[Parse, ...], + ) -> None: + table = admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER) + assert Phrase(body=LEANED_ON) in table.phrases + + def test_the_shapes_the_plane_plays_outrank_the_ones_it_never_plays( + self, + cache: MatchCache, + baseline: Tuple[Parse, ...], + ) -> None: + """The plane leans on one shape and reaches for another seldom, in that order.""" + table = admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER) + assert [table[0].body, table[1].body] == [LEANED_ON, PLAYED_SELDOM] + + def test_the_run_says_the_table_could_not_take_them_all( + self, + cache: MatchCache, + baseline: Tuple[Parse, ...], + caplog: pytest.LogCaptureFixture, + ) -> None: + with caplog.at_level(logging.WARNING): + admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER) + + assert str(MAX_PHRASE_IDS) in caplog.text + + +def crowded_seeds() -> Tuple[Phrase, ...]: + """More shapes than a token can name, two of which the plane actually plays.""" + return (*unplayed_seeds(), Phrase(body=LEANED_ON), Phrase(body=PLAYED_SELDOM)) diff --git a/tests/unit/sampletones_player/compression/test_entries.py b/tests/unit/sampletones_player/compression/test_entries.py new file mode 100644 index 000000000..282916a6b --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_entries.py @@ -0,0 +1,42 @@ +from typing import Final + +import pytest + +from sampletones_player.compression.encode import emit +from sampletones_player.compression.entries import stream_entry +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken + +FIRST_TICK: Final[int] = 0 +PHRASE_ID: Final[int] = 2 +SHIFT: Final[int] = 7 + + +class TestWhereAStreamIsReEntered: + """A song coming round resumes at a byte, and the walk over the tokens finds it.""" + + def test_the_first_tick_is_the_streams_own_first_byte(self) -> None: + stream = emit([LiteralToken(values=b"\x01\x02"), HoldToken(ticks=3)]) + assert stream_entry(stream, FIRST_TICK) == 0 + + def test_a_tick_a_token_starts_answers_with_that_tokens_byte(self) -> None: + literal = LiteralToken(values=b"\x01\x02") + stream = emit([literal, HoldToken(ticks=3), PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=0)]) + assert stream_entry(stream, literal.ticks) == literal.size + + def test_a_tick_further_in_walks_past_every_token_before_it(self) -> None: + tokens = [ + LiteralToken(values=b"\x01\x02"), + HoldToken(ticks=3), + PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=SHIFT), + ] + stream = emit(tokens) + covered = sum(token.ticks for token in tokens[:2]) + assert stream_entry(stream, covered) == sum(token.size for token in tokens[:2]) + + def test_a_tick_a_token_spans_is_refused(self) -> None: + """A stream re-entered mid-token would leave the driver reading operands as opcodes.""" + stream = emit([HoldToken(ticks=8)]) + with pytest.raises(ValueError, match="spans tick"): + stream_entry(stream, 3) diff --git a/tests/unit/sampletones_player/compression/tokens/test_span.py b/tests/unit/sampletones_player/compression/tokens/test_span.py new file mode 100644 index 000000000..6420af0ef --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_span.py @@ -0,0 +1,128 @@ +from dataclasses import dataclass +from typing import Final + +import pytest + +from sampletones_player.compression.encode import emit +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.compression.tokens.phrase import PhraseToken +from sampletones_player.compression.tokens.span import TokenSpan, token_span +from sampletones_player.compression.tokens.types import TokenUnion +from sampletones_player.specification.compression import ( + MAX_HOLD_TICKS, + MAX_PHRASE_TICKS, + PHRASE_ID_ESCAPE, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +FIRST_TOKEN: Final[int] = 0 +CHEAP_ID: Final[int] = 1 +SHIFT: Final[int] = 5 +PHRASE_PLAY_TICKS: Final[int] = 10 + + +class TestWhatAWrittenTokenTakesAndCovers(BaseTestSuite): + """A stream is walked by its opcodes alone, so each token states its own bytes and ticks.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: TokenSpan + name: str + token: TokenUnion + + @property + def label(self) -> str: + return self.name + + test_cases = ( + TestCase(name="hold", token=HoldToken(ticks=7), expected=TokenSpan(size=1, ticks=7)), + TestCase( + name="hold-longest", + token=HoldToken(ticks=MAX_HOLD_TICKS), + expected=TokenSpan(size=1, ticks=MAX_HOLD_TICKS), + ), + TestCase( + name="literal", + token=LiteralToken(values=b"\x01\x02\x03"), + expected=TokenSpan(size=4, ticks=3), + ), + TestCase( + name="phrase", + token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=0), + expected=TokenSpan(size=2, ticks=PHRASE_PLAY_TICKS), + ), + TestCase( + name="phrase-shifted", + token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=SHIFT), + expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS), + ), + TestCase( + name="phrase-escaped", + token=PhraseToken(phrase_id=PHRASE_ID_ESCAPE, ticks=PHRASE_PLAY_TICKS, transpose=0), + expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS), + ), + TestCase( + name="phrase-escaped-shifted", + token=PhraseToken( + phrase_id=PHRASE_ID_ESCAPE, + ticks=PHRASE_PLAY_TICKS, + transpose=SHIFT, + ), + expected=TokenSpan(size=4, ticks=PHRASE_PLAY_TICKS), + ), + TestCase( + name="phrase-longest", + token=PhraseToken(phrase_id=CHEAP_ID, ticks=MAX_PHRASE_TICKS, transpose=0), + expected=TokenSpan(size=2, ticks=MAX_PHRASE_TICKS), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_span_states_what_the_token_takes(self, test_case: TestCase) -> None: + span = token_span(emit([test_case.token]), FIRST_TOKEN) + assert span.size == test_case.expected.size + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_span_states_what_the_token_covers(self, test_case: TestCase) -> None: + span = token_span(emit([test_case.token]), FIRST_TOKEN) + assert span.ticks == test_case.expected.ticks + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_span_reaches_the_byte_the_next_token_begins_at(self, test_case: TestCase) -> None: + stream = emit([test_case.token, HoldToken(ticks=1)]) + assert token_span(stream, FIRST_TOKEN).size == len(stream) - 1 + + +class TestWalkingAStream: + """The spans of a stream's tokens sum to the stream itself.""" + + def test_the_sizes_reach_the_streams_last_byte(self) -> None: + tokens = ( + LiteralToken(values=b"\x01\x02"), + HoldToken(ticks=3), + PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=SHIFT), + ) + stream = emit(tokens) + position = 0 + for _ in tokens: + position += token_span(stream, position).size + + assert position == len(stream) + + def test_the_ticks_reach_the_songs_length(self) -> None: + tokens = ( + LiteralToken(values=b"\x01\x02"), + HoldToken(ticks=3), + PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=0), + ) + stream = emit(tokens) + position = 0 + covered = 0 + for _ in tokens: + span = token_span(stream, position) + covered += span.ticks + position += span.size + + assert covered == 2 + 3 + 4 diff --git a/tests/unit/sampletones_player/driver/test_song_include.py b/tests/unit/sampletones_player/driver/test_song_include.py new file mode 100644 index 000000000..f5d5c9a04 --- /dev/null +++ b/tests/unit/sampletones_player/driver/test_song_include.py @@ -0,0 +1,147 @@ +import ast +from pathlib import Path +from typing import Dict, Final + +import pytest + +from sampletones_player.compression.pitch import PITCH_COUNT +from sampletones_player.driver.assembler.layout import INCLUDE_DIRECTORY +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import ( + OPCODE_SIZE, + PHRASE_ID_ESCAPE, + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_COUNT_SIZE, + PHRASE_TABLE_ENTRY_SIZE, + PLANE_COUNT, + PLANE_STATE_SIZE, + TOKEN_OPERAND_MASK, + TOKEN_TAG_MASK, + TokenTag, +) +from sampletones_player.specification.song import ( + LOOP_ENTRIES_OFFSET, + LOOP_TICK_OFFSET, + NO_LOOP, + PHRASE_TABLE_OFFSET, + STEP_FRACTION_OFFSET, + STEP_WHOLE_OFFSET, + STREAM_OFFSETS_OFFSET, + TIMER_TABLE_OFFSET, + TOTAL_TICKS_OFFSET, +) + +SONG_INCLUDE: Final[str] = "song.inc" +HEXADECIMAL_MARKER: Final[str] = "$" +HEXADECIMAL_PREFIX: Final[str] = "0x" +ASSIGNMENT: Final[str] = "=" + +STATED: Final[Dict[str, int]] = { + "WORD_SIZE": WORD_SIZE, + "PLANE_COUNT": PLANE_COUNT, + "STEP_WHOLE_OFFSET": STEP_WHOLE_OFFSET, + "STEP_FRACTION_OFFSET": STEP_FRACTION_OFFSET, + "TOTAL_TICKS_OFFSET": TOTAL_TICKS_OFFSET, + "LOOP_TICK_OFFSET": LOOP_TICK_OFFSET, + "TIMER_TABLE_OFFSET": TIMER_TABLE_OFFSET, + "PHRASE_TABLE_OFFSET": PHRASE_TABLE_OFFSET, + "STREAM_OFFSETS_OFFSET": STREAM_OFFSETS_OFFSET, + "LOOP_ENTRIES_OFFSET": LOOP_ENTRIES_OFFSET, + "NO_LOOP": NO_LOOP, + "PITCH_COUNT": PITCH_COUNT, + "TOKEN_TAG_MASK": TOKEN_TAG_MASK, + "TOKEN_OPERAND_MASK": TOKEN_OPERAND_MASK, + "TAG_HOLD": TokenTag.HOLD, + "TAG_LITERAL": TokenTag.LITERAL, + "TAG_PHRASE": TokenTag.PHRASE, + "TAG_TRANSPOSED_PHRASE": TokenTag.TRANSPOSED_PHRASE, + "OPCODE_SIZE": OPCODE_SIZE, + "PHRASE_ID_ESCAPE": PHRASE_ID_ESCAPE, + "PHRASE_TABLE_COUNT_SIZE": PHRASE_TABLE_COUNT_SIZE, + "PHRASE_TABLE_ENTRY_SIZE": PHRASE_TABLE_ENTRY_SIZE, + "PHRASE_LENGTH_SIZE": PHRASE_LENGTH_SIZE, + "PLANE_STATE_SIZE": PLANE_STATE_SIZE, + "PLANE_STATE_BYTES": PLANE_COUNT * PLANE_STATE_SIZE, +} + + +def _value(node: ast.expr, defined: Dict[str, int]) -> int: + """The number an equate's expression comes to, over the equates before it.""" + match node: + case ast.Constant(value=int() as number): + return number + case ast.Name(id=name): + return defined[name] + case ast.BinOp(left=left, op=ast.Add(), right=right): + return _value(left, defined) + _value(right, defined) + case ast.BinOp(left=left, op=ast.Mult(), right=right): + return _value(left, defined) * _value(right, defined) + + raise ValueError(f"an equate reads {ast.dump(node)}, which the include holds no form for") + + +def read_equates(path: Path) -> Dict[str, int]: + """Reads the constants an assembly include states, each over the ones stated before it. + + The driver and the exporter read one song block, so what the assembly believes about the + layout is held against what the specification states. An include line is ``NAME = value``, + where the value is a number, another equate, or the two joined by an addition or a product. + + Args: + path: The include file to read. + + Returns: + Dict[str, int]: The value each equate comes to. + """ + defined: Dict[str, int] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if ASSIGNMENT not in line: + continue + + name, expression = line.split(ASSIGNMENT, 1) + parsed = ast.parse(expression.strip().replace(HEXADECIMAL_MARKER, HEXADECIMAL_PREFIX), mode="eval") + defined[name.strip()] = _value(parsed.body, defined) + + return defined + + +@pytest.fixture(name="equates", scope="module") +def equates_fixture() -> Dict[str, int]: + return read_equates(INCLUDE_DIRECTORY / SONG_INCLUDE) + + +class TestTheDriverReadsTheBlockTheExporterWrites: + """Every figure the assembly reads the song block by, held against the specification.""" + + @pytest.mark.parametrize("name", sorted(STATED), ids=sorted(STATED)) + def test_the_include_states_what_the_specification_states( + self, + name: str, + equates: Dict[str, int], + ) -> None: + assert equates[name] == STATED[name] + + def test_the_plane_state_fields_fill_the_block_each_plane_holds( + self, + equates: Dict[str, int], + ) -> None: + """A plane's decoder state is read by field, so the fields cover the block and no more.""" + fields = ("PLANE_SOURCE", "PLANE_PHRASE", "PLANE_PHRASE_TICKS", "PLANE_TOKEN_TICKS") + stated = ("PLANE_VALUE", "PLANE_SHIFT") + offsets = [equates[field] for field in (*fields, *stated)] + assert offsets == sorted(offsets) + assert max(offsets) < equates["PLANE_STATE_SIZE"] + + def test_every_plane_is_named_at_its_own_state_block(self, equates: Dict[str, int]) -> None: + planes = ( + "PULSE1_CONTROL_PLANE", + "PULSE1_VALUE_PLANE", + "PULSE2_CONTROL_PLANE", + "PULSE2_VALUE_PLANE", + "TRIANGLE_CONTROL_PLANE", + "TRIANGLE_VALUE_PLANE", + "NOISE_CONTROL_PLANE", + "NOISE_VALUE_PLANE", + ) + expected = [plane * equates["PLANE_STATE_SIZE"] for plane in range(PLANE_COUNT)] + assert [equates[plane] for plane in planes] == expected diff --git a/tests/unit/sampletones_player/nsf/test_file.py b/tests/unit/sampletones_player/nsf/test_file.py index 80d1d4635..391a00774 100644 --- a/tests/unit/sampletones_player/nsf/test_file.py +++ b/tests/unit/sampletones_player/nsf/test_file.py @@ -23,14 +23,26 @@ player_song, pulse_tick, resting_streams, + spelled_song, ) NTSC_FREQUENCY: Final[int] = 60 FILENAME: Final[str] = "song.nsf" -INFORMATION: Final[NSFInformation] = NSFInformation(title="Amen", artist="Jakim") +INFORMATION: Final[NSFInformation] = NSFInformation( + title="Amen", + artist="Author", +) -SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) -RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +SOUNDING: Final = pulse_tick( + PLAYER_FULL_VOLUME, + 0, + PLAYER_REFERENCE_TIMER, +) +RESTING: Final = pulse_tick( + PLAYER_SILENT_VOLUME, + 0, + PLAYER_REFERENCE_TIMER, +) @pytest.fixture(scope="module") @@ -40,32 +52,64 @@ def image() -> DriverImage: @pytest.fixture def song() -> Song: - return player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) + return player_song( + resting_streams((SOUNDING, RESTING)), + NTSC_FREQUENCY, + loop_tick=None, + ) def oversized_song(image: DriverImage) -> Song: - ticks = PROGRAM_SIZE - len(image.code) - return player_song(resting_streams((SOUNDING,) * ticks), NTSC_FREQUENCY, loop_tick=None) + return spelled_song( + PROGRAM_SIZE - len(image.code), + NTSC_FREQUENCY, + ) class TestNSFBytes: """The three parts a console loads, in the order it loads them.""" - def test_the_file_leads_with_its_header(self, song: Song, image: DriverImage) -> None: - assert nsf_to_bytes(song, INFORMATION, image)[:HEADER_SIZE] == header_to_bytes(INFORMATION, image.addresses) + def test_the_file_leads_with_its_header( + self, + song: Song, + image: DriverImage, + ) -> None: + assert nsf_to_bytes(song, INFORMATION, image)[:HEADER_SIZE] == header_to_bytes( + INFORMATION, + image.addresses, + ) - def test_the_driver_follows_the_header(self, song: Song, image: DriverImage) -> None: + def test_the_driver_follows_the_header( + self, + song: Song, + image: DriverImage, + ) -> None: assert nsf_to_bytes(song, INFORMATION, image)[HEADER_SIZE : HEADER_SIZE + len(image.code)] == image.code - def test_the_song_follows_the_driver(self, song: Song, image: DriverImage) -> None: + def test_the_song_follows_the_driver( + self, + song: Song, + image: DriverImage, + ) -> None: data = nsf_to_bytes(song, INFORMATION, image) - assert data[HEADER_SIZE + len(image.code) :] == song_to_bytes(song, PROGRAM_SIZE - len(image.code)) + assert data[HEADER_SIZE + len(image.code) :] == song_to_bytes( + song, + PROGRAM_SIZE - len(image.code), + ) - def test_the_file_is_its_three_parts_and_nothing_more(self, song: Song, image: DriverImage) -> None: + def test_the_file_is_its_three_parts_and_nothing_more( + self, + song: Song, + image: DriverImage, + ) -> None: block = song_to_bytes(song, PROGRAM_SIZE - len(image.code)) assert len(nsf_to_bytes(song, INFORMATION, image)) == HEADER_SIZE + len(image.code) + len(block) - def test_the_loaded_image_fits_the_program_area(self, song: Song, image: DriverImage) -> None: + def test_the_loaded_image_fits_the_program_area( + self, + song: Song, + image: DriverImage, + ) -> None: assert len(nsf_to_bytes(song, INFORMATION, image)) - HEADER_SIZE <= PROGRAM_SIZE def test_the_header_loads_the_image_where_the_driver_expects_it( @@ -90,9 +134,16 @@ def test_the_song_lands_at_the_address_the_driver_reads_it_from( class TestSongsBeyondTheProgramArea: """A song outgrowing the room behind the driver names the overflow.""" - def test_a_song_too_large_for_the_program_area_raises(self, image: DriverImage) -> None: + def test_a_song_too_large_for_the_program_area_raises( + self, + image: DriverImage, + ) -> None: with pytest.raises(SongTooLargeError): - nsf_to_bytes(oversized_song(image), INFORMATION, image) + nsf_to_bytes( + oversized_song(image), + INFORMATION, + image, + ) class TestWriteNSF: @@ -106,4 +157,8 @@ def test_the_file_holds_the_bytes_the_song_serialises_to( ) -> None: destination = tmp_path / FILENAME write_nsf(destination, song, INFORMATION, image) - assert destination.read_bytes() == nsf_to_bytes(song, INFORMATION, image) + assert destination.read_bytes() == nsf_to_bytes( + song, + INFORMATION, + image, + ) diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py new file mode 100644 index 000000000..c87bb2a99 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_layout.py @@ -0,0 +1,107 @@ +from typing import Final + +from sampletones_player.nsf.layout import SongLayout +from sampletones_player.song import Song +from sampletones_player.specification.compression import ( + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_COUNT_SIZE, + PHRASE_TABLE_ENTRY_SIZE, + PLANE_COUNT, +) +from sampletones_player.specification.song import SONG_HEADER_SIZE +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + player_song, + pulse_tick, + resting_streams, +) + +NTSC_FREQUENCY: Final[int] = 60 +LOOP_TICK: Final[int] = 2 +FIGURE_REPEATS: Final[int] = 8 + +SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) +RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) +OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + +FIGURE: Final = (SOUNDING, OCTAVE_UP, RESTING, OCTAVE_UP) + + +def figure_song(loop_tick: int) -> Song: + return player_song(resting_streams(FIGURE * FIGURE_REPEATS), NTSC_FREQUENCY, loop_tick=loop_tick) + + +class TestWhereEachPartOfTheBlockBegins: + """The parts follow one another in the order the block writes them, none of them overlapping.""" + + def test_the_timer_table_follows_the_header(self) -> None: + assert SongLayout.of(figure_song(LOOP_TICK)).timer_table == SONG_HEADER_SIZE + + def test_the_dictionary_follows_the_timer_table(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + assert layout.phrase_table == layout.timer_table + len(song.pitches.data) + + def test_the_first_body_follows_the_table_of_entries(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + entries = PHRASE_TABLE_COUNT_SIZE + PHRASE_TABLE_ENTRY_SIZE * len(song.planes.phrases) + assert layout.bodies[0] == layout.phrase_table + entries + + def test_each_body_follows_the_one_before_it(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + for phrase, offset, following in zip(song.planes.phrases.phrases, layout.bodies, layout.bodies[1:]): + assert following == offset + PHRASE_LENGTH_SIZE + phrase.length + + def test_the_first_stream_follows_the_last_body(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + last = song.planes.phrases[len(song.planes.phrases) - 1] + assert layout.streams[0] == layout.bodies[-1] + PHRASE_LENGTH_SIZE + last.length + + def test_each_stream_follows_the_one_before_it(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + for stream, offset, following in zip(song.planes.streams, layout.streams, layout.streams[1:]): + assert following == offset + len(stream) + + def test_the_block_ends_behind_the_last_stream(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + assert layout.size == layout.streams[-1] + len(song.planes.streams[-1]) + + +class TestWhereEachStreamIsReEntered: + """A loop entry stands inside the stream it belongs to, at the token its tick starts.""" + + def test_every_plane_states_an_entry(self) -> None: + assert len(SongLayout.of(figure_song(LOOP_TICK)).loop_entries) == PLANE_COUNT + + def test_an_entry_stands_at_the_token_the_loop_tick_starts(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + entered = song.planes.entries(LOOP_TICK) + assert layout.loop_entries == tuple(offset + entry for offset, entry in zip(layout.streams, entered)) + + def test_a_song_that_stops_re_enters_at_each_streams_own_start(self) -> None: + song = player_song(resting_streams(FIGURE * FIGURE_REPEATS), NTSC_FREQUENCY, loop_tick=None) + layout = SongLayout.of(song) + assert layout.loop_entries == layout.streams + + +class TestWhatTheBlockStates: + """Every offset written into the block is named, so an overflow says which part reached past.""" + + def test_every_part_of_the_block_is_named(self) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + expected = 2 + len(song.planes.phrases) + 2 * PLANE_COUNT + assert len(layout.stated) == expected + + def test_every_stated_offset_lies_inside_the_block(self) -> None: + layout = SongLayout.of(figure_song(LOOP_TICK)) + assert all(0 < offset < layout.size for _, offset in layout.stated) diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 1d6358bc6..052472205 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -4,18 +4,23 @@ import pytest -from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.nsf.layout import NAME_SEPARATOR, SongLayout from sampletones_player.nsf.song import song_to_bytes from sampletones_player.song import Song from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import PLANE_COUNT from sampletones_player.specification.song import ( + LOOP_ENTRIES_OFFSET, LOOP_TICK_OFFSET, - MAX_STREAM_OFFSET, + MAX_BLOCK_OFFSET, NO_LOOP, + PHRASE_TABLE_OFFSET, SONG_HEADER_SIZE, STEP_FRACTION_OFFSET, STEP_WHOLE_OFFSET, STREAM_OFFSETS_OFFSET, + TIMER_TABLE_OFFSET, TOTAL_TICKS_OFFSET, ) from sampletones_shared.exceptions import SongTooLargeError @@ -29,12 +34,13 @@ player_song, pulse_tick, resting_streams, + spelled_song, ) NTSC_FREQUENCY: Final[int] = 60 HALF_RATE_FREQUENCY: Final[int] = 30 PROGRAM_AREA_BYTES: Final[int] = 0x8000 -UNBOUNDED_SPACE: Final[int] = MAX_STREAM_OFFSET * len(ChannelName) +LOOP_TICK: Final[int] = 2 SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) @@ -45,37 +51,69 @@ def read_word(data: bytes, offset: int) -> int: return int(struct.unpack_from(" Tuple[int, ...]: + return tuple(read_word(data, offset + WORD_SIZE * field) for field in range(count)) + + def stream_offsets(data: bytes) -> Tuple[int, ...]: - return tuple(read_word(data, STREAM_OFFSETS_OFFSET + WORD_SIZE * channel) for channel in range(len(ChannelName))) + return read_words(data, STREAM_OFFSETS_OFFSET, PLANE_COUNT) + + +def loop_entries(data: bytes) -> Tuple[int, ...]: + return read_words(data, LOOP_ENTRIES_OFFSET, PLANE_COUNT) def two_tick_song(nes_frequency: int) -> Song: return player_song(resting_streams((SOUNDING, RESTING)), nes_frequency, loop_tick=None) +def repeating_song() -> Song: + return player_song( + resting_streams((SOUNDING, OCTAVE_UP, RESTING, SOUNDING)), + NTSC_FREQUENCY, + loop_tick=LOOP_TICK, + ) + + class TestSongBytes: """The exact bytes a hand-built song serialises to. The layout is the contract the driver reads the song through, so the literal states it in - full: a fifteen-byte header, then each channel's records back to back in channel order. + full: the header, the timer every pitch sounds at, the dictionary the tokens name, and the + eight token streams. The timer table is named rather than transcribed, since it is the + tuning's own table and the block carries whatever that table holds. """ - EXPECTED: Final[bytes] = ( - b"\x00\xca\x7f" + EXPECTED_HEADER: Final[bytes] = ( + b"\x00" + b"\xca\x7f" b"\x02\x00" b"\xff\xff" - b"\x0f\x00\x15\x00\x1b\x00\x21\x00" - b"\x3f\xfb\x01\x30\xfb\x01" - b"\x30\xfb\x01\x30\xfb\x01" - b"\x80\xfb\x01\x80\xfb\x01" - b"\x30\x0a\x30\x0a" + b"\x2b\x00" + b"\xfb\x00" + b"\xfc\x00\xff\x00\x02\x01\x05\x01\x08\x01\x0b\x01\x0e\x01\x11\x01" + b"\xfc\x00\xff\x00\x02\x01\x05\x01\x08\x01\x0b\x01\x0e\x01\x11\x01" + ) + + EXPECTED_STREAMS: Final[bytes] = ( + b"\x00" + b"\x41\x3f\x30" + b"\x40\x21\x00" + b"\x40\x30\x00" + b"\x40\x21\x00" + b"\x40\x80\x00" + b"\x40\x21\x00" + b"\x40\x30\x00" + b"\x40\x0a\x00" ) def test_the_song_serialises_to_the_expected_bytes(self) -> None: - assert song_to_bytes(two_tick_song(HALF_RATE_FREQUENCY), PROGRAM_AREA_BYTES) == self.EXPECTED + song = two_tick_song(HALF_RATE_FREQUENCY) + expected = self.EXPECTED_HEADER + song.pitches.data + self.EXPECTED_STREAMS + assert song_to_bytes(song, PROGRAM_AREA_BYTES) == expected - def test_the_streams_begin_where_the_header_ends(self) -> None: - assert stream_offsets(self.EXPECTED)[0] == SONG_HEADER_SIZE + def test_the_header_runs_to_the_length_the_offsets_are_read_at(self) -> None: + assert len(self.EXPECTED_HEADER) == SONG_HEADER_SIZE class TestSongHeader(BaseTestSuite): @@ -117,41 +155,97 @@ def test_a_song_that_stops_states_no_loop(self) -> None: assert read_word(data, LOOP_TICK_OFFSET) == NO_LOOP def test_a_song_that_repeats_states_its_loop_tick(self) -> None: - song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=1) + data = song_to_bytes(repeating_song(), PROGRAM_AREA_BYTES) + assert read_word(data, LOOP_TICK_OFFSET) == LOOP_TICK + + +class TestTimerTable: + """The timer every pitch sounds at, where the header says it lies.""" + + def test_the_table_begins_where_the_header_states(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) data = song_to_bytes(song, PROGRAM_AREA_BYTES) - assert read_word(data, LOOP_TICK_OFFSET) == 1 + offset = read_word(data, TIMER_TABLE_OFFSET) + assert data[offset : offset + len(song.pitches.data)] == song.pitches.data + + def test_the_table_follows_the_header(self) -> None: + data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) + assert read_word(data, TIMER_TABLE_OFFSET) == SONG_HEADER_SIZE + + +class TestPhraseTable: + """The dictionary the tokens name, counted and then reached through its own offsets.""" + + def test_the_table_states_how_many_phrases_it_holds(self) -> None: + song = repeating_song() + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + assert data[read_word(data, PHRASE_TABLE_OFFSET)] == len(song.planes.phrases) + + def test_each_entry_reaches_that_phrases_length_and_body(self) -> None: + song = spelled_phrase_song() + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + layout = SongLayout.of(song) + for phrase, offset in zip(song.planes.phrases.phrases, layout.bodies): + assert data[offset] == phrase.length + assert data[offset + 1 : offset + 1 + phrase.length] == phrase.body + + def test_the_entries_stand_where_the_table_states(self) -> None: + song = spelled_phrase_song() + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + table = read_word(data, PHRASE_TABLE_OFFSET) + stated = read_words(data, table + 1, len(song.planes.phrases)) + assert stated == SongLayout.of(song).bodies + + +def spelled_phrase_song() -> Song: + """A song whose figure repeats often enough for the dictionary to hold it.""" + figure = (SOUNDING, OCTAVE_UP, RESTING, OCTAVE_UP) + return player_song(resting_streams(figure * 8), NTSC_FREQUENCY, loop_tick=None) class TestStreamOffsets: - """Every channel's stream is found where the header says it is.""" + """Every plane's stream is found where the header says it is.""" - def test_the_first_stream_begins_past_the_header(self) -> None: - data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) - assert stream_offsets(data)[0] == SONG_HEADER_SIZE + def test_the_first_stream_begins_past_the_dictionary(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + assert stream_offsets(data)[0] == SongLayout.of(song).streams[0] - def test_the_offsets_ascend_in_channel_order(self) -> None: + def test_the_offsets_ascend_in_plane_order(self) -> None: data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) offsets = stream_offsets(data) assert list(offsets) == sorted(offsets) - def test_each_offset_lands_on_that_channels_first_record(self) -> None: + def test_each_offset_lands_on_that_planes_first_token(self) -> None: song = two_tick_song(NTSC_FREQUENCY) data = song_to_bytes(song, PROGRAM_AREA_BYTES) - for offset, stream in zip(stream_offsets(data), song.streams.padded): - assert tuple(data[offset : offset + len(stream[0].values)]) == stream[0].values + for offset, stream in zip(stream_offsets(data), song.planes.streams): + assert data[offset : offset + len(stream)] == stream def test_the_streams_fill_the_song_to_its_last_byte(self) -> None: song = two_tick_song(NTSC_FREQUENCY) data = song_to_bytes(song, PROGRAM_AREA_BYTES) - records = sum(len(registers.values) for stream in song.streams.padded for registers in stream) - assert len(data) == SONG_HEADER_SIZE + records + assert len(data) == stream_offsets(data)[-1] + len(song.planes.streams[-1]) - def test_a_shorter_channel_is_written_to_the_songs_length(self) -> None: - song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING)), NTSC_FREQUENCY, loop_tick=None) + +class TestLoopEntries: + """Where each plane's stream is re-entered once the song repeats.""" + + def test_a_song_that_repeats_states_the_token_its_loop_tick_starts(self) -> None: + song = repeating_song() data = song_to_bytes(song, PROGRAM_AREA_BYTES) - offsets = stream_offsets(data) - noise_bytes = data[offsets[3] :] - assert noise_bytes == bytes(song.streams.noise[0].values) * song.ticks + entered = song.planes.entries(LOOP_TICK) + assert loop_entries(data) == tuple(offset + entry for offset, entry in zip(stream_offsets(data), entered)) + + def test_a_song_that_stops_re_enters_at_its_own_first_token(self) -> None: + data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES) + assert loop_entries(data) == stream_offsets(data) + + def test_every_entry_lands_on_a_token_the_stream_holds(self) -> None: + song = repeating_song() + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + for entry, offset, stream in zip(loop_entries(data), stream_offsets(data), song.planes.streams): + assert offset <= entry < offset + len(stream) class TestSongTooLarge: @@ -168,8 +262,11 @@ def test_a_song_filling_the_available_space_exactly_is_written(self) -> None: data = song_to_bytes(song, PROGRAM_AREA_BYTES) assert song_to_bytes(song, len(data)) == data - def test_a_song_reaching_past_the_offset_field_raises(self) -> None: - ticks = MAX_STREAM_OFFSET // len(SOUNDING.values) + 1 - song = player_song(resting_streams((SOUNDING,) * ticks), NTSC_FREQUENCY, loop_tick=None) - with pytest.raises(SongTooLargeError, match=ChannelName.PULSE2.value): - song_to_bytes(song, UNBOUNDED_SPACE) + def test_a_song_reaching_past_the_offset_field_names_what_overflowed(self) -> None: + """A block given exactly the room it takes is still refused where an offset overflows.""" + song = spelled_song(MAX_BLOCK_OFFSET, NTSC_FREQUENCY) + with pytest.raises(SongTooLargeError) as overflow: + song_to_bytes(song, SongLayout.of(song).size) + + named = [plane.replace(NAME_SEPARATOR, " ") for plane in PlaneOrder.names()] + assert any(plane in str(overflow.value) for plane in named) diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py index 66ee37993..ddfb4c211 100644 --- a/tests/unit/sampletones_player/test_export.py +++ b/tests/unit/sampletones_player/test_export.py @@ -7,7 +7,11 @@ from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.progress import ExportProgress -from sampletones_core.exports.request import InstrumentExport, ProjectExport +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) from sampletones_core.exports.scope import ExportScope from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project @@ -27,13 +31,14 @@ player_features, player_instrument, player_sample, + varied_features, ) from tests.suite.progress import RecordingReporter, reported_stages NTSC_FREQUENCY: Final[int] = 60 SOUNDING_TICKS: Final[int] = 8 BASS_PITCH: Final[int] = 45 -OVERLONG_TICKS: Final[int] = PROGRAM_SIZE +OVERLONG_TICKS: Final[int] = 8192 FILENAME: Final[str] = "reconstruction.nsf" SAMPLE_NAME: Final[str] = "Amen" PROJECT_TITLE: Final[str] = "Demo" @@ -51,6 +56,37 @@ def lead_slice(name: str, frames: int) -> InstrumentExport: ) +def overlong_sample() -> SampleExport: + """A reconstruction whose channels turn over at every tick, so its song outgrows the console.""" + return player_sample( + SAMPLE_NAME, + ( + player_instrument( + "lead", + ChannelName.PULSE1, + varied_features(OVERLONG_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + player_instrument( + "harmony", + ChannelName.PULSE2, + varied_features(OVERLONG_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + player_instrument( + "bass", + ChannelName.TRIANGLE, + varied_features(OVERLONG_TICKS, BASS_PITCH, duty_cycle=False), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + ), + nes_frequency=NTSC_FREQUENCY, + ) + + def bass_slice(name: str, frames: int) -> InstrumentExport: return player_instrument( name, @@ -143,9 +179,8 @@ def test_a_reconstruction_outgrowing_the_program_area_reports_its_size( tmp_path: Path, ) -> None: destination = tmp_path / FILENAME - request = player_sample(SAMPLE_NAME, (lead_slice("lead", OVERLONG_TICKS),), nes_frequency=NTSC_FREQUENCY) with pytest.raises(SongTooLargeError): - backend.write_sample(destination, request) + backend.write_sample(destination, overlong_sample()) class TestWriteInstrument: From e54ddea7fdb704a498a480424d62c7cf6239b37d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 00:16:23 +0200 Subject: [PATCH 067/142] Fixed: wrong theme of a disabled remove button --- .../ui/themes/dpg_constants.py | 2 + .../ui/themes/loader.py | 13 +++- .../ui/themes/theme.py | 9 +++ src/sampletones_config/palettes/dark.yaml | 1 + src/sampletones_config/palettes/light.yaml | 1 + src/sampletones_config/palettes/studio.yaml | 1 + .../theme/button/danger.yaml | 15 ++++ .../ui/themes/test_loader.py | 70 ++++++++++++++++++- 8 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index 5eed2b854..a9871b817 100644 --- a/src/sampletones_application/ui/themes/dpg_constants.py +++ b/src/sampletones_application/ui/themes/dpg_constants.py @@ -2,6 +2,8 @@ import dearpygui.dearpygui as dpg +EVERY_ITEM_TYPE: Final[int] = dpg.mvAll + ITEM_TYPE_MAP: Final[Dict[str, int]] = { "All": dpg.mvAll, "Button": dpg.mvButton, diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index 61ed75738..855e27834 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -5,6 +5,7 @@ CATEGORY_MAP, CORE_COLOR_MAP, CORE_STYLE_MAP, + EVERY_ITEM_TYPE, ITEM_TYPE_MAP, PLOTS_COLOR_MAP, PLOTS_STYLE_MAP, @@ -222,11 +223,21 @@ def _mirror_disabled_entries(entries: ThemeEntries) -> ThemeEntries: @staticmethod def _entries_to_items(entries: ThemeEntries) -> ThemeItems: + """Gathers the entries into the components a theme is built from, broadest first. + + DearPyGui fills an item's colours by walking a theme's components in the order they were + created, so the last one covering a colour is the one the item wears. A component naming a + single item type states what that type is meant to look like, and one naming every type + states the ground it stands on, so the ground is laid first and the item type paints over + it. Ordering them here keeps that true whichever order a theme and the theme it extends + happened to state them in, and whichever entries the disabled-state mirror added. + """ grouped: Dict[ThemeParameter, List[ThemeValue]] = {} for parameter, value in entries.values(): grouped.setdefault(parameter, []).append(value) - return ThemeItems(items=grouped) + ordered = sorted(grouped, key=lambda parameter: parameter.item_type != EVERY_ITEM_TYPE) + return ThemeItems(items={parameter: grouped[parameter] for parameter in ordered}) @classmethod def _check_for_cycles(cls, name_index: Dict[str, ThemeSpec]) -> None: diff --git a/src/sampletones_application/ui/themes/theme.py b/src/sampletones_application/ui/themes/theme.py index 05db24721..8742b7318 100644 --- a/src/sampletones_application/ui/themes/theme.py +++ b/src/sampletones_application/ui/themes/theme.py @@ -40,6 +40,15 @@ def _index(items: ThemeItems) -> ThemeDictionary: return dictionary + @property + def components(self) -> Tuple[ThemeParameter, ...]: + """The components the theme is built from, in the order DearPyGui fills an item from them. + + A later component covering a colour is the one the item wears, so the order states which + of two components naming the same colour has the final say. + """ + return tuple(self._items.items) + def create(self) -> None: """Builds the DearPyGui theme once, registering each colour item it fills. diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 8734723e1..e07fa2dfd 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -49,6 +49,7 @@ colors: danger_hover: "#b85850" danger_active: "#853830" on_danger: "#f2f2f4" + danger_disabled: "#4a3230" dialog_surface: "#2c2c30" dialog_title: "#333a44" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 1bb2f7b72..6b3a14ba5 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -49,6 +49,7 @@ colors: danger_hover: "#c33a30" danger_active: "#851f18" on_danger: "#ffffff" + danger_disabled: "#dfd0ce" dialog_surface: "#f2f4f8" dialog_title: "#c6cbd5" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 0539a6ef7..14e0aeda5 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -49,6 +49,7 @@ colors: danger_hover: "#bd6a6a" danger_active: "#8e4747" on_danger: "#f1f1f5" + danger_disabled: "#4d3d47" dialog_surface: "#313547" dialog_title: "#34405e" diff --git a/src/sampletones_config/theme/button/danger.yaml b/src/sampletones_config/theme/button/danger.yaml index f891d4944..180aee994 100644 --- a/src/sampletones_config/theme/button/danger.yaml +++ b/src/sampletones_config/theme/button/danger.yaml @@ -16,3 +16,18 @@ components: - type: color key: Text value: .on_danger + - item_type: Button + enabled: false + entries: + - type: color + key: Text + value: .text_disabled + - type: color + key: Button + value: .danger_disabled + - type: color + key: ButtonHovered + value: .danger_disabled + - type: color + key: ButtonActive + value: .danger_disabled diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py index 2dbdcf407..7e5418b31 100644 --- a/tests/unit/sampletones_application/ui/themes/test_loader.py +++ b/tests/unit/sampletones_application/ui/themes/test_loader.py @@ -5,9 +5,14 @@ import pytest from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY -from sampletones_application.tags.general import TAG_GLOBAL_THEME_DEFAULT +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_DEFAULT, +) +from sampletones_application.ui.themes.dpg_constants import EVERY_ITEM_TYPE from sampletones_application.ui.themes.loader import ThemeLoader from sampletones_application.ui.themes.spec import ThemeSpec +from sampletones_application.ui.themes.style import ThemeParameter from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.palette import Palette @@ -108,6 +113,28 @@ def test_a_theme_keeps_its_own_override_on_top_of_the_base(self, themes: Dict[st finally: dpg.destroy_context() + def test_the_danger_button_states_its_own_disabled_look(self, themes: Dict[str, Theme]) -> None: + """A button held back reads as held back. + + Every theme is completed for both states, so one stating only the tone it wears while it can + be pressed would wear that same tone once it is held back and read as a button that simply + does nothing. The danger button states the greyed look itself, which is what the stems list + relies on to show that its last row stays. + """ + dpg.create_context() + try: + danger = themes[TAG_GLOBAL_THEME_DANGER_BUTTON] + danger.create() + + pressable = danger.get_color(dpg.mvButton, dpg.mvThemeCol_Button) + held_back = danger.get_color(dpg.mvButton, dpg.mvThemeCol_Button, enabled_state=False) + + assert pressable is not None + assert held_back is not None + assert held_back != pressable + finally: + dpg.destroy_context() + def test_the_tracker_theme_stands_the_pattern_on_one_even_ground(self, themes: Dict[str, Theme]) -> None: """The tracker gives both stripes the same shade, leaving the row background free to carry the beat and bar grouping that tells the pattern's rows apart. @@ -126,6 +153,47 @@ def test_the_tracker_theme_stands_the_pattern_on_one_even_ground(self, themes: D dpg.destroy_context() +class TestComponentOrder: + """A theme lays its ground before it paints on it. + + DearPyGui fills an item from a theme's components in the order they were created, so the last + one covering a colour is the one the item wears. A component naming every item type is the + ground a theme stands on; one naming a single type states what that type is meant to look like, + and it only reaches the item if it comes after the ground. + """ + + @pytest.fixture + def themes(self) -> Dict[str, Theme]: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return {theme.tag: theme for theme in ThemeLoader(THEME_DIRECTORY, source).load_all()} + + def test_a_component_naming_every_type_comes_before_the_ones_naming_a_type( + self, + themes: Dict[str, Theme], + ) -> None: + for theme in themes.values(): + grounds = [ + index for index, component in enumerate(theme.components) if component.item_type == EVERY_ITEM_TYPE + ] + specifics = [ + index for index, component in enumerate(theme.components) if component.item_type != EVERY_ITEM_TYPE + ] + assert not grounds or not specifics or max(grounds) < min(specifics), theme.tag + + def test_the_danger_button_paints_its_disabled_look_over_the_ground( + self, + themes: Dict[str, Theme], + ) -> None: + """The mirror completes the ground for both states, so the button has to paint after it.""" + danger = themes[TAG_GLOBAL_THEME_DANGER_BUTTON] + held_back = ThemeParameter(item_type=dpg.mvButton, enabled_state=False) + ground = ThemeParameter(item_type=EVERY_ITEM_TYPE, enabled_state=False) + + components = list(danger.components) + + assert components.index(ground) < components.index(held_back) + + class TestDisabledStateMirroring: """Disabled-state completeness: DearPyGui resolves each item against the theme component matching the item's enabled state and re-applies its built-in palette From 1e9de48e8c7626c221f8bfe60c26066d85ab0272 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 00:42:21 +0200 Subject: [PATCH 068/142] Offered: a whole song as an NSF program --- docs/development/bugs-and-todos.md | 1 - docs/development/player.md | 160 ++++++++++++++++ docs/formats/nsf.md | 178 ++++++++++++++++++ docs/guide/sequencer.md | 6 + docs/index.md | 2 + .../categories/elements/global_.py | 4 + .../categories/elements/settings.py | 1 + .../categories/exports.py | 7 + .../utils/gui/shortcuts/ids.py | 2 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 5 + src/sampletones_core/performance/__init__.py | 10 + src/sampletones_core/performance/progress.py | 44 +++++ src/sampletones_core/performance/song.py | 21 ++- src/sampletones_core/project/tuning.py | 39 ++++ src/sampletones_player/builder.py | 23 ++- src/sampletones_player/export.py | 65 ++++++- tests/integration/nsf/corpus.py | 7 +- tests/integration/nsf/test_backend.py | 99 +++++++++- tests/integration/nsf/test_song_export.py | 3 +- .../services/test_export.py | 68 ++++++- tests/suite/performance.py | 13 ++ .../sampletones_core/performance/test_song.py | 42 ++++- .../sampletones_core/project/test_tuning.py | 63 +++++++ tests/unit/sampletones_player/test_builder.py | 14 +- tests/unit/sampletones_player/test_export.py | 150 ++++++++++++++- 27 files changed, 988 insertions(+), 41 deletions(-) create mode 100644 docs/development/player.md create mode 100644 docs/formats/nsf.md create mode 100644 src/sampletones_core/performance/progress.py create mode 100644 src/sampletones_core/project/tuning.py create mode 100644 tests/unit/sampletones_core/project/test_tuning.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index b5b2a61c7..0452391cf 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -27,7 +27,6 @@ * In-application guide/tutorial * Language selector -* NSF export ### Technical diff --git a/docs/development/player.md b/docs/development/player.md new file mode 100644 index 000000000..675bf8c46 --- /dev/null +++ b/docs/development/player.md @@ -0,0 +1,160 @@ +# The console player + +This document governs `sampletones_player`: the 6502 driver an exported `.nsf` carries, the +codec that fits a song into the console's program area, and the chain that holds both to +what the application plays. Read it before changing the assembly under +`driver/assembly/`, anything under `compression/`, or the way a song is built in +`builder.py`. The byte layout the two sides meet on is [the NSF format](../formats/nsf.md); +where the package sits among the others is [package layers](packages.md). + +Everything else _SampleToNES_ exports describes a song to a program that plays it. This one +**is** the program. That single difference sets the whole design: the file has to carry a +player, the player has to fit beside the song in 32 KB, and the song has to be decodable by +a processor that has no multiply. + +## Principles + +**The driver interprets nothing.** Which value silences a channel, how a duty cycle reaches +its bits, how a pitch becomes a period, how the linear counter is held — every one of those +is settled in Python, under `registers/`, where it is testable at a keystroke. What crosses +into assembly is moving bytes to addresses and counting ticks. A rule that would have to be +debugged on a 6502 is a rule in the wrong place. + +**What a correct driver writes is stated in Python, and the assembly is held to it.** +`RegisterTrace.from_song` says which APU registers a run touches, in what order, call by +call. The assembled driver is run on a 6502 emulator and its writes are compared against +that statement. The oracle is the contract; the assembly is an implementation of it, and +either one being wrong shows up as a difference rather than as a wrong sound. + +**A song is decoded forward, never indexed.** Reading a tick by multiplying its number +bought exactly two things: skipping several ticks in one play call, and jumping to the loop +point. The first is a matter of decoding several ticks in a row; the second the header can +state outright. Giving both up in exchange for compression is what turns a program area +that holds seconds into one that holds minutes. + +**The dictionary is the instrument table.** A song is built by playing samples at rows, so +the shapes its planes repeat are knowable rather than discoverable: each sample offers the +planes it writes, and every row playing it becomes a token naming that entry. Search fills +what the samples leave uncovered. + +**Every layer earns its place on measured ground.** Each stage of the codec can be switched +off on its own, and `make compression-report` writes what each one saves across a corpus of +songs. The format's constants are settled from that report rather than from argument. + +## The song a file carries + +`Song` is the compressed song: the dictionary, the eight token streams, the timer table, +the clock and the loop point. The register values every channel writes are read back out of +the streams on demand, so a trace, a writer and a test all speak to the compressed song +without knowing it is one. + +A song is built three ways, and the difference between them is only where the ticks come +from: `song_from_reconstruction` sounds a reconstruction's own instructions, +`song_from_sample` sounds the slices of an export request, and `song_from_project` plays a +whole arrangement out row by row through the same walk the sequencer sounds a song with. +The last of those is the one that seeds the dictionary from the project's samples. + +A project carries no tuning of its own — each sample was reconstructed against one — so the +samples state it by agreeing on it, and a project whose samples disagree is refused rather +than sounded half in tune. + +## The codec + +The codec turns the four channels' per-tick register values into the eight token streams the +driver reads, and back again. `compression/decode.py` is the golden model: every encoding is +held against it, so what the console plays and what the encoder meant are the same values. + +**Planes.** A channel's registers for one tick sit adjacent, which is exactly the +interleaving that destroys self-similarity — a volume envelope, a pitch line and a timbre +are unrelated series braided together. Split apart, each is a slowly-changing series of its +own. + +**A pitch index rather than a timer.** A tone channel's two timer bytes become one index +into a table the block carries. It saves a byte a tick directly, but the reason it matters +is that a timer cannot be transposed and an index can: the same figure played at several +pitches is several copies in timer space and one entry plus a shift in index space. + +**Tokens.** A plane is written as holds, literals and phrase plays — the encoding is in +[the format document](../formats/nsf.md#b4-the-token-streams). What matters here is that a +token's count is a duration rather than a length, so one dictionary entry serves a figure +however long it is held and whatever pitch it is played at. + +**The cheapest reading, not a greedy one.** A plane is parsed as a shortest path: every way +of covering a tick is an edge priced in the bytes its token takes, and the cheapest path +across the plane is its encoding. Costs are in the currency the program area is measured in, +so the parse optimises the thing that actually has to fit. + +**A phrase earns its entry.** Naming a phrase is not enough — an entry costs its own bytes. +Each is weighed by what it spares the streams against a reading of the song that names no +phrase at all, and the ones that fail to pay are dropped. Dropping them changes which ids +are cheap, so the table settles over a few rounds. + +**Matching is measured once.** What a phrase plays against a plane at a position follows +from the plane and the phrase alone, so it holds for every parse the encoder runs. Measuring +it once per phrase, rather than once per parse, is what keeps the encoder's cost proportional +to the dictionary rather than to the dictionary times the parses. + +A run reports what it holds as it goes and answers a caller that no longer wants it, in its +own vocabulary; the export backend translates that into the stages a user sees. The walk +through a project's song does the same, and it knows its length in advance, so it reads as a +true fraction of the song. + +## The driver + +The driver is three sources: the entry points and the play call in `driver.s`, the clock in +`clock.s`, and the eight plane decoders in `channels.s`. + +**The clock steps a tick at a time.** A play call adds the header's step to an accumulator +and reads the whole ticks off the top; the driver then moves the clock on by one tick at a +time, and each step answers what the channels are to do with it — play it, play it from the +loop entry, or leave the console alone because the song has ended. Nothing wraps +arithmetically: reaching the end either points the planes at where the song comes round or +finishes. + +**One routine plays a tick on every plane.** A plane's state carries where its next token +lies, where in a phrase body it stands, how much of that body is left, how much of the +current token is left, the value it last played, and the shift it is playing at. The three +kinds of token fold into that one shape — a hold is a phrase of no bytes, a literal is a +phrase whose bytes lie inline behind its opcode — so playing a tick is the same handful of +instructions whichever token is standing. + +**The plane's own state block is the whole of the dispatch.** Which plane is being advanced +is a base offset held in `X`, the way a channel's register base is, so eight decoders are +one routine called eight times. The state lives in zero page, well inside what the driver +leaves free, and the linker configuration keeps the two-segment memory model an NSF loads. + +## How it is verified + +The chain runs from the register values upward, and each link is held on its own: + +| Level | How | +|---|---| +| The codec is lossless | every encoding decodes to the planes it was written from, over a corpus | +| The codec is safe | a plane the codec finds nothing in stays within its literal bound | +| The ratio | `make compression-report` — bytes per tick and ticks that fit, per layer | +| The byte layout | a hand-built song serialises to expected bytes | +| The assembly agrees with the specification | the include's equates are read and compared field by field | +| The driver behaves | the assembled image on a 6502 emulator against `RegisterTrace.from_song`, over several rates and over songs that repeat | +| The audio | a captured trace re-rendered against the reconstruction's own approximation | +| The whole export | a project exported, played on the emulator, and read back as the instructions the sequencer sounds | +| Listening | `make nsf-samples` then `make nsf-render`, or any NSF player | +| Speed | `make benchmarks` — the encoder's own cost on the shapes that scale worst | + +The audio comparison is the one that catches a mistake the trace would let through: the +trace says the right registers were written, and the render says the result is the waveform +the reconstruction was built as. + +## Building the driver + +`make player` assembles the sources with cc65 and writes `driver/binary/driver.bin`, which +is committed beside them — exporting an `.nsf` needs no assembler, and the wheel carries the +binary alone. + +The link line names our own configuration and our own object files, with the CPU stated +outright. That is the guardrail that keeps the shipped image entirely ours: reaching for a +cc65 target or library would place that project's start-up code and runtime in the bytes the +package distributes. A build also holds the linker's own labels against the addresses the +exporter states without one, so the committed image and the header describing it cannot +drift apart. + +Installing cc65 is covered in [dependencies](dependencies.md). diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md new file mode 100644 index 000000000..6f915a1e0 --- /dev/null +++ b/docs/formats/nsf.md @@ -0,0 +1,178 @@ +# NSF export format + +This document is the reference for the `.nsf` files _SampleToNES_ writes: the file a +console or an NSF player loads, and the song block inside it that the player's own 6502 +driver reads. Read it before changing anything under `sampletones_player/nsf/`, +`sampletones_player/compression/`, or the assembly under `sampletones_player/driver/`. +The design behind the format — why a song is stored this way and how the driver is held +to it — is in [the player](../development/player.md); the layout itself is here. + +An `.nsf` is unlike the tracker exports beside it. A [FamiTracker](famitracker.md) or +[Bitphase](bitphase.md) file describes a song to a program that already knows how to play +one; an `.nsf` carries its own player. The file therefore holds three things: a header +naming where the program loads and which routines the console calls, the assembled driver, +and the song that driver plays. + +Every constant named here has a counterpart under +`sampletones_player/specification/`, and the assembly reads the same figures from +`driver/assembly/include/song.inc`. The two are held against each other by a test, so a +change made in one file and forgotten in the other is reported by name. + +## A. The file + +``` ++0 NSF header, 128 bytes ++128 the assembled driver, loaded at $8000 ++128 + driver length the song block, at the address the header states +``` + +The header is NSF version 1: the magic `NESM\x1a`, one song, the load, init and play +addresses, three 32-byte text fields, and the NTSC play period. The text fields carry the +name of what was exported, its author and the copyright. Written by `nsf/header.py`. + +The driver's entry points lead its image as a pair of jumps, so `init` answers at the load +address and `play` three bytes later whatever the driver's own length. That is what lets +the header state both addresses without assembling anything. The song follows the code +directly, which is the one address a build decides — `driver/addresses.py` reads it back +out of the linker's own labels. + +The console calls `init` once and then `play` once a video frame. The header asks for the +NTSC frame period, so a player honouring the field and one driving from the frame itself +run a song at the speed it was built at. + +**The program area is 32 KB**, from `$8000` upward, and the song block has whatever the +driver leaves of it. A song that outgrows that space is reported as an export failure +rather than written short. + +## B. The song block + +A song reaches the console as eight **token streams** — one per plane, two planes per +channel — decoded a tick at a time against a **dictionary** of phrases and a **timer +table** of pitches. Every offset below is a `uint16` counted from the block's own first +byte, so the whole block plays from wherever the file loads it. + +``` ++0 header ++43 timer table + phrase table: count, then one offset per phrase + phrase bodies: each a length byte, then its values + eight token streams, in plane order +``` + +### B.1 The header + +| Offset | Size | Field | +|---|---|---| +| +0 | 1 | ticks each play call advances by, whole part | +| +1 | 2 | the same step's 16-bit fraction | +| +3 | 2 | the ticks the song lasts | +| +5 | 2 | the tick the song returns to, or `$FFFF` where it stops there | +| +7 | 2 | where the timer table begins | +| +9 | 2 | where the phrase table begins | +| +11 | 8×2 | where each plane's stream begins | +| +27 | 8×2 | where each plane's stream is re-entered once the song comes round | + +All fields are little-endian, and the header runs to `SONG_HEADER_SIZE` bytes. + +**The step is how one data set plays at every rate.** A reconstruction advances its +envelopes at whatever rate it was built at, and the console calls `play` at the video +frame rate. The step is the first measured against the second, held as a whole byte and a +16-bit fraction; the driver adds it to an accumulator each call and advances the streams +by the whole ticks that fall out. A song slower than the play rate stands still on the +calls between its ticks, and a faster one advances several. + +### B.2 The timer table + +The table holds the timer register value every pitch sounds at: the low byte of each +pitch, in pitch order, then the high byte of each. One pointer reaches both halves, which +is what the driver's lookup takes advantage of. + +A plane names a pitch as its **index** — the distance above the lowest pitch the tuning +covers — rather than as a divider. Pitches beyond the divider's range share the timer they +clamp to, and the lowest pitch sounding a timer stands for the whole group. + +The table is written from the tuning the exported work was built at, computed by the very +function the reconstruction's own generators render from. + +### B.3 The dictionary + +``` +count 1 byte, how many phrases the table holds +offsets one uint16 per phrase, in id order +bodies each phrase: a length byte, then its values +``` + +A **phrase** is a run of values a plane plays, stored at the pitch it was found at. Its +position in the table is its **id**, and the ids that ride inside a token's opcode are the +cheap ones, so the phrases a song leans on hardest are listed first. + +A song's phrases come from two places: the samples it plays, each offering the planes it +writes, and a search over whatever those leave uncovered. Both are weighed the same way — +a phrase keeps its entry by sparing the streams more bytes than the entry costs. + +### B.4 The token streams + +Each plane is a byte sequence written as tokens. The opcode's top two bits name the kind +and the low six carry its operand: + +``` +00cccccc hold the value the plane reached, for c+1 ticks +01nnnnnn b0..bn the n+1 bytes that follow, one per tick +10pppppp cccccccc phrase p, for c+1 ticks +11pppppp cccccccc tt phrase p, for c+1 ticks, every value plus tt +``` + +`p == $3F` escapes: the phrase's id is the byte that follows, which reaches every id in +the table while the low ones stay a byte cheaper. The shift `tt` is **added within the +byte**, wrapping — one addition on the 6502, and the same one the encoder agrees with. + +**A token's count is a duration, and it may run past the phrase.** Past its last value the +plane holds that value onward, which is how a note whose envelope has finished keeps +sounding; a count short of the body cuts the note off. One entry therefore serves every +length a figure is played at, and — with the shift — every pitch. + +### B.5 Where a song comes round + +A song that repeats re-enters its streams partway through, so the tick it returns to +begins a token on every plane, and that token names its values outright rather than +leaning on the value the plane had reached. Coming round is then a matter of pointing each +plane at the byte the header states and clearing what it was playing. + +## C. What the planes hold + +The planes are written in this order, and each pair belongs to one channel: + +| Plane | Carries | Reaches | +|---|---|---| +| pulse 1 control | duty cycle and volume | `$4000` | +| pulse 1 value | pitch index | `$4002`, `$4003` | +| pulse 2 control | duty cycle and volume | `$4004` | +| pulse 2 value | pitch index | `$4006`, `$4007` | +| triangle control | linear counter | `$4008` | +| triangle value | pitch index | `$400A`, `$400B` | +| noise control | volume | `$400C` | +| noise value | period and mode | `$400E` | + +Splitting a channel's registers apart is what gives each plane something to repeat: a +volume envelope and a pitch line are separate series that turn over at their own rates. + +**A timer's high half reaches the register only where it differs from the last one +written.** Storing it restarts a pulse waveform and reloads the triangle's counter, so a +channel holding one pitch across a rest keeps its phase running the way a rendered channel +does. + +## D. Limits + +| Limit | Value | +|---|---| +| Program area | 32 KB from `$8000`, less the driver | +| Song length | as many ticks as the streams fit in | +| Offsets within the block | `uint16` | +| Ticks one hold or literal covers | up to `MAX_HOLD_TICKS` / `MAX_LITERAL_BYTES` | +| Ticks one phrase token covers | up to `MAX_PHRASE_TICKS` | +| Values one phrase holds | up to `MAX_PHRASE_LENGTH` | +| Phrases one dictionary holds | up to `MAX_PHRASE_IDS` | + +A song reaching past the space behind the driver, or past what an offset field states, +raises `SongTooLargeError` naming the part that overflowed. A project offering more +phrases than a dictionary holds keeps the ones sparing the streams most, and says so. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 1f47a3fdd..2529ca061 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -212,6 +212,12 @@ FamiTracker module...**) writes the `.ftm`. See [FamiTracker export](../formats/famitracker.md) for what the module contains and the limits it respects. +**File ▸ Export ▸ NSF program...** writes the song as an `.nsf` instead: a program +the console itself plays, carrying its own player, so it needs no tracker to sound. +The console holds one program in 32 KB, so a long song can outgrow it — the export +says so rather than writing a file that plays part of itself. See +[NSF export](../formats/nsf.md) for what the file holds. + ## Rendering to audio A module is for a tracker. To get a file anyone can play, use **File ▸ Render diff --git a/docs/index.md b/docs/index.md index 5b24118a8..b86ea9672 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,6 +44,7 @@ The [**formats**](formats/) section documents the files _SampleToNES_ reads and - [Projects](formats/projects.md) — the `.stp` project bundle. - [FamiTracker export](formats/famitracker.md) — the `.fti` instrument and `.ftm` module formats. - [Bitphase export](formats/bitphase.md) — the `.btp` document and `.json` instrument preset formats. +- [NSF export](formats/nsf.md) — the `.nsf` program the console plays, and the song block inside it. - [Configuration file](formats/configuration.md) — the `config.json` structure. ## Programming with SampleToNES @@ -60,6 +61,7 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. +- [Console player](development/player.md) — the 6502 driver an `.nsf` carries, the codec that fits a song beside it, and how both are verified. - [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 582828340..2de852478 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -92,6 +92,7 @@ class MenuElements(AbstractElement): GROUP_FILE_EXPORT = "group_file_export" ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker" ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase" + ITEM_FILE_EXPORT_NSF = "item_file_export_nsf" ITEM_FILE_RENDER_SONG = "item_file_render_song" ITEM_FILE_CLOSE_PROJECT = "item_file_close_project" ITEM_FILE_EXIT = "item_file_exit" @@ -200,6 +201,8 @@ class GlobalMessageElements(AbstractElement): PROJECT_EXPORT_FAILED = "project_export_failed" BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY = "bitphase_project_exported_successfully" BITPHASE_PROJECT_EXPORT_FAILED = "bitphase_project_export_failed" + NSF_PROJECT_EXPORTED_SUCCESSFULLY = "nsf_project_exported_successfully" + NSF_PROJECT_EXPORT_FAILED = "nsf_project_export_failed" NEW_UNSAVED_PROJECT = "new_unsaved_project" OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" @@ -242,6 +245,7 @@ class GlobalDialogTitleElements(AbstractElement): PROJECT_SAVED = "project_saved" EXPORT_MODULE = "export_module" EXPORT_BITPHASE_PROJECT = "export_bitphase_project" + EXPORT_NSF_PROJECT = "export_nsf_project" PROJECT_EXPORTED = "project_exported" NEW_UNSAVED_PROJECT = "new_unsaved_project" OPEN_UNSAVED_PROJECT = "open_unsaved_project" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 0d8455013..1a4e451b7 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -41,6 +41,7 @@ class KeybindingActionElements(AbstractElement): PROJECT_PROPERTIES = "project_properties" EXPORT_PROJECT_FAMITRACKER = "export_project_famitracker" EXPORT_PROJECT_BITPHASE = "export_project_bitphase" + EXPORT_PROJECT_NSF = "export_project_nsf" RENDER_SONG = "render_song" CLOSE_PROJECT = "close_project" EXIT = "exit" diff --git a/src/sampletones_application/categories/exports.py b/src/sampletones_application/categories/exports.py index 39ea25652..4b2399d51 100644 --- a/src/sampletones_application/categories/exports.py +++ b/src/sampletones_application/categories/exports.py @@ -43,11 +43,18 @@ class ExportProjectElements: exported_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY, export_failed_message=GlobalMessageElements.BITPHASE_PROJECT_EXPORT_FAILED, ), + ExportFormat.NSF: ExportProjectElements( + dialog_title=GlobalDialogTitleElements.EXPORT_NSF_PROJECT, + filter_name=FileFilterElements.NSF, + exported_message=GlobalMessageElements.NSF_PROJECT_EXPORTED_SUCCESSFULLY, + export_failed_message=GlobalMessageElements.NSF_PROJECT_EXPORT_FAILED, + ), } EXPORT_PROJECT_MENU_LABELS: Final[Dict[ExportFormat, MenuElements]] = { ExportFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, ExportFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE, + ExportFormat.NSF: MenuElements.ITEM_FILE_EXPORT_NSF, } INSTRUMENT_EXPORT_FORMATS: Final[Tuple[ExportFormat, ...]] = ( diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index bb4862f90..62fcf8968 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -48,6 +48,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: PROJECT_PROPERTIES = ("ProjectProperties", ShortcutCategory.APPLICATION) EXPORT_PROJECT_FAMITRACKER = ("ExportProjectFamiTracker", ShortcutCategory.APPLICATION) EXPORT_PROJECT_BITPHASE = ("ExportProjectBitphase", ShortcutCategory.APPLICATION) + EXPORT_PROJECT_NSF = ("ExportProjectNSF", ShortcutCategory.APPLICATION) RENDER_SONG = ("RenderSong", ShortcutCategory.APPLICATION) CLOSE_PROJECT = ("CloseProject", ShortcutCategory.APPLICATION) EXIT = ("Exit", ShortcutCategory.APPLICATION) @@ -224,6 +225,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = { ExportFormat.FAMITRACKER: ShortcutId.EXPORT_PROJECT_FAMITRACKER, ExportFormat.BITPHASE: ShortcutId.EXPORT_PROJECT_BITPHASE, + ExportFormat.NSF: ShortcutId.EXPORT_PROJECT_NSF, } SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = { diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 492c6ad4f..fae874fcf 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -9,6 +9,7 @@ bindings: ProjectProperties: {combination: "Alt+P"} ExportProjectFamiTracker: {combination: "Ctrl+M"} ExportProjectBitphase: {combination: "Ctrl+B"} + ExportProjectNSF: {combination: ~} RenderSong: {combination: "Ctrl+Shift+E"} CloseProject: {combination: "Ctrl+W"} Exit: {combination: "Alt+F4"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index e0e69fed5..fabfb0303 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -9,6 +9,7 @@ bindings: ProjectProperties: {combination: "Cmd+Alt+P"} ExportProjectFamiTracker: {combination: "Cmd+M"} ExportProjectBitphase: {combination: "Cmd+B"} + ExportProjectNSF: {combination: ~} RenderSong: {combination: "Cmd+Shift+E"} CloseProject: {combination: "Cmd+W"} Exit: {combination: "Cmd+Q"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 1e5952bdb..c0b7b72da 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -35,6 +35,7 @@ global.dialog.title.save_project: "Save project" global.dialog.title.project_saved: "Project saved" global.dialog.title.export_module: "Export FamiTracker module" global.dialog.title.export_bitphase_project: "Export Bitphase project" +global.dialog.title.export_nsf_project: "Export NSF program" global.dialog.title.project_exported: "Project exported" global.dialog.title.new_unsaved_project: "New project" global.dialog.title.open_unsaved_project: "Open project" @@ -77,6 +78,8 @@ global.dialog.message.project_exported_successfully: "FamiTracker module exporte global.dialog.message.project_export_failed: "Failed to export FamiTracker module." global.dialog.message.bitphase_project_exported_successfully: "Bitphase project exported successfully." global.dialog.message.bitphase_project_export_failed: "Failed to export Bitphase project." +global.dialog.message.nsf_project_exported_successfully: "NSF program exported successfully." +global.dialog.message.nsf_project_export_failed: "Failed to export NSF program." global.dialog.message.new_unsaved_project: "The current project has unsaved changes. Do you want to save it before starting a new one?" global.dialog.message.open_unsaved_project: "The current project has unsaved changes. Do you want to save it before opening another?" global.dialog.message.close_unsaved_project: "The current project has unsaved changes. Do you want to save it before closing?" @@ -185,6 +188,7 @@ global.menu.label.item_file_project_properties: "Project properties..." global.menu.label.group_file_export: "Export" global.menu.label.item_file_export_famitracker: "FamiTracker module..." global.menu.label.item_file_export_bitphase: "Bitphase project..." +global.menu.label.item_file_export_nsf: "NSF program..." global.menu.label.item_file_render_song: "Render song..." global.menu.label.item_file_close_project: "Close project" global.menu.label.item_file_exit: "Exit" @@ -818,6 +822,7 @@ settings.keybindings.label.save_project_as: "Save project as" settings.keybindings.label.project_properties: "Project properties" settings.keybindings.label.export_project_famitracker: "Export project to FamiTracker" settings.keybindings.label.export_project_bitphase: "Export project to Bitphase" +settings.keybindings.label.export_project_nsf: "Export project to NSF" settings.keybindings.label.render_song: "Render song to an audio file" settings.keybindings.label.close_project: "Close project" settings.keybindings.label.exit: "Exit" diff --git a/src/sampletones_core/performance/__init__.py b/src/sampletones_core/performance/__init__.py index 48259f614..b2072e2ae 100644 --- a/src/sampletones_core/performance/__init__.py +++ b/src/sampletones_core/performance/__init__.py @@ -1,4 +1,10 @@ from .modifiers import apply_modifiers +from .progress import ( + SILENT_WALK_REPORTER, + WalkProgress, + WalkReporter, + announce, +) from .rows import apply_row, resolve_row from .song import song_instructions from .state import ChannelPerformance @@ -6,8 +12,12 @@ from .voice import SampleVoice __all__ = [ + "SILENT_WALK_REPORTER", "ChannelPerformance", "SampleVoice", + "WalkProgress", + "WalkReporter", + "announce", "apply_modifiers", "apply_row", "resolve_row", diff --git a/src/sampletones_core/performance/progress.py b/src/sampletones_core/performance/progress.py new file mode 100644 index 000000000..7579addec --- /dev/null +++ b/src/sampletones_core/performance/progress.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import Callable, Final + +from sampletones_shared.exceptions import OperationCancelled + + +@dataclass(frozen=True) +class WalkProgress: + """How far a walk through a song's order has sounded it. + + Attributes: + ticks: The engine ticks every channel has sounded so far. + total: The engine ticks the whole order lasts, which the project's groove states before + a single row is played. + """ + + ticks: int + total: int + + +WalkReporter = Callable[[WalkProgress], bool] + + +def _carry_on(progress: WalkProgress) -> bool: # pylint: disable=unused-argument + """Answers that the walk goes on, which is what a caller watching nothing asks of it.""" + return True + + +SILENT_WALK_REPORTER: Final[WalkReporter] = _carry_on + + +def announce(report: WalkReporter, ticks: int, total: int) -> None: + """Tells a reporter how far the walk has come, and unwinds a walk it withdraws. + + Args: + report: Hears the walk and answers whether it goes on. + ticks: The engine ticks sounded so far. + total: The engine ticks the whole order lasts. + + Raises: + OperationCancelled: If the walk is no longer wanted. + """ + if not report(WalkProgress(ticks=ticks, total=total)): + raise OperationCancelled(f"the walk was withdrawn having sounded {ticks} of {total} ticks") diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index 75b6b2083..b5327752a 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -3,6 +3,11 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP from sampletones_core.instructions import InstructionUnion +from sampletones_core.performance.progress import ( + SILENT_WALK_REPORTER, + WalkReporter, + announce, +) from sampletones_core.performance.rows import apply_row, resolve_row from sampletones_core.performance.state import ChannelPerformance from sampletones_core.performance.ticks import sound_tick @@ -13,7 +18,10 @@ from sampletones_core.timing.song import SongTiming -def song_instructions(project: Project) -> Dict[ChannelName, List[InstructionUnion]]: +def song_instructions( + project: Project, + report: WalkReporter = SILENT_WALK_REPORTER, +) -> Dict[ChannelName, List[InstructionUnion]]: """Plays a whole song out as the instructions each channel sounds, one per engine tick. The order is walked frame by frame and row by row, each row lasting the ticks the project's @@ -21,18 +29,27 @@ def song_instructions(project: Project) -> Dict[ChannelName, List[InstructionUni so the four streams share one length and a tick's index into them is the same moment of the song — which is what an engine consuming one instruction per tick plays from. + The groove states the ticks the whole order lasts before a row is played, so a walk of a song + of minutes says how far along it is and answers a caller who no longer wants it. + Args: project: The project whose song is played. + report: Hears how far the walk has sounded, and answers whether it goes on. Returns: Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, tick by tick. + + Raises: + OperationCancelled: If ``report`` withdraws the walk. """ song = project.song groove = SongTiming.from_project(project).groove() + total = groove.total_ticks * song.order_length() performances = {channel_name: ChannelPerformance() for channel_name in ChannelName.items()} streams: Dict[ChannelName, List[InstructionUnion]] = {channel_name: [] for channel_name in ChannelName.items()} position = SongPosition() + walked = 0 while position.order_position < song.order_length(): ticks = groove.ticks[position.row_index] for channel_name in ChannelName.items(): @@ -50,6 +67,8 @@ def song_instructions(project: Project) -> Dict[ChannelName, List[InstructionUni ) ) + walked += ticks + announce(report, walked, total) position.advance(song.rows_per_pattern, song.order_length()) return streams diff --git a/src/sampletones_core/project/tuning.py b/src/sampletones_core/project/tuning.py new file mode 100644 index 000000000..7cefd5ec5 --- /dev/null +++ b/src/sampletones_core/project/tuning.py @@ -0,0 +1,39 @@ +from typing import Final, Set + +from sampletones_core.project.project import Project +from sampletones_shared.music import Tuning + +UNTUNED_PROJECT: Final[Tuning] = Tuning() + + +def _named(tuning: Tuning) -> str: + return f"A{tuning.a4_pitch} at {tuning.a4_frequency} Hz" + + +def tuning_from_project(project: Project) -> Tuning: + """Where concert pitch sits for a whole project, which its samples state together. + + A project carries no tuning of its own: each sample was reconstructed against one, and a + format sounding those pitches itself — the console player reaching them through timer values + — measures the whole song from a single tuning. The samples state it by agreeing on it, and + a project holding none takes the tuning a reconstruction is built against by default. + + Args: + project: The project whose samples state the tuning. + + Returns: + Tuning: The tuning every sample of the project was reconstructed against. + + Raises: + ValueError: If the samples were reconstructed against tunings that differ, which one + timer table sounds only one of. + """ + tunings: Set[Tuning] = {sample.reconstruction.config.tuning for sample in project.samples} + if not tunings: + return UNTUNED_PROJECT + + if len(tunings) > 1: + stated = ", ".join(sorted(_named(tuning) for tuning in tunings)) + raise ValueError(f"a project sounds one tuning, and its samples were reconstructed at {stated}") + + return tunings.pop() diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index a6e30b27f..2d323a344 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -4,8 +4,13 @@ from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import InstructionUnion -from sampletones_core.performance import song_instructions +from sampletones_core.performance import ( + SILENT_WALK_REPORTER, + WalkReporter, + song_instructions, +) from sampletones_core.project.project import Project +from sampletones_core.project.tuning import tuning_from_project from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule @@ -16,7 +21,6 @@ from sampletones_player.registers.channel import channel_registers from sampletones_player.registers.streams import ChannelStreams from sampletones_player.song import Song -from sampletones_shared.music import Tuning SONG_START: Final[int] = 0 NO_SEEDS: Final[Tuple[Phrase, ...]] = () @@ -179,38 +183,41 @@ def song_from_sample( def song_from_project( project: Project, - tuning: Tuning, loop_tick: Optional[int], report: CodecReporter = SILENT_REPORTER, + walk: WalkReporter = SILENT_WALK_REPORTER, ) -> Song: """Builds the song the console plays a whole project as. The project's song is played out row by row into the instructions each channel sounds, so what reaches the console is the arrangement itself rather than one reconstruction: the same walk the sequencer sounds a song through, read as register values instead of audio. The - project states the rate the driver re-clocks those ticks by. + project states both the rate the driver re-clocks those ticks by and, through the samples it + holds, the tuning its pitches become timers under. A row plays a sample the project already holds, so the samples themselves seed the dictionary and every row naming one reaches the stream as a token naming that entry. Args: project: The project whose song is played. - tuning: Where concert pitch sits, which decides the timer each pitch sounds at. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. report: Hears what the codec holds each time it looks up, and answers whether the compression goes on. + walk: Hears how far the song has been played out, and answers whether the walk goes on. Returns: Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCancelled: If ``report`` or ``walk`` withdraws the run. TypeError: If a channel's stream holds an instruction another channel sounds. - ValueError: If ``loop_tick`` lies outside the song's ticks. + ValueError: If ``loop_tick`` lies outside the song's ticks, or the project's samples were + reconstructed against tunings that differ. """ + tuning = tuning_from_project(project) return Song.from_streams( streams=streams_from_instructions( - song_instructions(project), + song_instructions(project, walk), get_timer_table(tuning), ), pitches=PitchTable.from_tuning(tuning), diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py index 61f07d5b7..b181137d7 100644 --- a/src/sampletones_player/export.py +++ b/src/sampletones_player/export.py @@ -16,7 +16,8 @@ ) from sampletones_core.exports.scope import ExportScope from sampletones_core.exports.stage import ExportStage -from sampletones_player.builder import song_from_sample +from sampletones_core.performance import WalkProgress, WalkReporter +from sampletones_player.builder import SONG_START, song_from_project, song_from_sample from sampletones_player.compression.progress.report import CodecProgress, CodecReporter from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.file import write_nsf @@ -27,6 +28,7 @@ { ExportScope.INSTRUMENT, ExportScope.SAMPLE, + ExportScope.PROJECT, } ) @@ -37,6 +39,31 @@ UNMEASURED: None = None +def _walking(report: ExportReporter) -> WalkReporter: + """The walk's own reckoning, said in the words an export reports itself in. + + A song's order states the ticks it lasts before a row of it is played, so this stage travels + toward a length it knows and reads as a true fraction of the song. + + Args: + report: Hears each stage of the export, and answers whether it goes on. + + Returns: + WalkReporter: What playing the song out tells the export about itself. + """ + + def reached(progress: WalkProgress) -> bool: + return report( + ExportProgress( + stage=ExportStage.WALKING, + completed=progress.ticks, + total=progress.total, + ) + ) + + return reached + + def _compressing(report: ExportReporter) -> CodecReporter: """The codec's own reckoning, said in the words an export reports itself in. @@ -163,9 +190,39 @@ def write_project( request: ProjectExport, report: ExportReporter = SILENT_REPORTER, ) -> ExportArtifact: - """Reports that a program plays one reconstruction. + """Writes a program playing a whole composition. + + The arrangement is played out row by row into the ticks each channel sounds, those ticks + are compressed to what the console has room for, and the file is written; each of those + says so as it starts, and the walk reads as a fraction of the song it is playing out. + + The file repeats from its first tick, which is how a piece of music is listened to and + what an NSF player expects of a song that has reached its end. Raises: - NotImplementedError: Always, until a song flattens to the four streams a program plays. + OperationCancelled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the project's samples were reconstructed against tunings that differ. """ - raise NotImplementedError("An NSF plays one reconstruction; a whole song reaches the console later") + destination.parent.mkdir(parents=True, exist_ok=True) + song = song_from_project( + request.project, + SONG_START, + _compressing(report), + _walking(report), + ) + + announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) + write_nsf( + destination, + song, + NSFInformation( + title=request.project.info.title, + artist=request.project.info.author, + ), + self._image, + ) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) diff --git a/tests/integration/nsf/corpus.py b/tests/integration/nsf/corpus.py index e0e5d088a..9ed998151 100644 --- a/tests/integration/nsf/corpus.py +++ b/tests/integration/nsf/corpus.py @@ -14,6 +14,7 @@ from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.tuning import tuning_from_project from sampletones_core.timers.utils import get_timer_table from sampletones_core.timing import SongTiming from sampletones_player.builder import ( @@ -111,7 +112,7 @@ def arrangement_entry( name: str, project: Project, ) -> CorpusEntry: - """A whole project flattened into one song, at concert tuning. + """A whole project flattened into one song, at the tuning its samples were built at. Args: name: What the entry is called in a report. @@ -120,10 +121,10 @@ def arrangement_entry( Returns: CorpusEntry: The song and the phrases the project's instruments offer. """ - tuning = Tuning() + tuning = tuning_from_project(project) return CorpusEntry( name=name, - song=song_from_project(project, tuning, loop_tick=None), + song=song_from_project(project, loop_tick=None), seeds=phrases_from_project(project, tuning), tuning=tuning, ) diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 64fce808c..8e87c7f49 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List +from typing import Dict, Final, List, Optional import numpy as np import pytest @@ -7,20 +7,40 @@ from sampletones_core.audio.mixing import mix from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.naming import instrument_slice_name -from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) from sampletones_core.generators.render import render_channels from sampletones_core.instructions import InstructionUnion +from sampletones_core.performance import song_instructions from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project +from sampletones_core.project.tuning import tuning_from_project from sampletones_core.timers.utils import get_timer_table -from sampletones_player.builder import instructions_from_instruments, song_from_sample +from sampletones_player.builder import ( + SONG_START, + instructions_from_instruments, + song_from_project, + song_from_sample, +) from sampletones_player.export import NSFBackend +from sampletones_player.song import Song from sampletones_player.specification.nsf import NSF_MAGIC from sampletones_shared.paths.extensions import EXT_FILE_NSF from tests.integration.nsf.console.instructions import instructions_from_trace -from tests.integration.nsf.console.session import captured_file_trace +from tests.integration.nsf.console.session import ( + captured_file_trace, + captured_run, + play_calls_reaching, +) +from tests.integration.output import resolve_output_path ChannelInstructions = Dict[ChannelName, List[InstructionUnion]] +PROJECT_ARTIFACT: Final[str] = "song" + def resting(instruction: InstructionUnion) -> InstructionUnion: """A sounding instruction as it stands, and a rest as the canonical silent one. @@ -168,3 +188,74 @@ def test_the_console_sounds_the_reconstructions_own_waveform( assert np.array_equal(rendered[:audible], approximation[:audible]) assert not np.any(rendered[audible:]) assert not np.any(approximation[audible:]) + + +@pytest.fixture(scope="module") +def project_song(integration_project: Project) -> Song: + """The song the console plays the integration project's arrangement as.""" + return song_from_project(integration_project, SONG_START) + + +@pytest.fixture(scope="module") +def project_file( + backend: NSFBackend, + integration_project: Project, + nsf_output_dir: Optional[Path], + tmp_path_factory: pytest.TempPathFactory, +) -> Path: + """The whole arrangement written through the backend the application registers. + + It joins the samples in the emitted artifacts, so the arrangement can be listened to + beside the instruments it is built from. + """ + destination = resolve_output_path( + nsf_output_dir, + tmp_path_factory.mktemp("nsf-project"), + f"{PROJECT_ARTIFACT}{EXT_FILE_NSF}", + ) + backend.write_project(destination, ProjectExport(project=integration_project)) + return destination + + +class TestTheBackendWritesAWholeSong: + """What reaches disk when the application exports its arrangement to the console.""" + + def test_the_project_reaches_a_file_a_player_recognizes(self, project_file: Path) -> None: + assert project_file.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC + + def test_the_console_sounds_the_arrangement_the_project_states( + self, + project_file: Path, + project_song: Song, + integration_project: Project, + ) -> None: + """This closes the loop a project export opens: the arrangement was played out row by + row, compressed to eight token streams, decoded by the 6502 and written to the APU, and + what stood in those registers is the very song the sequencer sounds. + """ + trace = captured_run( + project_file.read_bytes(), + play_calls_reaching(project_song, project_song.ticks), + ) + played = instructions_from_trace(trace, get_timer_table(tuning_from_project(integration_project))) + for channel, instructions in song_instructions(integration_project).items(): + sounded = played[channel][: len(instructions)] + assert [resting(instruction) for instruction in sounded] == [ + resting(instruction) for instruction in instructions + ] + + def test_the_song_comes_round_rather_than_falling_silent( + self, + project_file: Path, + project_song: Song, + integration_project: Project, + ) -> None: + """The file repeats, so the calls past the arrangement's end sound its first ticks again.""" + ticks = project_song.ticks + trace = captured_run(project_file.read_bytes(), play_calls_reaching(project_song, 2 * ticks)) + played = instructions_from_trace(trace, get_timer_table(tuning_from_project(integration_project))) + for channel, sounded in played.items(): + assert len(sounded) > ticks + assert [resting(instruction) for instruction in sounded[ticks : 2 * ticks]] == [ + resting(instruction) for instruction in sounded[:ticks] + ] diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py index f6f30e904..7f566958c 100644 --- a/tests/integration/nsf/test_song_export.py +++ b/tests/integration/nsf/test_song_export.py @@ -13,7 +13,6 @@ TOTAL_TICKS_OFFSET, ) from sampletones_shared.exceptions import SongTooLargeError -from sampletones_shared.music import Tuning from tests.integration.nsf.songs import RECORD_BYTES_PER_TICK, available_bytes @@ -24,7 +23,7 @@ def read_word(data: bytes, offset: int) -> int: @pytest.fixture def project_song(integration_project: Project) -> Song: """The song the console plays the integration project's arrangement as.""" - return song_from_project(integration_project, Tuning(), loop_tick=None) + return song_from_project(integration_project, loop_tick=None) class TestTheProjectReachesTheConsole: diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index 06d9c632b..d962c030a 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -7,18 +7,33 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.services.result import ServiceProgress from sampletones_core.audio import read_wave from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend -from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.stage import ExportStage +from sampletones_core.project.project import Project +from sampletones_core.timing import SongTiming from sampletones_player.export import NSFBackend from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE from sampletones_shared.music import Tuning +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_sample, +) from tests.suite.player import varied_features NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 +ROWS_PER_PATTERN: Final[int] = 4 +SOUNDING_TICKS: Final[int] = 8 def outcome(results: List[Any]) -> Any: @@ -235,3 +250,54 @@ def test_a_reconstruction_outgrowing_the_program_area_is_reported(self, tmp_path assert isinstance(outcome(results), ExportError) assert outcome(results).kind == ExportKind.SAMPLE + + +def arranged_project() -> Project: + """A project whose song sounds one sample from its first row.""" + project, sample = project_with_sample( + make_pulse_reconstruction(pitch=REFERENCE_PITCH, count=SOUNDING_TICKS), + rows_per_pattern=ROWS_PER_PATTERN, + ) + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=sample, + ) + return project + + +class TestExportProjectToTheConsoleIntegration: + """A whole arrangement reaching the console through the service the application exports by.""" + + def test_the_song_is_written_as_one_program(self, tmp_path, console_backend) -> None: + export_service = ExportService() + results: List[Any] = [] + export_service.subscribe(results.append) + + filepath = tmp_path / "song.nsf" + export_service.export_project(filepath, console_backend, ProjectExport(project=arranged_project())) + + assert isinstance(outcome(results), ExportSuccess) + assert outcome(results).kind == ExportKind.PROJECT + assert outcome(results).filepath == filepath + assert filepath.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC + + def test_the_walk_reads_as_a_fraction_of_the_song(self, tmp_path, console_backend) -> None: + """A song states the ticks it lasts before a row of it is played, so the stage that + plays it out travels toward a length the dialog can draw.""" + project = arranged_project() + export_service = ExportService() + results: List[Any] = [] + export_service.subscribe(results.append) + + export_service.export_project(tmp_path / "song.nsf", console_backend, ProjectExport(project=project)) + + walked = [ + result + for result in results + if isinstance(result, ServiceProgress) and result.current_item == ExportStage.WALKING + ] + groove = SongTiming.from_project(project).groove() + assert walked + assert walked[-1].total == project.song.order_length() * groove.total_ticks diff --git a/tests/suite/performance.py b/tests/suite/performance.py index 6729e98f8..6e1927981 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -91,6 +91,19 @@ def make_noise_reconstruction( return _reconstruction(ChannelName.NOISE, instructions) +def retuned_reconstruction( + reconstruction: Reconstruction, + a4_frequency: float, +) -> Reconstruction: + """The same reconstruction read as though concert pitch had sat at ``a4_frequency``. + + A tuning reaches a reconstruction through the library settings it was built with, so a case + needing two samples that disagree copies one of them onto another reference. + """ + library = reconstruction.config.library.model_copy(update={"a4_frequency": a4_frequency}) + return reconstruction.model_copy(update={"config": reconstruction.config.model_copy(update={"library": library})}) + + def project_with_sample( reconstruction: Reconstruction, *, diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py index a8d8dc417..dd75d8e9d 100644 --- a/tests/unit/sampletones_core/performance/test_song.py +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -1,11 +1,14 @@ -from typing import Final +from typing import Final, List + +import pytest from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP -from sampletones_core.performance import song_instructions +from sampletones_core.performance import WalkProgress, song_instructions from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.timing import SongTiming +from sampletones_shared.exceptions import OperationCancelled from tests.suite.performance import ( make_pulse_reconstruction, place_instrument, @@ -79,3 +82,38 @@ def test_a_pattern_the_order_plays_twice_sounds_alike_both_times(self) -> None: frame_ticks = groove.total_ticks assert stream[:frame_ticks] == stream[frame_ticks : 2 * frame_ticks] + + +class TestWhatAWalkSaysAboutItself: + """A song of minutes is played out row by row, so the walk says how far along it is.""" + + def test_the_walk_reports_each_row_it_sounds(self) -> None: + project = _project() + heard: List[WalkProgress] = [] + song_instructions(project, lambda progress: heard.append(progress) is None) + assert len(heard) == project.song.order_length() * project.song.rows_per_pattern + + def test_the_walk_states_the_ticks_the_order_lasts(self) -> None: + """The groove states the length before a row is played, so every report names the same.""" + project = _project() + heard: List[WalkProgress] = [] + song_instructions(project, lambda progress: heard.append(progress) is None) + expected = project.song.order_length() * SongTiming.from_project(project).groove().total_ticks + assert [progress.total for progress in heard] == [expected] * len(heard) + + def test_the_walk_reaches_the_ticks_it_set_out_to_sound(self) -> None: + project = _project() + heard: List[WalkProgress] = [] + song_instructions(project, lambda progress: heard.append(progress) is None) + assert heard[-1].ticks == heard[-1].total + + def test_the_walk_counts_up_as_it_goes(self) -> None: + project = _project() + heard: List[WalkProgress] = [] + song_instructions(project, lambda progress: heard.append(progress) is None) + counted = [progress.ticks for progress in heard] + assert counted == sorted(counted) + + def test_a_withdrawn_walk_stops_where_it_was_told(self) -> None: + with pytest.raises(OperationCancelled): + song_instructions(_project(), lambda progress: False) diff --git a/tests/unit/sampletones_core/project/test_tuning.py b/tests/unit/sampletones_core/project/test_tuning.py new file mode 100644 index 000000000..9d494d790 --- /dev/null +++ b/tests/unit/sampletones_core/project/test_tuning.py @@ -0,0 +1,63 @@ +from typing import Final + +import pytest + +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project +from sampletones_core.project.tuning import UNTUNED_PROJECT, tuning_from_project +from sampletones_shared.music import Tuning +from tests.suite.performance import ( + make_pulse_reconstruction, + make_triangle_reconstruction, + project_with_sample, + retuned_reconstruction, +) + +ROWS_PER_PATTERN: Final[int] = 4 +CONCERT_PITCH: Final[float] = 440.0 +BAROQUE_PITCH: Final[float] = 415.0 + + +def sampled_project() -> Project: + project, _ = project_with_sample( + make_pulse_reconstruction(pitch=60, count=2), + rows_per_pattern=ROWS_PER_PATTERN, + ) + return project + + +class TestTheTuningAProjectSounds: + """A project carries no tuning of its own, so its samples state it between them.""" + + def test_a_projects_sample_states_the_tuning(self) -> None: + project = sampled_project() + assert tuning_from_project(project) == project.samples[0].reconstruction.config.tuning + + def test_samples_agreeing_state_the_tuning_they_agree_on(self) -> None: + project = sampled_project() + project.samples.append( + Sample( + name="second", + reconstruction=make_triangle_reconstruction(pitch=45, count=2), + ) + ) + assert tuning_from_project(project) == Tuning(a4_frequency=CONCERT_PITCH) + + def test_a_project_holding_no_samples_sounds_the_default_tuning(self) -> None: + """A project with nothing to sound still answers, so an empty song reaches the console.""" + assert tuning_from_project(Project.create(rows_per_pattern=ROWS_PER_PATTERN)) == UNTUNED_PROJECT + + def test_samples_that_disagree_are_refused(self) -> None: + """One timer table sounds one tuning, so a project holding two of them names both.""" + project = sampled_project() + project.samples.append( + Sample( + name="baroque", + reconstruction=retuned_reconstruction( + make_triangle_reconstruction(pitch=45, count=2), + BAROQUE_PITCH, + ), + ) + ) + with pytest.raises(ValueError, match=str(BAROQUE_PITCH)): + tuning_from_project(project) diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py index 97a3e5e1e..bf04231de 100644 --- a/tests/unit/sampletones_player/test_builder.py +++ b/tests/unit/sampletones_player/test_builder.py @@ -264,31 +264,31 @@ class TestSongFromProject: def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows(self) -> None: project = drum_project() - song = song_from_project(project, Tuning(), None) + song = song_from_project(project, None) groove = SongTiming.from_project(project).groove() assert song.ticks == project.song.order_length() * groove.total_ticks def test_the_schedule_follows_the_rate_the_project_states(self) -> None: - song = song_from_project(drum_project(), Tuning(), None) + song = song_from_project(drum_project(), None) assert song.schedule == PlaySchedule.from_parameters(SONG_SETTINGS.nes_frequency) def test_every_channel_carries_the_songs_whole_length(self) -> None: - song = song_from_project(drum_project(), Tuning(), None) + song = song_from_project(drum_project(), None) assert len(song.streams.noise) == song.ticks assert len(song.streams.triangle) == song.ticks def test_a_pitch_reaches_the_timer_the_tuning_names(self) -> None: - song = song_from_project(drum_project(), Tuning(), None) + song = song_from_project(drum_project(), None) timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8) def test_the_song_carries_the_loop_it_is_given(self) -> None: - song = song_from_project(drum_project(), Tuning(), SONG_START) + song = song_from_project(drum_project(), SONG_START) assert song.loop_tick == SONG_START def test_a_frame_the_order_plays_twice_lasts_twice_as_long(self) -> None: project = drum_project() - one_frame = song_from_project(project, Tuning(), None).ticks + one_frame = song_from_project(project, None).ticks project.song.append_frame() project.song.set_order_entry(1, ChannelName.PULSE1, 0) - assert song_from_project(project, Tuning(), None).ticks == 2 * one_frame + assert song_from_project(project, None).ticks == 2 * one_frame diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py index ddfb4c211..6449987f3 100644 --- a/tests/unit/sampletones_player/test_export.py +++ b/tests/unit/sampletones_player/test_export.py @@ -1,3 +1,4 @@ +import struct from pathlib import Path from typing import Final @@ -16,6 +17,9 @@ from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SongTiming +from sampletones_player.builder import SONG_START, song_from_project +from sampletones_player.driver.image import DriverImage from sampletones_player.export import NSFBackend from sampletones_player.specification.nsf import ( ARTIST_OFFSET, @@ -24,8 +28,14 @@ STRING_FIELD_SIZE, TITLE_OFFSET, ) +from sampletones_player.specification.song import LOOP_TICK_OFFSET from sampletones_shared.exceptions import OperationCancelled, SongTooLargeError from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_sample, +) from tests.suite.player import ( PLAYER_REFERENCE_PITCH, player_features, @@ -42,6 +52,8 @@ FILENAME: Final[str] = "reconstruction.nsf" SAMPLE_NAME: Final[str] = "Amen" PROJECT_TITLE: Final[str] = "Demo" +PROJECT_AUTHOR: Final[str] = "Jakim" +ROWS_PER_PATTERN: Final[int] = 4 WITHDRAWN_WHILE_WALKING: Final[int] = 1 WITHDRAWN_WHILE_COMPRESSING: Final[int] = 2 @@ -97,6 +109,12 @@ def bass_slice(name: str, frames: int) -> InstrumentExport: ) +def written_loop_tick(data: bytes) -> int: + """The tick a written program comes round to, read out of the song block behind the driver.""" + block = data[HEADER_SIZE + len(DriverImage.load().code) :] + return int(struct.unpack_from(" str: return data[offset : offset + STRING_FIELD_SIZE].rstrip(b"\x00").decode() @@ -112,10 +130,10 @@ class TestSeam: def test_the_backend_writes_the_nsf_format(self, backend: NSFBackend) -> None: assert backend.export_format == ExportFormat.NSF - def test_a_program_plays_an_instrument_and_a_reconstruction(self, backend: NSFBackend) -> None: - assert backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + def test_a_program_plays_every_scope_the_application_exports(self, backend: NSFBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) - @pytest.mark.parametrize("scope", [ExportScope.INSTRUMENT, ExportScope.SAMPLE]) + @pytest.mark.parametrize("scope", list(ExportScope)) def test_every_scope_the_backend_writes_carries_the_nsf_extension( self, backend: NSFBackend, @@ -205,13 +223,129 @@ def test_a_slice_plays_the_program_its_reconstruction_would(self, backend: NSFBa assert alone.read_bytes() == together.read_bytes() +def drum_project() -> Project: + """A one-frame project sounding a pulse envelope from its first row.""" + project, sample = project_with_sample( + make_pulse_reconstruction(pitch=PLAYER_REFERENCE_PITCH, count=SOUNDING_TICKS), + rows_per_pattern=ROWS_PER_PATTERN, + settings=ProjectSettings(nes_frequency=NTSC_FREQUENCY), + ) + project.info.title = PROJECT_TITLE + project.info.author = PROJECT_AUTHOR + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=sample, + ) + return project + + class TestWriteProject: - """What a whole composition meets at the console's door.""" + """A whole composition written as one program the console plays.""" + + def test_the_file_is_written(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + backend.write_project(destination, ProjectExport(project=drum_project())) + assert destination.is_file() + + def test_the_program_is_listed_under_the_projects_own_title( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + backend.write_project(destination, ProjectExport(project=drum_project())) + assert read_field(destination.read_bytes(), TITLE_OFFSET) == PROJECT_TITLE + + def test_the_program_is_credited_to_the_projects_author( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + backend.write_project(destination, ProjectExport(project=drum_project())) + assert read_field(destination.read_bytes(), ARTIST_OFFSET) == PROJECT_AUTHOR + + def test_the_program_plays_the_song_the_project_arranges( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + """What reaches the file is the arrangement, so it lasts the ticks the groove gives it.""" + project = drum_project() + destination = tmp_path / FILENAME + backend.write_project(destination, ProjectExport(project=project)) + groove = SongTiming.from_project(project).groove() + expected = project.song.order_length() * groove.total_ticks + assert song_from_project(project, SONG_START).ticks == expected - def test_a_project_reaches_the_console_later(self, backend: NSFBackend, tmp_path: Path) -> None: - project = Project.create(title=PROJECT_TITLE, settings=ProjectSettings()) - with pytest.raises(NotImplementedError): - backend.write_project(tmp_path / FILENAME, ProjectExport(project=project)) + def test_the_program_repeats_from_its_first_tick( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + """A piece of music is listened to over and over, so the file comes round where the + arrangement ends rather than falling silent there.""" + destination = tmp_path / FILENAME + backend.write_project(destination, ProjectExport(project=drum_project())) + assert written_loop_tick(destination.read_bytes()) == SONG_START + + def test_a_destination_reaches_a_directory_the_run_creates( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / "exports" / FILENAME + backend.write_project(destination, ProjectExport(project=drum_project())) + assert destination.is_file() + + +class TestWhatAProjectRunSaysAboutItself: + """A song is played out before it is compressed, and both stages read as they run.""" + + def test_the_run_names_each_stage_in_the_order_it_reaches_it( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + backend.write_project(tmp_path / FILENAME, ProjectExport(project=drum_project()), reporter) + assert reported_stages(reporter.reports) == [ + ExportStage.WALKING, + ExportStage.COMPRESSING, + ExportStage.WRITING, + ] + + def test_the_walk_counts_the_ticks_the_song_lasts( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + """The groove states the length before a row is played, so the stage travels toward it.""" + project = drum_project() + reporter: RecordingReporter[ExportProgress] = RecordingReporter() + backend.write_project(tmp_path / FILENAME, ProjectExport(project=project), reporter) + + walked = [report for report in reporter.reports if report.stage == ExportStage.WALKING] + groove = SongTiming.from_project(project).groove() + expected = project.song.order_length() * groove.total_ticks + assert walked + assert [report.total for report in walked] == [expected] * len(walked) + assert walked[-1].completed == expected + + def test_a_withdrawn_walk_leaves_no_file( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) + with pytest.raises(OperationCancelled): + backend.write_project(destination, ProjectExport(project=drum_project()), reporter) + + assert reporter.last.stage == ExportStage.WALKING + assert not destination.exists() class TestWhatARunSaysAboutItself: From 5e17943013e1dbe27036c715ff93671b34277381 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 00:46:27 +0200 Subject: [PATCH 069/142] Added: the reconstruction engine rate to the source card --- .../categories/elements/reconstructions.py | 1 + .../logic/reconstruction/reconstruction.py | 2 + .../tags/reconstructions.py | 6 + .../ui/panels/reconstruction/audio.py | 26 +++- .../reconstruction/reconstruction.py | 6 +- src/sampletones_config/lang/en.yaml | 1 + .../reconstruction/test_reconstruction.py | 45 ++++++ .../panels/reconstruction/test_audio_panel.py | 128 ++++++++++++++++++ .../ui/panels/reconstruction/test_plot.py | 1 + .../reconstruction/test_reconstruction.py | 1 + 10 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py index 7cc1230c7..3ecf933be 100644 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ b/src/sampletones_application/categories/elements/reconstructions.py @@ -26,6 +26,7 @@ class ReconstructionPanelElements(AbstractElement): AUDIO_SOURCE_LABEL = "audio_source_label" AUTOSCALE_CHECKBOX = "autoscale_checkbox" RECONSTRUCTION_FILE_LABEL = "reconstruction_file_label" + NES_FREQUENCY_LABEL = "nes_frequency_label" PATH_NOT_FOUND = "path_not_found" PATH_NOT_APPLICABLE = "path_not_applicable" ORIGINAL_AUDIO_RADIO = "original_audio_radio" diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 7ae0ebd98..054ff344a 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -192,6 +192,7 @@ def _build_view_model( selected_channels=frozenset(self._selected_channels), reconstruction_file=reconstruction_file, original_audio=original_audio, + nes_frequency=reconstruction_data.config.nes_frequency, ) def close_reconstruction(self) -> None: @@ -221,6 +222,7 @@ def close_reconstruction(self) -> None: selected_channels=frozenset(), reconstruction_file=empty_path, original_audio=empty_path, + nes_frequency=None, ), ) diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 43628faa4..d654aed38 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -104,6 +104,12 @@ Widget.PATH, "reconstruction_file", ) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TEXT, + "nes_frequency", +) TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 26d2b5be7..3fd18c9f9 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -9,6 +9,7 @@ TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -26,6 +27,7 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_core.configs.display import format_nes_frequency from sampletones_core.constants.enums import AudioSourceType from sampletones_shared.types.application import Sender @@ -33,9 +35,9 @@ class GUIReconstructionAudioPanel(GUIPanel): """Where the reconstruction came from and which of the two waveforms plays. - The card names the reconstruction's own file and offers the choice between the - reconstruction and the audio it was built from. The recordings behind that audio are named - by the stems card, one row each. + The card names the reconstruction's own file and the engine rate it runs at, and offers + the choice between the reconstruction and the audio it was built from. The recordings + behind that audio are named by the stems card, one row each. """ def __init__( @@ -82,12 +84,14 @@ def create_panel(self, parent: str) -> None: self._create_audio_source_radio_buttons() dpg.add_separator() self._create_path_display() + self._create_frequency_display() def update_view(self, view_model: ReconstructionViewModel) -> None: self._render_path( self._reconstruction_file_path, view_model.reconstruction_file, ) + self._render_frequency(view_model.nes_frequency) dpg_configure_item( TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, enabled=view_model.audio_source_enabled, @@ -133,6 +137,22 @@ def _create_path_display(self) -> None: ) self._reconstruction_file_path.set_status("", self._path_status_color) + def _create_frequency_display(self) -> None: + """Draws the engine rate as a readout beside its label, monospaced as a figure.""" + with dpg.group(horizontal=True, parent=self._body_container): + label = dpg.add_text(self._language_manager["reconstructions.reconstruction.label.nes_frequency_label"]) + dpg.add_text("", tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) + + FontRegistry.bind_to_item(label, Font.REGULAR_SMALL) + FontRegistry.bind_to_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, Font.MONO) + + def _render_frequency(self, nes_frequency: Optional[int]) -> None: + """States the rate a loaded reconstruction runs at, and stands blank for an empty tab.""" + dpg_set_value( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, + format_nes_frequency(nes_frequency) if nes_frequency is not None else "", + ) + def _create_audio_source_radio_buttons(self) -> None: with dpg.group( tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index 64ce22c7c..1279d3496 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -1,4 +1,4 @@ -from typing import FrozenSet +from typing import FrozenSet, Optional from pydantic import BaseModel @@ -18,6 +18,9 @@ class ReconstructionViewModel(BaseModel, frozen=True): A channel plays once its instruction stream describes a frame, which is what makes its channel checkbox reachable; :attr:`selected_channels` is the subset the reader keeps switched on, so a channel switched off by hand stays off across an edit. + + :attr:`nes_frequency` is the engine rate the open reconstruction runs at, and ``None`` + while the tab holds no document. """ reconstruction_loaded: bool @@ -25,6 +28,7 @@ class ReconstructionViewModel(BaseModel, frozen=True): selected_channels: FrozenSet[ChannelName] reconstruction_file: ReconstructionPathViewModel original_audio: ReconstructionPathViewModel + nes_frequency: Optional[int] @property def audio_source_enabled(self) -> bool: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 1e5952bdb..7ed96182d 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -446,6 +446,7 @@ reconstructions.browser.template.incompatible_version_template: "Incompatible re reconstructions.reconstruction.label.audio_source_label: "Source" reconstructions.reconstruction.label.autoscale_checkbox: "Autoscale" reconstructions.reconstruction.label.reconstruction_file_label: "Reconstruction file:" +reconstructions.reconstruction.label.nes_frequency_label: "NES frequency:" reconstructions.reconstruction.label.path_not_found: "not found" reconstructions.reconstruction.label.path_not_applicable: "N/A" reconstructions.reconstruction.label.original_audio_radio: "Original audio" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 3827b1010..eb3823f67 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -29,6 +29,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_shared.constants.nes import PAL_FREQUENCY from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, @@ -136,6 +137,17 @@ def retuned_data( ) +@pytest.fixture +def reclocked_data( + reconstruction_factory: Callable[[], Reconstruction], +) -> ReconstructionData: + """A reconstruction running at the PAL rate, which a fresh configuration departs from.""" + return ReconstructionData.from_reconstruction( + reconstruction_factory().with_nes_frequency(PAL_FREQUENCY), + name="Sample", + ) + + @pytest.fixture def data_with_original_audio( reconstruction_factory: Callable[[], Reconstruction], @@ -421,6 +433,39 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform( assert received[0].selected_channels == frozenset() +class TestReconstructionPanelLogicEngineRate: + """The rate the card states, which follows the open document.""" + + @staticmethod + def _received(panel_logic: ReconstructionPanelLogic) -> List[ReconstructionViewModel]: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + return received + + def test_the_view_states_the_rate_the_reconstruction_runs_at( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + reclocked_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = reclocked_data + received = self._received(panel_logic) + + panel_logic.display_reconstruction() + + assert received[0].nes_frequency == reclocked_data.config.nes_frequency + + def test_a_closed_tab_states_no_rate( + self, + panel_logic: ReconstructionPanelLogic, + ) -> None: + received = self._received(panel_logic) + + panel_logic.close_reconstruction() + + assert received[0].nes_frequency is None + + class TestReconstructionPanelLogicClose: def test_close_fires_on_waveform_cleared( self, diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py new file mode 100644 index 000000000..d1ef2d3f8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py @@ -0,0 +1,128 @@ +from typing import Iterator, Optional + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.reconstruction.audio import ( + GUIReconstructionAudioPanel, +) +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.reconstruction.paths.path import ( + ReconstructionPathViewModel, +) +from sampletones_application.view_model.reconstruction.paths.state import ( + ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.reconstruction import ( + ReconstructionViewModel, +) +from sampletones_core.configs.display import format_nes_frequency +from sampletones_shared.constants.nes import PAL_FREQUENCY + +ROOT_TAG = "test_root" + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: + """Stands up the context, fonts, themes, and section-header geometry the panel resolves on construction.""" + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) + GUIPanel.configure_section_header( + layout_config.glyphs, + layout_config.general.section_header, + layout_config.general.collapse, + ) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture +def panel(dpg_context: None, layout_config: LayoutConfig) -> GUIReconstructionAudioPanel: + return GUIReconstructionAudioPanel( + path_colors=layout_config.general.colors.paths, + path_status_color=layout_config.general.colors.text.disabled, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + ) + + +@pytest.fixture +def rendered_panel(panel: GUIReconstructionAudioPanel) -> GUIReconstructionAudioPanel: + with dpg.window(tag=ROOT_TAG): + panel.create_panel(ROOT_TAG) + + return panel + + +def _view_model(nes_frequency: Optional[int]) -> ReconstructionViewModel: + empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()) + return ReconstructionViewModel( + reconstruction_loaded=nes_frequency is not None, + playing_channels=frozenset(), + selected_channels=frozenset(), + reconstruction_file=empty_path, + original_audio=empty_path, + nes_frequency=nes_frequency, + ) + + +class TestEngineRateReadout: + """The rate the card states for the open reconstruction.""" + + def test_a_loaded_reconstruction_states_its_rate( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY)) + + assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) == format_nes_frequency( + PAL_FREQUENCY, + ) + + def test_an_empty_tab_leaves_the_readout_blank( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY)) + rendered_panel.update_view(_view_model(None)) + + assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) == "" + + def test_the_rate_reads_as_a_monospaced_figure( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + readout = dpg.get_item_info(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) + + assert readout["font"] == FontRegistry.get_tag(Font.MONO) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py index 4566f66b3..736a1a0d5 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -90,6 +90,7 @@ def _view_model( selected_channels=selected, reconstruction_file=empty_path, original_audio=empty_path, + nes_frequency=None, ) diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 8adf2b208..c0d457848 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -101,6 +101,7 @@ def test_enablement_follows_original_audio_state( selected_channels=frozenset(), reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()), original_audio=ReconstructionPathViewModel(state=case.original_audio_state, paths=()), + nes_frequency=None, ) assert view_model.audio_source_enabled is case.audio_source_enabled From 8b1a8484c7750c5f746dd66ebf046c720084d6b8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 00:51:28 +0200 Subject: [PATCH 070/142] Added: NSF compression scheme explanation --- docs/concepts/compression.md | 306 +++++++++++++++++++++++++++++++++++ docs/development/player.md | 3 + docs/formats/nsf.md | 3 +- docs/guide/sequencer.md | 4 +- docs/index.md | 1 + 5 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 docs/concepts/compression.md diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md new file mode 100644 index 000000000..8c805d380 --- /dev/null +++ b/docs/concepts/compression.md @@ -0,0 +1,306 @@ +# Song compression + +This document explains how a whole song is squeezed into the space an NES program +has for it. It is written to be readable without the source code, and it covers +the ideas rather than the bytes: the layout the encoder writes is documented in +[NSF export](../formats/nsf.md), and the package that implements it, along with +the driver that reads it back, in [the console player](../development/player.md). + +The other exports _SampleToNES_ writes describe a song to a program that already +knows how to play one. An `.nsf` carries its own player, so the song and the code +that reads it share one 32 KB program area, and every byte the song takes is a byte +the console has to hold in cartridge space. This is what makes compression part of +the format rather than a convenience on top of it. + +The scheme is four layers, and each can be switched off on its own so that what it +saves can be measured (§6). Nothing here is lossy: the values the console writes to +its sound registers are exactly the values the sequencer plays. + +## 1. The problem + +A song reaches the console as **ticks** — the fixed-rate slices a reconstruction's +envelopes advance through, the same slices the sequencer sounds a row in. On every +tick each of the four channels has a full set of register values, and written out +plainly that is 11 bytes a tick: three each for the two pulse channels and the +triangle, two for the noise. + +At 60 ticks a second, 11 bytes a tick fills the space behind the driver in **49 +seconds**. A song of three minutes needs 118800 bytes and the console has about +32000. So either songs stay under a minute, or the stream is stored in a form the +driver can unpack as it plays. + +The content in those bytes is far smaller than the bytes themselves. What a tick +actually says is a volume, a duty cycle, a pitch and a noise period — roughly four +bytes' worth even before anything repeats. And a great deal repeats: a channel +resting through a passage writes the same three bytes hundreds of times over, and a +song built by playing the same drum sample at forty rows writes that sample's +envelopes forty times. + +## 2. What a song looks like from the inside + +### 2.1 Planes + +The eleven bytes of a tick are stored channel by channel, so a channel's volume, its +pitch low byte and its pitch high byte sit next to each other. That adjacency is +exactly what hides the repetition: three unrelated series braided together turn over +at every tick even when each of them is nearly constant. + +The first thing the encoder does is unbraid them. Each register becomes a **plane** — +one byte per tick, the whole song long — and each plane is a series of its own: a +volume envelope that falls and holds, a pitch line that steps between notes, a duty +cycle that barely moves. An idle channel's planes become one value repeated, which +costs almost nothing to state. + +This one change is most of the win. Split into planes and coded, the three-minute +arrangement falls from 11 bytes a tick to about 1.7. + +### 2.2 Pitches instead of dividers + +A tone channel names a pitch by the **divider** the hardware counts down from, which +takes two bytes and runs the opposite way to the note: higher notes have smaller +dividers, and the steps between them are uneven. The encoder replaces the two +divider planes with one **pitch index** — how far the note sits above the lowest +pitch the table covers — and the song block carries a table the driver resolves it +through. Every channel is then two planes, and a tick is eight bytes before any +coding at all. + +Saving a byte a tick is the smaller half of why this matters. The larger half is +that **a pitch index can be transposed and a divider cannot.** The same figure played +at five pitches is five unrelated byte sequences in divider space; in index space it +is one sequence and five offsets. That is what turns a repeated sample into a single +dictionary entry in §5. + +## 3. The token language + +Each plane is written as a sequence of **tokens**, and the driver reads them forward, +one tick at a time. There are three things a token can say, and the opcode byte's top +two bits say which: + +- **Hold** — keep the value the plane reached, for up to 64 ticks. One byte. +- **Literal** — take the next few bytes, one per tick, up to 64 of them. One byte plus + the values. +- **Phrase** — play entry *p* of the dictionary, for up to 256 ticks, optionally with + every value shifted. Two bytes, three if the phrase needs a full id byte or a shift, + four if it needs both. + +Literals alone can write any plane, so the codec always has an answer; holds and +phrases are what make that answer short. + +Two properties of the encoding do most of the work later: + +**A token's count is a duration, not a length.** A phrase token says how many *ticks* +it covers, and that may run past the phrase's last value — past the end, the plane +holds that value onward. This is what a note does when its envelope has finished and +the note is still sounding, and it means one dictionary entry serves the same figure +however long it is held. A count shorter than the body cuts the note off, which is +what a tracker does when the next note arrives early. So one entry covers every length +a figure is played at. + +**A shift is added within the byte.** A transposed phrase carries one byte that is +added to each of its values, wrapping at 256. On the 6502 that is a single addition, +and a fall in pitch is simply the byte that wraps around to it. Together with the +duration rule, one entry covers every pitch *and* every length a figure is played at. + +## 4. Reading a plane the cheapest way + +A plane usually admits many readings. A run of eight identical values can be one hold, +or two holds, or a literal, or the tail of a phrase somebody else pays for. The +readings cost different numbers of bytes, and the differences compound over a song of +thousands of ticks. + +So the encoder does not pick a reading by rules of thumb; it searches for the cheapest +one. The plane becomes a graph: each tick is a node, each token that could start there +is an edge to the tick after the ones it covers, and the edge's weight is the bytes +that token takes. **The cheapest path across the plane is its encoding** — and because +the weights are bytes, the search optimises the very quantity that has to fit in the +program area. + +### 4.1 The edges + +From each tick, the encoder offers: + +- a **hold**, where the value repeats the one before it, running as far as the repeated + run does, capped at 64 ticks; +- a **literal**, reaching this tick from the cheapest start within the last 64 ticks; +- a **phrase**, one edge per dictionary entry the plane plays from here, covering as + many ticks as the plane agrees with it plus however long its last value carries. + +Literals need care, because every one of the 64 possible starts is a candidate and +checking them all would make the parse quadratic. A literal costs its opcode and its +bytes whatever its length, so a start that is beaten by a later one is beaten for good; +keeping the live starts in that order leaves the best of them at the front, and the +whole plane's literals are priced in one pass. + +### 4.2 Why the search beats taking the longest match + +The obvious alternative — at each tick take the longest phrase that matches, otherwise +hold, otherwise spell out — is wrong in a way that shows up constantly. Taking a +40-tick phrase for two bytes looks better than taking a 30-tick one, until it turns out +that stopping at 30 would have let the next 200 ticks be a single hold. Costs also +depend on the dictionary: the same phrase is two bytes with a cheap id and four with an +escaped id and a shift. The search weighs those against each other; a rule of thumb +cannot. + +### 4.3 Where a song comes round + +A song that repeats re-enters its streams partway through rather than at the beginning, +and the driver arrives there with nothing behind it: it points each plane at a byte the +header names and starts reading. For that to work, the loop tick has to **begin** a +token on every plane, and that token has to state its values outright rather than lean +on a value the plane reached earlier. + +The parse takes this as a constraint. The loop tick is a boundary: tokens may end there +and start there, and none may span it. A hold is barred from starting there, since a +hold is precisely a token that leans on what came before. Literals and phrases both +state their own values, so either can open the loop. + +## 5. The dictionary + +The third token kind needs something to name. The dictionary is a table of **phrases** — +runs of values a plane plays — stored once in the song block and named by tokens +wherever they occur. + +A phrase's position in the table is its id, and the ids are not equally priced: the +first 63 ride inside the opcode byte, and the rest need a byte of their own. So the +order of the table is part of the encoding, and the phrases a song leans on hardest +belong at the front. + +### 5.1 The instruments seed it + +A song is built by placing samples at rows, so the shapes its planes repeat are +**knowable in advance rather than discoverable**. Each sample slice offers the two +planes it writes, at the pitch and level it was reconstructed at, and every row playing +that sample becomes a token naming those entries with the shift the row asks for. No +search is involved, and this is where most of a project's compression comes from: on +the three-minute arrangement the instruments alone take it from 1.6 bytes a tick to +1.13, and transposition to 0.98. + +A project can offer more phrases than the table holds. When it does, each is weighed by +what it would actually spare the song, and the ones that pay most keep their place; the +export says so in the log rather than quietly dropping whichever sample happened to be +listed last. + +### 5.2 The search fills the rest + +The instruments cover the notes, and leave behind everything else: rests, tails, the +transitions between rows, and the whole of a reconstruction export, where each slice is +played exactly once and nothing repeats by construction. + +The search works over that residue — the spans the current parse still spells out as +literals. It gathers every run of 3 to 48 ticks that appears in them and groups them by +**shape**: the step from each value to the next, so a figure played at five pitches +collects into one candidate seen five times. Candidates occurring at least twice, in +places that do not overlap, are scored: + +``` +gain = what the current parse pays for those spans today + − what tokens naming the phrase would pay instead + − the entry the phrase takes in the dictionary +``` + +Scoring against **the current parse** rather than against raw length is what keeps the +search honest. A run of 200 identical values looks enormous by length and is worth +nothing, because a hold already covers it for one byte. Only spans the parse is +genuinely paying for can pay a candidate back. + +The best few candidates of each round are then confirmed the expensive way: the whole +song is parsed again with each one added, and the round keeps whichever actually +shrank the total. The estimate ranks; the re-parse decides. Rounds continue until a +round earns nothing. + +### 5.3 Every entry pays for itself + +An entry costs its table slot, its length byte and its values, whether or not anything +names it — so being used is not the same as being worth keeping. After the search, each +phrase is weighed against a reading of the song in which no phrase exists at all, and +the ones sparing fewer bytes than their entry takes are dropped. + +Dropping them changes which ids are cheap, which changes the parse, which changes what +each phrase is worth. So the table is rebuilt with the busiest phrases first, the song +is parsed again, and the process repeats until it settles — a few rounds at most. + +### 5.4 Matching is measured once + +The encoder parses the whole song many times: once as a baseline, once per confirmed +candidate, once per settling round. A parse asks the same question at every tick — what +does this phrase play here, and for how many ticks — and the answer depends only on the +plane and the phrase. It cannot change between parses. + +So it is measured once per plane per phrase and kept for the whole encoding. A search +round that adds one phrase measures that one phrase; everything already in the table +answers from the reading taken when it arrived. This turns the cost of an encode from +*parses × dictionary* into *dictionary*, and it is the largest reason a three-minute +song encodes in about two seconds. Phrases are also offered only at the ticks whose +first two steps match their own, so a reading covers the handful of places a phrase +could begin rather than every tick of the song. + +## 6. What it achieves + +Measured over a three-minute arrangement, 10800 ticks, each layer added to the ones +above it: + +| what is stored | bytes per tick | ratio | ticks that fit | +|---|---|---|---| +| a record per tick per channel | 11.000 | 1.00 | 2925 | +| planes, coded | 1.735 | 6.34 | 18544 | +| planes with a pitch index | 1.598 | 6.88 | 20255 | +| phrases from the instruments | 1.126 | 9.77 | 28994 | +| phrases played transposed | 0.976 | 11.27 | 33567 | +| phrases from the search as well | **0.811** | **13.56** | **40673** | + +The whole song is 8761 bytes of the roughly 32000 available, and **40673 ticks is 11.3 +minutes at 60 Hz**, against the 49 seconds a record per tick reaches. Encoding it costs +about two seconds; decoding it costs the console around twenty instructions per plane +per tick, comfortably inside a video frame. + +`make compression-report` writes this table over a corpus of songs, and the format's +constants are settled from it. Two of them were settled against expectation: splitting +the duty cycle out of the control byte into a plane of its own **costs** 14 %, because +volume and duty turn over together and a split pays two opcodes for what one covers; +and the pitch index earns its place twice, 8 % directly and a further 13 % through the +transposition it makes possible. + +## 7. Limitations + +- **The search struggles on dense reconstructions.** A reconstruction whose planes turn + over at nearly every tick offers enormous numbers of candidates, and past a cap the + search stops gathering and earns nothing for that song. It bites at lengths where the + song already exceeds the program area, so it costs a diagnosis rather than a song, but + a dense export of two minutes is stored materially larger than the same song at one. +- **The dictionary holds 255 phrases.** A project of roughly 30 to 60 samples fills it + from its instruments alone, at which point the search has no room left to work in and + further samples compete for slots on measured value. +- **A phrase holds at most 255 values and a token covers at most 256 ticks.** Longer + figures are stated as several tokens, which costs a couple of bytes each time. +- **Compression is per plane.** Two channels playing the same figure at once share + dictionary entries, and nothing exploits the correlation between a channel's own + control and value planes. + +## Appendix — the shape of the format + +| quantity | value | +|---|---| +| planes | 8 — control and value, for each of four channels | +| bytes per tick before coding | 8 | +| ticks one hold covers | 1 to 64 | +| values one literal carries | 1 to 64 | +| ticks one phrase token covers | 1 to 256 | +| phrase ids inside the opcode | 63 | +| phrases in the dictionary | up to 255 | +| values in a phrase | up to 255 | +| candidate lengths the search gathers | 3 to 48 | +| decoder state on the console | 64 bytes of zero page, 8 per plane | + +Where things live: + +| concern | module | +|---|---| +| planes, and the pitch index | `sampletones_player.compression.planes`, `.pitch` | +| the token kinds and their byte costs | `sampletones_player.compression.tokens` | +| the cheapest reading of a plane | `sampletones_player.compression.parse` | +| the dictionary, its entries and its pruning | `sampletones_player.compression.dictionary` | +| phrases the instruments offer | `sampletones_player.compression.seeds` | +| phrases the search earns | `sampletones_player.compression.search` | +| what a phrase plays against a plane | `sampletones_player.compression.matches` | +| encoding, and the decoder the driver is held to | `sampletones_player.compression.encode`, `.decode` | +| the opcode layout and its bounds | `sampletones_player.specification.compression` | diff --git a/docs/development/player.md b/docs/development/player.md index 675bf8c46..4a2efe520 100644 --- a/docs/development/player.md +++ b/docs/development/player.md @@ -63,6 +63,9 @@ than sounded half in tune. The codec turns the four channels' per-tick register values into the eight token streams the driver reads, and back again. `compression/decode.py` is the golden model: every encoding is held against it, so what the console plays and what the encoder meant are the same values. +The scheme itself, with the measurements each layer is settled on, is +[song compression](../concepts/compression.md); what governs it here is where each part +belongs and what holds it. **Planes.** A channel's registers for one tick sit adjacent, which is exactly the interleaving that destroys self-similarity — a volume envelope, a pitch line and a timbre diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md index 6f915a1e0..f7d8484e4 100644 --- a/docs/formats/nsf.md +++ b/docs/formats/nsf.md @@ -5,7 +5,8 @@ console or an NSF player loads, and the song block inside it that the player's o driver reads. Read it before changing anything under `sampletones_player/nsf/`, `sampletones_player/compression/`, or the assembly under `sampletones_player/driver/`. The design behind the format — why a song is stored this way and how the driver is held -to it — is in [the player](../development/player.md); the layout itself is here. +to it — is in [the player](../development/player.md), and the compression scheme is explained +in [song compression](../concepts/compression.md); the layout itself is here. An `.nsf` is unlike the tracker exports beside it. A [FamiTracker](famitracker.md) or [Bitphase](bitphase.md) file describes a song to a program that already knows how to play diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 2529ca061..8a3722ef7 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -216,7 +216,9 @@ the limits it respects. the console itself plays, carrying its own player, so it needs no tracker to sound. The console holds one program in 32 KB, so a long song can outgrow it — the export says so rather than writing a file that plays part of itself. See -[NSF export](../formats/nsf.md) for what the file holds. +[NSF export](../formats/nsf.md) for what the file holds, and +[song compression](../concepts/compression.md) for how a song of minutes is fitted +into that space. ## Rendering to audio diff --git a/docs/index.md b/docs/index.md index b86ea9672..a40a463e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,7 @@ reconstruction. It is written to be read without the source code. - [Reconstruction algorithms](concepts/reconstruction.md) — how a sample becomes a stream of NES instructions. - [Stems reconstruction](concepts/stems.md) — how one reconstruction is assigned across several stems. - [Instruction library](concepts/instruction-library.md) — the catalogue of NES sounds the search draws from. +- [Song compression](concepts/compression.md) — how a whole song is fitted into the space an NES program has for it. - [Project](concepts/project.md) — a whole composition: a song and the reconstructions it is built from. - [Calibration](concepts/calibration.md) — how the reconstruction's settings are tuned by experiment. From 20f6bfa58ff97ed0bddcdcaa28f2a73bcd87c484 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 01:34:36 +0200 Subject: [PATCH 071/142] Matched: each stem against its own recording --- CHANGELOG.md | 1 + docs/concepts/reconstruction.md | 39 ++-- docs/concepts/stems.md | 116 +++++++----- .../logic/reconstruction/data.py | 32 ++-- src/sampletones_core/audio/__init__.py | 3 +- src/sampletones_core/audio/io.py | 68 ++++++- src/sampletones_core/constants/algorithm.py | 9 +- .../reconstructions/criterion/criterion.py | 18 +- .../reconstructions/criterion/spectral.py | 37 +++- .../reconstructions/reconstructor/matching.py | 4 + .../reconstructor/reconstructor.py | 89 +++++---- .../reconstructions/reconstructor/scorer.py | 16 ++ .../reconstructor/stems/assignment/frame.py | 25 +-- .../reconstructor/stems/assignment/session.py | 76 ++++++-- .../test_stems_reconstruction.py | 175 ++++++++++++++++-- .../logic/reconstruction/test_data.py | 25 ++- tests/unit/sampletones_core/audio/test_io.py | 85 +++++++++ .../reconstructions/reconstructor/conftest.py | 31 +++- .../reconstructor/stems/conftest.py | 10 + .../reconstructor/stems/test_equivalence.py | 37 ++-- .../reconstructor/stems/test_frame.py | 146 ++++++++++++++- 21 files changed, 844 insertions(+), 198 deletions(-) create mode 100644 tests/unit/sampletones_core/audio/test_io.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb2c66e0..b30d05826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added NSF player and export. * Added stems conversion: mix several recordings into one reconstruction +* Matched each stem against its own recording, so a stem plays what was recorded on it * Added a per-source channel cap * Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`. diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index deb1d6893..7ca07a486 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -47,7 +47,9 @@ have very different waveforms depending on phase. input through a fixed sequence of stages: 1. **Load** the audio (`sampletones_core.audio`) — mix to mono, resample, and - optionally clean it up (normalize, quantize). + optionally clean it up (normalize, quantize). Several sources load together, so + one scale drawn from the peak of their sum holds them at the balance they were + captured in. 2. **Set a working level** — scale the whole signal so its typical loudness sits in the range the NES channels can reproduce, keeping quiet passages matchable (§3.4). 3. **Fragment** it into short, fixed-length frames @@ -56,7 +58,8 @@ input through a fixed sequence of stages: 4. **Describe each frame** by a spectral feature that captures its frequency content (§3). 5. **Assign** every frame's channels to the sources, and with each channel the - candidates it may sound there, judged by the criterion (§5 and §4). + candidates it may sound there, each source judged against its own audio by the + criterion (§5 and §4). 6. **Decode** each channel's stream, reading its candidates across the whole recording (§5). 7. **Render** the chosen instructions back into audio through the generators, @@ -199,24 +202,31 @@ A frame is assigned one pick at a time: ``` free = {channels the setup covers} +residual[source] = that source's own frame, for every source that sounds in it while a source may still take a channel and free is non-empty: - pick the single (source, channel, instruction) with the lowest cost - across every candidate of every channel that source may still take - subtract its rendered contribution from the frame's residual + pick the single (source, channel, instruction) covering the most of + residual[source], across every channel that source may still take + subtract its rendered contribution from residual[source] assign it and remove that channel from `free` ``` -Every pick lets whichever channel fits the residual best go first. Where several -channels share one generator kind, the lowest free channel of that kind represents it -during scoring, so successive picks over one kind land on the lowest free channel. A -channel still free when the picks end **rests**: it holds its channel's null +Every pick lets whichever channel fits that source's residual best go first. Where +several channels share one generator kind, the lowest free channel of that kind +represents it during scoring, so successive picks over one kind land on the lowest free +channel. A channel still free when the picks end **rests**: it holds its channel's null instruction for that frame, which is what keeps every channel's stream in step with the frames it describes. +A source takes a channel in the frames its own audio reaches a level a channel can +render, and stands aside in the rest, so a frame it is silent in leaves its channels +resting. + A classic single-file conversion is one source covering every enabled channel, so the -loop above assigns each channel exactly once per frame. Several sources, a precedence -hierarchy and a per-source channel cap are the general case, described in -[Stems reconstruction](stems.md). +one residual is the frame itself and the loop assigns every channel in each frame the +source sounds in. Several sources, a precedence hierarchy and a per-source channel cap +are the general case, described in [Stems reconstruction](stems.md); there each residual +holds one source's own sound, which is what makes the channel a source wins carry that +source's material. ### 5.2 Greedy decoding @@ -267,8 +277,9 @@ reconstruction and the original can be shown and played on a common scale. - **CQT time resolution.** Because constant-Q analysis needs long windows at low frequencies, low-pitched transients are inherently smeared in time under `cqt`; `fft`/`logfft` localize time better at the cost of low-frequency resolution. -- **Per-channel independence in Viterbi.** Channels are decoded independently after a - shared residual is formed, which is fast but not jointly optimal across channels. +- **Per-channel independence in Viterbi.** Channels are decoded independently once the + assignment has settled their columns, which is fast but not jointly optimal across + channels. ## Appendix — key parameters and where things live diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index 783bc7d4b..aa1127c45 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -7,11 +7,11 @@ names, reveals, and plays the recorded stems. The single-sample pipeline this builds on is described in [Reconstruction](reconstruction.md), and the stored record in [Reconstructions](../formats/reconstructions.md). -A stems reconstruction converts several audio stems at once. The stems are -mixed and the mix is matched against the instruction library; within each frame, -the channels are handed to the stems one pick at a time, following a precedence -hierarchy. The result is one reconstruction whose `stems_data` records, per -channel and frame, which stem's stream plays. +A stems reconstruction converts several audio stems at once. Each stem is matched +against the instruction library on its own; within each frame, the channels are +handed to the stems one pick at a time, following a precedence hierarchy. The +result is one reconstruction whose `stems_data` records, per channel and frame, +which stem's stream plays. ## Principles @@ -28,24 +28,38 @@ This is what lets a channel cap, a hierarchy and a per-source channel set reach every conversion alike, and what keeps the classic run from being a second path that has to be kept in step. -### 2. The mix is the target +### 2. A stem is matched against its own recording -Every stem is loaded and normalized on its own, padded to the longest stem's -length, and summed. Frames and residuals come from the mix; a stem's own audio -takes no separate part in matching. The working-level coefficient is computed -from the mix, exactly as for a single file. +Every stem is loaded, padded to the longest stem's length, and framed on its own, +so a stem's picks are scored against the sound that stem contributes and the +channels it wins carry that recording. Ownership and content then say the same +thing: a stem heard on its own plays what was recorded on it. -### 3. One greedy pick at a time +The mix keeps the two jobs it answers: the whole set is scaled by one factor drawn +from the peak of its sum, which holds the stems at the balance they were captured +in, and the working-level coefficient is measured on that mix, exactly as for a +single file. The reconstruction the run assembles is the sum of the stems' +approximations, which approximates the mix because each part approximates its part. -A pick scores each eligible stem's candidates against the current residual with -the same two-stage criterion the single-sample pipeline uses (`FrameMatcher`), -takes the cheapest choice across the active level, subtracts its approximation -from the residual, and consumes the channel. Picks continue until every stem -channel is assigned, or caps and free channels are exhausted. Matching against -the residual is what keeps later picks from re-approximating content earlier -picks already cover. +### 3. A stem sounds where its recording sounds -### 4. A frame is answered whole +A stem takes a channel in the frames its own recording reaches a level a channel +can render, and stands aside in the rest. A channel a passing stem leaves free goes +to a stem that does sound there, or rests. This is what keeps a recording quiet +through a passage from sounding that passage on the channels it holds elsewhere. + +### 4. One greedy pick at a time + +A pick scores each eligible stem's candidates against what is left of that stem's +own frame, with the same two-stage criterion the single-sample pipeline uses +(`FrameMatcher`), takes the winning offer across the active level, subtracts its +approximation from that stem's residual, and consumes the channel. Picks continue +until every stem channel is assigned, or caps and free channels are exhausted. +Each stem carrying a residual of its own is what keeps its later picks from +re-approximating what its earlier picks already cover, while leaving what the other +stems sound out of it. + +### 5. A frame is answered whole Every channel the setup covers leaves a frame either picked or **resting**. A resting channel holds its channel's null instruction over a silent frame and @@ -59,7 +73,7 @@ cap left unclaimed would shorten that channel's streams and carry its later frames early, so what the channel plays would drift out of step with the recording it was matched against. -### 5. Ownership and decoding compose +### 6. Ownership and decoding compose The assignment answers *which stem owns which channel this frame*; the decoder answers *what that channel plays across frames*. Each pick leaves the channel it @@ -70,7 +84,7 @@ decoder as a column of one, so a channel a cap left free sits in the path as the off state it is. See [Reconstruction §5](reconstruction.md) for the decoders themselves. -### 6. Precedence orders, mode alternates +### 7. Precedence orders, mode alternates The hierarchy groups stem ids into levels that pick in the listed order. In `strict` mode a level exhausts its stems' channel caps before the next level @@ -78,13 +92,22 @@ picks; in `round_robin` mode the levels take turns, granting every level's stems one channel per round. Both modes let every stem hold at most `channel_cap` channels per frame. -### 7. Ties resolve deterministically +### 8. A level's channel goes to the stem with the most to render -Equal-cost choices go to the stem earlier in level order. Channels of one kind -resolve to the lowest free channel, so successive picks over one kind land on -the lowest free channel and a rerun assigns the same way every time. +A cost is a fraction of its own recording's energy, so two stems' costs stand on +different scales and comparing them alone would hand a channel to whichever +recording is easiest to approximate. Within a level, an offer is therefore ranked +by the energy its candidate covers — the cost weighted by the energy behind it — so +the channel reaches the stem with the most sound waiting. Precedence between levels +stays the hierarchy's, which is what a reader arranges the levels to say. -### 8. The single-sample case stays exact +### 9. Ties resolve deterministically + +Equal offers go to the stem earlier in level order. Channels of one kind resolve to +the lowest free channel, so successive picks over one kind land on the lowest free +channel and a rerun assigns the same way every time. + +### 10. The single-sample case stays exact One stem covering every enabled channel, with a cap at the channel count, reproduces the classic greedy reconstruction pick for pick. Property tests hold @@ -92,7 +115,7 @@ the assignment against an independent restatement of that reconstruction — identical choices, instructions, and approximations — so the one pipeline serves the single-sample case exactly as it stands. -### 9. The working level follows the frame budget +### 11. The working level follows the frame budget A frame reaches as loud as the channels that may sound in it, so the level the mix is scaled to is measured against the mixer weights of the loudest covered @@ -124,19 +147,26 @@ neither built nor stored, and it derives the views the run reads (`entries_by_id The assignment lives in `reconstructor/stems/assignment/`: -- `assign_frame` validates the setup against the run's channels and answers one - frame whole: the picks in the order they were made, each with its candidate - column, together with the channels left resting; -- `AssignmentSession` carries one frame's progress — the residual, the free - channels, the per-stem counts — and runs the hierarchy's mode; +- `assign_frame` takes this frame of every stem, keyed by stem id, validates the + setup against the run's channels, and answers the frame whole: the picks in the + order they were made, each with its candidate column, together with the channels + left resting; +- `AssignmentSession` carries one frame's progress — each stem's residual, the free + channels, the per-stem counts, and the stems sounding in the frame — and runs the + hierarchy's mode. It ranks a level's offers by `StemOffer.bid`, the energy a + candidate covers, which the matcher measures through `reference_energy`; - `TrackAssignment` gathers the frames into what the rest of the run reads: the lattice each channel offers the decoder, and the stem owning each of its frames. -`Reconstructor.reconstruct` loads the sources, mixes them, assigns every frame, -releases the channels that rested throughout, decodes the remaining lattices, -and folds the decoded streams into the state in frame order — the order each -generator's oscillator phase is carried in. +`Reconstructor.reconstruct` loads the sources through `load_stems`, which brings +them to one length and one scale, measures the working level on their mix, frames +each of them, assigns every frame, releases the channels that rested throughout, +decodes the remaining lattices, and folds the decoded streams into the state in +frame order — the order each generator's oscillator phase is carried in. + +`STEM_ACTIVITY_FLOOR` is the level a stem's frame reaches to take a channel: the +quietest note any channel renders, measured against the working level. The record stored in a reconstruction (`stems_data`) holds the stems setup the assignment was made under and, per channel, the stem id holding each frame, @@ -157,13 +187,13 @@ in entry order; the serialized form carries them in order. The application reads them through `source_paths`: empty once the reconstruction is detached from its origin, one path for a single source, the tuple for stems. -Opening the document loads each recorded stem the way a single source loads -(resampled, normalized and quantized as the configuration asks) and mixes them -with `mix` — padded to the longest stem and summed. The mix is the -original audio the source toggle and the waveform offer, computed fresh on every -load. A recorded stem absent or unreadable on this machine follows the -single-source rule: the whole original is unavailable, the approximation stands -on its own, and the application names the first missing path in its dialog. +Opening the document loads the recorded stems through `load_stems`, the same call +the conversion loads them with, so each one carries the level it holds in the mix +and a stem heard on its own sounds at that level. The mix of them is the original +audio the source toggle and the waveform offer, computed fresh on every load. A +recorded stem absent or unreadable on this machine follows the single-source rule: +the whole original is unavailable, the approximation stands on its own, and the +application names the first missing path in its dialog. The document's name follows the naming rules in `sampletones_core.reconstructions.naming`, applied to the recorded paths in diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index ed058d181..d45ff5dd0 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -7,7 +7,7 @@ from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.audio import load_audio, mix +from sampletones_core.audio import load_stems, mix from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @@ -140,6 +140,9 @@ def _load_stem_audios( ) -> Tuple[np.ndarray, ...]: """Loads the recorded source, one recording per path, in path order. + The set is loaded together, so every recording carries the level it holds in the + mix and one heard on its own sounds at that level. + A reconstruction detached from its origin (a project sample) records no source path, and a file-backed reconstruction may point at audio absent or unreadable on this machine. One unreadable stem costs the whole original, so the recordings @@ -151,22 +154,17 @@ def _load_stem_audios( return () config = reconstruction.config - recordings: List[np.ndarray] = [] - for path in source_paths: - try: - recordings.append( - load_audio( - path=path, - target_sample_rate=config.library.sample_rate, - normalize=config.general.normalize, - quantize=config.general.quantize, - ) - ) - except (FileNotFoundError, IsADirectoryError, PermissionError, OSError): - logger.warning(f"Could not load original audio from '{path}'. The original is unavailable") - return () - - return tuple(recordings) + try: + return load_stems( + source_paths, + target_sample_rate=config.library.sample_rate, + normalize=config.general.normalize, + quantize=config.general.quantize, + quantization_levels=config.general.quantization_levels, + ) + except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as error: + logger.warning(f"Could not load the original audio: {error}. The original is unavailable") + return () @cached_property def original_audio(self) -> Optional[np.ndarray]: diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index 3648e3ca6..39045bf5c 100644 --- a/src/sampletones_core/audio/__init__.py +++ b/src/sampletones_core/audio/__init__.py @@ -1,5 +1,5 @@ from .device import AudioDevice, CurrentDevice -from .io import load_audio, read_wave, write_wave +from .io import load_audio, load_stems, read_wave, write_wave from .manager import CHANNELS, FORMAT, AudioDeviceManager from .mixing import align, common_length, mix from .processing import ( @@ -35,6 +35,7 @@ "common_length", "interpolate", "load_audio", + "load_stems", "minmax_decimate", "mix", "normalize", diff --git a/src/sampletones_core/audio/io.py b/src/sampletones_core/audio/io.py index 6d6eedbba..187923a6b 100644 --- a/src/sampletones_core/audio/io.py +++ b/src/sampletones_core/audio/io.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Sequence, Tuple import numpy as np from scipy.io import wavfile @@ -8,6 +8,7 @@ from sampletones_core.constants.algorithm import QUANTIZATION_LEVELS from sampletones_shared.types.path import Pathlike +from .mixing import align, common_length, mix from .processing import clip_audio from .processing import normalize as normalize_audio from .processing import quantize as quantize_audio @@ -118,3 +119,68 @@ def load_audio( audio = quantize_audio(audio, levels=quantization_levels) return audio + + +def load_stems( + paths: Sequence[Pathlike], + *, + target_sample_rate: int, + normalize: bool, + quantize: bool, + quantization_levels: int, +) -> Tuple[np.ndarray, ...]: + """ + Load a set of recordings onto one shared scale and one shared length. + + The recordings of one piece stand in a balance the piece was mixed at, so the whole + set is scaled by a single factor drawn from the peak of their sum. Each recording + then keeps the level it holds in the mix, which is what lets one of them be heard on + its own at the level it sounds there. Quantization follows the scaling, the order it + is defined against. + + Scaling one recording by the peak of its own sum is normalizing it, so a set of one + reaches exactly what :func:`load_audio` answers for that path. + + Args: + paths: Paths to the recordings, in the order they are returned. + target_sample_rate: Sample rate every recording is resampled to. + normalize: Whether the set is scaled to the peak of its mix. + quantize: Whether each scaled recording is quantized. + quantization_levels: Number of amplitude levels used when quantization is enabled. + + Returns: + The recordings in the order given, each one the length of the longest. + + Raises: + ValueError: If ``target_sample_rate`` is not in the allowed sample rates. + FileNotFoundError: If a path names no file. + IsADirectoryError: If a path points at a directory. + """ + recordings = [ + load_audio( + path, + target_sample_rate=target_sample_rate, + normalize=False, + quantize=False, + ) + for path in paths + ] + aligned = align(recordings, common_length(recordings)) + scaled = _scaled_to_mix(aligned) if normalize else aligned + if quantize: + return tuple(quantize_audio(recording, levels=quantization_levels) for recording in scaled) + + return tuple(scaled) + + +def _scaled_to_mix(recordings: List[np.ndarray]) -> List[np.ndarray]: + """The recordings divided by the peak their mix reaches, silence left as it is.""" + finite = [np.nan_to_num(recording, nan=0.0, posinf=0.0, neginf=0.0) for recording in recordings] + if not finite: + return finite + + peak = float(np.max(np.abs(mix(finite)))) + if peak == 0.0: + return finite + + return [(recording / peak).astype(np.float32) for recording in finite] diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index d90c0afe8..ab0e5b502 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -7,7 +7,13 @@ SelectorName, SpectralDistance, ) -from sampletones_core.constants.general import MAX_VOLUME, MIN_VOLUME +from sampletones_core.constants.general import ( + MAX_VOLUME, + MIN_VOLUME, + MIXER_NOISE, + MIXER_PULSE, + MIXER_TRIANGLE, +) # Matching floors @@ -71,6 +77,7 @@ DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN RESTING_STEM_ID: Final[int] = -1 RESTING_FRAME_COST: Final[float] = 0.0 +STEM_ACTIVITY_FLOOR: Final[float] = TEMPORAL_LEVEL_FLOOR * min(MIXER_PULSE, MIXER_TRIANGLE, MIXER_NOISE) # Execution diff --git a/src/sampletones_core/reconstructions/criterion/criterion.py b/src/sampletones_core/reconstructions/criterion/criterion.py index 95e5eb3c1..2ad37a4af 100644 --- a/src/sampletones_core/reconstructions/criterion/criterion.py +++ b/src/sampletones_core/reconstructions/criterion/criterion.py @@ -6,7 +6,7 @@ from sampletones_core.structures.histogram import Histogram from sampletones_shared.array import xp -from .spectral import calculate_spectral_loss +from .spectral import calculate_spectral_loss, weighted_reference_energy from .temporal import calculate_temporal_loss from .weights import calculate_spectral_weights @@ -55,6 +55,22 @@ def spectral_loss( divergence_beta=self.divergence_beta, ) + def reference_energy(self, feature: Union[xp.ndarray, Histogram]) -> xp.ndarray: + """ + Weighted energy the target feature holds, the scale its spectral loss is measured against. + + Args: + feature: Target feature, as a histogram or its values. + + Returns: + The target's weighted energy. + """ + return weighted_reference_energy( + _feature_values(feature), + self.weights, + distance=self.spectral_distance, + ) + def temporal_loss( self, audio: xp.ndarray, diff --git a/src/sampletones_core/reconstructions/criterion/spectral.py b/src/sampletones_core/reconstructions/criterion/spectral.py index 8324d3ba5..2c75e988d 100644 --- a/src/sampletones_core/reconstructions/criterion/spectral.py +++ b/src/sampletones_core/reconstructions/criterion/spectral.py @@ -47,10 +47,8 @@ def calculate_spectral_loss( axis=-1, ) ) - denominator = xp.sqrt(xp.sum(weights * reference**2, axis=-1)) case SpectralDistance.ABSOLUTE: numerator = xp.sum(weights * xp.abs(candidates - reference), axis=-1) - denominator = xp.sum(weights * reference, axis=-1) case SpectralDistance.BETA_DIVERGENCE: numerator = xp.sum( weights @@ -61,13 +59,46 @@ def calculate_spectral_loss( ), axis=-1, ) - denominator = xp.sum(weights * reference, axis=-1) case _: raise ValueError(f"Unsupported spectral distance: {distance}") + denominator = weighted_reference_energy(reference, weights, distance=distance) return numerator / (denominator + SPECTRUM_FLOOR) +def weighted_reference_energy( + reference: xp.ndarray, + weights: xp.ndarray, + *, + distance: SpectralDistance, +) -> xp.ndarray: + """ + The target's own weighted energy, the scale its spectral distance is measured against. + + Every distance family divides by this quantity, so a loss reads as a fraction of what the + target holds. Read on its own it says how much there is to cover, which is what separates a + loud target from a quiet one when two targets are compared. + + Args: + reference: Target feature values. + weights: Per-bin weights of the configuration. + distance: Per-bin distance family the energy is measured for. + + Returns: + The weighted energy, in the units the matching distance produces. + + Raises: + ValueError: If the spectral distance is unsupported. + """ + match distance: + case SpectralDistance.SQUARED: + return xp.sqrt(xp.sum(weights * reference**2, axis=-1)) + case SpectralDistance.ABSOLUTE | SpectralDistance.BETA_DIVERGENCE: + return xp.sum(weights * reference, axis=-1) + case _: + raise ValueError(f"Unsupported spectral distance: {distance}") + + def _prepare( reference: xp.ndarray, candidates: xp.ndarray, diff --git a/src/sampletones_core/reconstructions/reconstructor/matching.py b/src/sampletones_core/reconstructions/reconstructor/matching.py index 8e3a873b4..d2d3d5020 100644 --- a/src/sampletones_core/reconstructions/reconstructor/matching.py +++ b/src/sampletones_core/reconstructions/reconstructor/matching.py @@ -107,6 +107,10 @@ def score_candidates( scored.sort(key=lambda candidate: candidate.cost) return scored + def reference_energy(self, fragment: Fragment) -> float: + """How much sound a target holds, in the units the scoring measures its cost in.""" + return self.scorer.reference_energy(fragment) + def build_approximation( self, fragment: Fragment, diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 42ae1ae8e..935775524 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -3,7 +3,7 @@ import numpy as np -from sampletones_core.audio import active_frame_level, load_audio, mix +from sampletones_core.audio import active_frame_level, common_length, load_audio, load_stems, mix from sampletones_core.configs import Config from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL from sampletones_core.constants.enums import ChannelName @@ -91,14 +91,15 @@ def reconstruct( paths: Sequence[Pathlike], stems_config: StemsConfig, ) -> Optional[Reconstruction]: - """Reconstructs the mix of one or more stem audio files into one reconstruction. + """Reconstructs one or more stem audio files into one reconstruction. - Loads and normalizes every stem, matches the frames of the stems' mix against - the library, and assigns each frame's channels to the stems following the - configured hierarchy and channel cap. The assignment leaves every channel in - play a column of candidates per frame, which the configured decoder reads into - the stream that channel plays. The per-frame assignment is recorded in the - reconstruction's stems data. + Loads the stems onto one scale drawn from their mix, matches each stem's frames + against the library on its own, and assigns each frame's channels to the stems + following the configured hierarchy and channel cap. A stem takes a channel where + its own recording sounds, so what the channel carries is that recording. The + assignment leaves every channel in play a column of candidates per frame, which + the configured decoder reads into the stream that channel plays. The per-frame + assignment is recorded in the reconstruction's stems data. Args: paths: Paths to the stem audio files, one per stems entry. @@ -107,17 +108,17 @@ def reconstruct( per-stem channel cap. Returns: - Optional[Reconstruction]: The reconstruction built from the mix. + Optional[Reconstruction]: The reconstruction built from the stems. Raises: ValueError: If the entries count differently than ``paths``. TypeError: If a path is not a string or ``Path``. """ checked_paths = self._check_stem_paths(paths, stems_config) - mixed = self._mix_stem_audios(checked_paths) - fragmented_audio, coefficient = self._prepare_stem_frames(mixed, stems_config) - worker = self._build_worker(mixed) - assignment = self._assign_stem_frames(fragmented_audio, stems_config, worker) + recordings = self._load_stem_recordings(checked_paths) + stem_frames, coefficient = self._prepare_stem_frames(recordings, stems_config) + worker = self._build_worker(common_length(recordings)) + assignment = self._assign_stem_frames(stem_frames, stems_config, worker) self._drop_resting_channels(assignment) self._record_streams(worker.decoder.decode(assignment.lattices)) return Reconstruction.from_state( @@ -151,54 +152,73 @@ def _check_stem_paths( return checked_paths - def _mix_stem_audios(self, checked_paths: List[Path]) -> np.ndarray: - """Loads every stem and returns their mix, the target the frames match against.""" - audios = [self.load_audio(path) for path in checked_paths] - return mix(audios) + def _load_stem_recordings(self, checked_paths: List[Path]) -> Tuple[np.ndarray, ...]: + """Loads the recordings onto the one scale and length the run measures them on. + + The set is scaled by the peak of its own mix, so each recording keeps the level it + holds there and the mix is the balance the recordings were captured in. + """ + return load_stems( + checked_paths, + target_sample_rate=self.config.library.sample_rate, + normalize=self.config.general.normalize, + quantize=self.config.general.quantize, + quantization_levels=self.config.general.quantization_levels, + ) def _prepare_stem_frames( self, - mixed: np.ndarray, + recordings: Sequence[np.ndarray], stems_config: StemsConfig, - ) -> Tuple[FragmentedAudio, float]: - """Scales the mix to the working level and frames it over the stems' channels. + ) -> Tuple[Dict[int, FragmentedAudio], float]: + """Scales the recordings to the working level and frames each of them. + + The level is measured on their mix, so one factor scales the whole set and a + recording quieter than the mix reaches its frames at the level it holds there. + Framing every recording on its own is what lets a stem's picks be scored against + the sound that stem contributes. - Returns the framed target together with the coefficient it was scaled by, so - the assembled reconstruction records the level it was matched at. + Returns the framed recordings keyed by stem id, together with the coefficient they + were scaled by, so the assembled reconstruction records the level it was matched at. """ - coefficient = self.get_coefficient(mixed, stems_config) + coefficient = self.get_coefficient(mix(list(recordings)), stems_config) self.reset_generators() covered = stems_config.covered_channels self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered]) - return self.get_fragments(mixed / coefficient), coefficient + stem_frames = { + entry.id: self.get_fragments(recording / coefficient) + for entry, recording in zip(stems_config.entries, recordings) + } + return stem_frames, coefficient - def _build_worker(self, signal: np.ndarray) -> ReconstructorWorker: + def _build_worker(self, signal_length: int) -> ReconstructorWorker: """Builds the matching machinery and the decoder this recording runs through.""" return ReconstructorWorker( config=self.config, window=self.window, channels=self.channels, library_data=self.library_data, - signal_length=signal.shape[0], + signal_length=signal_length, ) def _assign_stem_frames( self, - fragmented_audio: FragmentedAudio, + stem_frames: Dict[int, FragmentedAudio], stems_config: StemsConfig, worker: ReconstructorWorker, ) -> TrackAssignment: """Assigns every frame's channels to the stems and gathers the outcome per channel. - Each frame answers every channel in play — a pick or a rest — so the lattices the - decoder reads and the per-channel stem record stay parallel to the frames, and stem - id ``i`` names frame ``i`` of its channel. + Every stem hands the assignment the same frame of its own recording, so a pick is + judged against what that stem sounds there. Each frame answers every channel in play + — a pick or a rest — so the lattices the decoder reads and the per-channel stem record + stay parallel to the frames, and stem id ``i`` names frame ``i`` of its channel. """ assignment = TrackAssignment(self.state.channel_names) - for fragment_id in fragmented_audio.fragments_ids: + for fragment_id in range(self._stem_frame_count(stem_frames)): assignment.add( assign_frame( - fragmented_audio[fragment_id], + {stem_id: fragments[fragment_id] for stem_id, fragments in stem_frames.items()}, stems_config, self.channels, worker.matcher, @@ -209,6 +229,11 @@ def _assign_stem_frames( return assignment + @staticmethod + def _stem_frame_count(stem_frames: Dict[int, FragmentedAudio]) -> int: + """The frames every recording answers, which they share by sharing a length.""" + return min((len(fragments) for fragments in stem_frames.values()), default=0) + def _drop_resting_channels(self, assignment: TrackAssignment) -> None: """Leaves the channels that sound, releasing those that rested through every frame. diff --git a/src/sampletones_core/reconstructions/reconstructor/scorer.py b/src/sampletones_core/reconstructions/reconstructor/scorer.py index 43404fa9c..bc3abee77 100644 --- a/src/sampletones_core/reconstructions/reconstructor/scorer.py +++ b/src/sampletones_core/reconstructions/reconstructor/scorer.py @@ -49,6 +49,22 @@ def spectral_costs(self, target: Fragment, candidates: Fragment) -> np.ndarray: if CUPY_AVAILABLE: xp.get_default_memory_pool().free_all_blocks() + def reference_energy(self, target: Fragment) -> float: + """ + How much sound a target holds, in the units its spectral loss is measured in. + + A spectral cost is a fraction of this quantity, so multiplying the two states a covering + in absolute terms, which is what lets two targets of different loudness be compared. + + Args: + target: Target fragment to measure. + + Returns: + The target's weighted energy. + """ + energy = self.criterion.reference_energy(xp.asarray(target.feature.values)) + return float(to_numpy(energy).reshape(-1)[0]) + def aligned_cost( self, target: Fragment, diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py index 11d0879b8..60a481480 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py @@ -12,7 +12,7 @@ def assign_frame( - fragment: Fragment, + fragments: Dict[int, Fragment], stems_config: StemsConfig, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, @@ -20,14 +20,15 @@ def assign_frame( lattice_width: int, ) -> StemFrameAssignment: """ - Assigns one target frame's channels to stems, one pick at a time. + Assigns one frame's channels to stems, one pick at a time. - Every pick scores each eligible stem's candidates against the current residual, - takes the cheapest choice across the active level, subtracts its approximation - from the residual, and consumes its channel. Levels pick in the hierarchy's - mode: round-based gives every level's stems one channel per round in level - order, strict exhausts each level before the next. Each stem holds at most - the setup's channel cap per frame. + Every pick scores a stem's candidates against what is left of that stem's own frame, + takes the cheapest choice across the active level, subtracts its approximation from + that stem's residual, and consumes its channel. Scoring each stem against its own + recording is what makes the channel a stem wins carry that recording's sound. Levels + pick in the hierarchy's mode: round-based gives every level's stems one channel per + round in level order, strict exhausts each level before the next. Each stem holds at + most the setup's channel cap per frame. The frame is answered whole: every covered channel is either picked or reported as resting, so a caller records one entry per channel per frame and the streams it @@ -36,12 +37,12 @@ def assign_frame( reading the frames chooses its stream from. Args: - fragment: The frame to assign, matching the matcher and extractor feature - space. + fragments: This frame of every stem, keyed by stem id, in the matcher and + extractor feature space. stems_config: The stems setup the assignment runs under. channels: The enabled channels with their generators. matcher: The candidate scoring machinery. - extractor: The feature extractor whose subtraction forms the residual. + extractor: The feature extractor whose subtraction forms a residual. lattice_width: How many alternatives per channel the decoder reads. Returns: @@ -52,7 +53,7 @@ def assign_frame( """ validate_stems_config(stems_config, channels) session = AssignmentSession( - fragment, + fragments, stems_config, channels, matcher, diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py index cc9ff659e..83968bf72 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py @@ -1,9 +1,12 @@ from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, Set, Tuple +from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple + +import numpy as np from sampletones_core.constants.algorithm import ( RESTING_FRAME_COST, SINGLE_STATE_LATTICE_WIDTH, + STEM_ACTIVITY_FLOOR, ) from sampletones_core.constants.enums import ( ChannelName, @@ -32,11 +35,12 @@ StemFrameAssignment, ) from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest +from sampletones_shared.array import to_numpy @dataclass(frozen=True) class StemOffer: - """What one stem offers for the current residual: a shortlist, and the channel it won with. + """What one stem offers for its residual: a shortlist, and the channel it won with. The shortlist arrives best first, so its head is the candidate the stem competes with, and the generator behind that head names the channel the stem would take. @@ -46,11 +50,23 @@ class StemOffer: generator: GeneratorUnion shortlist: Tuple[ScoredCandidate, ...] generator_classes: Dict[GeneratorClassName, GeneratorUnion] + residual_energy: float @property def candidate(self) -> ScoredCandidate: return self.shortlist[0] + @property + def bid(self) -> float: + """How much of what this stem still holds its best candidate covers. + + A cost is a fraction of its own target's energy, so two stems' costs stand on different + scales and comparing them alone would hand a channel to whichever recording is easiest to + approximate. Weighting the cost by the energy behind it states the covering in absolute + terms, so the channel goes to the stem with the most sound left to render. + """ + return self.residual_energy * max(0.0, 1.0 - self.candidate.cost) + @property def class_restricted(self) -> bool: """The shortlist covers this offer's generator class alone, so it is the channel's own column.""" @@ -59,30 +75,36 @@ def class_restricted(self) -> bool: class AssignmentSession: """ - Carries one frame assignment's mutable progress: the residual, the free + Carries one frame assignment's mutable progress: each stem's residual, the free channels, and the per-stem channel counts. + + A stem's residual holds what is left of that stem's own frame, so a pick answers what + that recording still sounds and the channel it wins carries that recording. Only the + free channels are shared: the stems compete for them, ordered by the hierarchy, and the + stems sounding in the frame are the ones that compete. """ def __init__( self, - fragment: Fragment, + fragments: Dict[int, Fragment], stems_config: StemsConfig, channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, lattice_width: int, ) -> None: - self.fragment = fragment + self.fragments = fragments self.stems_config = stems_config self.channels = channels self.matcher = matcher self.extractor = extractor self.lattice_width = lattice_width self.channel_cap = stems_config.channel_cap - self.residual = fragment + self.residuals: Dict[int, Fragment] = dict(fragments) covered = stems_config.covered_channels self.free_channels = [name for name in ChannelName.items() if name in covered] self.used_channels: Dict[int, int] = {entry.id: 0 for entry in stems_config.entries} + self.sounding = self._sounding_stems() self.choices: List[StemChoice] = [] def run(self) -> StemFrameAssignment: @@ -124,7 +146,9 @@ def _pick_from_level( eligible = [ stem_id for stem_id in level - if self.used_channels[stem_id] < self.channel_cap and (repeat or stem_id not in picked_this_visit) + if stem_id in self.sounding + and self.used_channels[stem_id] < self.channel_cap + and (repeat or stem_id not in picked_this_visit) ] if not eligible or not self.free_channels: return @@ -137,13 +161,18 @@ def _pick_from_level( self.choices.append(choice) self.used_channels[choice.stem_id] += 1 self.free_channels.remove(choice.channel_name) - self.residual = self.extractor.subtract( - self.residual, + self.residuals[choice.stem_id] = self.extractor.subtract( + self.residuals[choice.stem_id], choice.approximation, ) picked_this_visit.add(choice.stem_id) def _best_offer(self, stem_ids: Sequence[int]) -> Optional[StemOffer]: + """The stem of ``stem_ids`` whose best candidate covers the most of what it still holds. + + Equal bids leave the offer already standing, which is the one earlier in level order, so a + rerun of the same frame assigns the same way. + """ best: Optional[StemOffer] = None for stem_id in stem_ids: remaining_channels = self._remaining_channels(stem_id) @@ -152,7 +181,7 @@ def _best_offer(self, stem_ids: Sequence[int]) -> Optional[StemOffer]: remaining_generator_classes = get_remaining_generator_classes(remaining_channels) scored = self.matcher.score_candidates( - self.residual, + self.residuals[stem_id], remaining_generator_classes, ) offer = StemOffer( @@ -163,8 +192,9 @@ def _best_offer(self, stem_ids: Sequence[int]) -> Optional[StemOffer]: ), shortlist=tuple(scored), generator_classes=remaining_generator_classes, + residual_energy=self.matcher.reference_energy(self.residuals[stem_id]), ) - if best is None or offer.candidate.cost < best.candidate.cost: + if best is None or offer.bid > best.bid: best = offer return best @@ -184,9 +214,9 @@ def _column(self, offer: StemOffer) -> Column: A decoder reading one candidate per frame settles on the pick itself, so the frame is answered by the scoring already done. A wider lattice scores the winning channel's own - candidates against the same residual, which reaches the alternatives a scoring across - several channels ranked below other channels' candidates. Where the offer was already - scored over one generator class, that scoring is the column. + candidates against the residual the pick was made on, which reaches the alternatives a + scoring across several channels ranked below other channels' candidates. Where the offer + was already scored over one generator class, that scoring is the column. """ if self.lattice_width == SINGLE_STATE_LATTICE_WIDTH: return (offer.candidate,) @@ -196,7 +226,7 @@ def _column(self, offer: StemOffer) -> Column: generator = offer.generator scored = self.matcher.score_candidates( - self.residual, + self.residuals[offer.stem_id], {generator.class_name(): generator}, ) return tuple(scored[: self.lattice_width]) @@ -206,7 +236,7 @@ def _rests(self) -> Tuple[StemRest, ...]: if not self.free_channels: return () - silent = self.fragment * 0.0 + silent = next(iter(self.fragments.values())) * 0.0 return tuple( StemRest( channel_name=channel_name, @@ -223,6 +253,20 @@ def _resting_candidate(self, channel_name: ChannelName, silent: Fragment) -> Sco approximation=silent, ) + def _sounding_stems(self) -> FrozenSet[int]: + """The stems whose own frame reaches a level a channel can render. + + A frame quieter than the quietest note the hardware plays, measured against the working + level, holds nothing for a channel to sound. Its stem stands aside, so the channel goes + to a stem that does sound there or rests, and a recording silent through a passage keeps + that passage silent on the channels it holds. + """ + return frozenset( + stem_id + for stem_id, fragment in self.fragments.items() + if float(np.max(np.abs(to_numpy(fragment.audio)), initial=0.0)) >= STEM_ACTIVITY_FLOOR + ) + def _remaining_channels( self, stem_id: int, diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index e42659ca9..89e5fc1a5 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -1,11 +1,11 @@ from pathlib import Path -from typing import AbstractSet, Dict, Final, Tuple +from typing import AbstractSet, Dict, Final, List, Sequence, Tuple import numpy as np import pytest from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_core.audio import load_audio, mix, write_wave +from sampletones_core.audio import mix, write_wave from sampletones_core.configs import Config from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP, RESTING_STEM_ID from sampletones_core.constants.enums import ChannelName, HierarchyMode @@ -29,6 +29,9 @@ _TONE_FREQUENCY: Final[float] = 440.0 _DURATION_SECONDS: Final[float] = 0.5 _MIX_TOLERANCE: Final[float] = 1e-6 # float32 sums drift with accumulation order +_DISJOINT_DURATION_SECONDS: Final[float] = 0.9 +_DISJOINT_TONES: Final[Tuple[float, ...]] = (220.0, 440.0, 880.0) +_DISJOINT_AMPLITUDE: Final[float] = 0.5 def _frame_count(config: Config, duration_seconds: float) -> int: @@ -315,17 +318,22 @@ def test_the_frames_it_held_fall_silent_while_the_rest_stand(self, tmp_path: Pat ] def test_the_channels_it_alone_held_stand_by(self, tmp_path: Path) -> None: - """Under a cap of one, stem c alone sounds pulse 1 and noise, so both fall quiet with it. + """A channel every remaining recording passes over describes no frame at all. - A channel every remaining recording passes over describes no frame at all, which is what - tells it apart from a channel that plays. + The channels stem c alone sounded come from the recorded assignment, so the test states + what a removal does to them rather than which channels this material happened to reach. """ reconstruction, _paths, _config = self._three_stems(tmp_path) + assignments = reconstruction.stems_data.assignments_by_channel + held_alone = tuple( + channel for channel, stem_ids in assignments.items() if set(stem_ids) - {RESTING_STEM_ID} == {STEM_C_ID} + ) + assert held_alone remaining = without_stem(reconstruction, STEM_C_ID) - assert remaining.playing_channels == (ChannelName.PULSE2, ChannelName.TRIANGLE) - for channel in (ChannelName.PULSE1, ChannelName.NOISE): + assert set(remaining.playing_channels) == set(assignments) - set(held_alone) + for channel in held_alone: np.testing.assert_array_equal( remaining.approximations[channel], np.zeros_like(reconstruction.approximations[channel]), @@ -363,7 +371,12 @@ def test_what_stays_plays_as_it_did_before(self, tmp_path: Path) -> None: class TestStemsOriginalAudio: - def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> None: + def test_mixes_the_recorded_stems_at_the_balance_they_were_captured_in(self, tmp_path: Path) -> None: + """The recordings reach the original at the levels they hold relative to one another. + + The set is scaled by one factor drawn from its mix, so the ratio between two recordings + survives loading and the mix reaches the full range. + """ config = Config() library = build_mini_library(config) reconstructor = Reconstructor(config, library=library) @@ -393,19 +406,15 @@ def test_mixes_the_recorded_stems_into_one_original(self, tmp_path: Path) -> Non assert data.reconstruction.audio_filepath == (tone_path, noise_path) assert data.name == tmp_path.name - load_options = { - "target_sample_rate": config.library.sample_rate, - "normalize": config.general.normalize, - "quantize": config.general.quantize, - } - expected = mix( - [ - load_audio(path=tone_path, **load_options), - load_audio(path=noise_path, **load_options), - ] - ) assert data.original_audio is not None - np.testing.assert_allclose(data.original_audio, expected) + loaded_tone, loaded_noise = data.stem_audios + np.testing.assert_allclose( + np.max(np.abs(loaded_tone)) / np.max(np.abs(loaded_noise)), + np.max(np.abs(tone)) / np.max(np.abs(noise)), + rtol=1e-5, + ) + np.testing.assert_allclose(data.original_audio, mix([loaded_tone, loaded_noise]), atol=_MIX_TOLERANCE) + np.testing.assert_allclose(np.max(np.abs(data.original_audio)), 1.0, rtol=1e-5) class TestClassicRunCarriesTheSingleEntryRecord: @@ -438,6 +447,40 @@ def test_classic_conversion_records_one_stem_over_every_enabled_channel(self, tm assert set(stem_ids) <= {0} assert len(stem_ids) == len(reconstruction.instructions[channel]) + def test_a_silent_stretch_rests_and_states_silence(self, tmp_path: Path) -> None: + """A source below the quietest renderable note leaves its channels resting there. + + The frames it does sound in are answered as they always were, so the run keeps its shape + while the silence it holds reaches the streams as silence. + """ + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + + sample_rate = config.library.sample_rate + frame_length = config.library.frame_length + frames = int(sample_rate * _DURATION_SECONDS) // frame_length + sounding = range(frames // 2) + audio = np.zeros(frames * frame_length, dtype=np.float32) + span = slice(0, len(sounding) * frame_length) + time = np.arange(span.stop) / sample_rate + audio[span] = _DISJOINT_AMPLITUDE * np.sin(2 * np.pi * _TONE_FREQUENCY * time) + tone_path = tmp_path / "half_silent.wav" + write_wave(tone_path, sample_rate, audio) + + reconstruction = reconstructor(tone_path) + + assert reconstruction is not None + for channel, stem_ids in reconstruction.stems_data.assignments_by_channel.items(): + assert set(stem_ids[: len(sounding)]) == {0} + assert set(stem_ids[len(sounding) :]) == {RESTING_STEM_ID} + for frame in range(len(sounding), frames): + assert not reconstruction.instructions[channel][frame].on + np.testing.assert_array_equal( + reconstruction.approximations[channel][frame * frame_length : (frame + 1) * frame_length], + np.zeros(frame_length, dtype=np.float32), + ) + def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> None: """One channel sounds per frame while the others rest, each keeping its place in the frame.""" config = Config() @@ -461,3 +504,95 @@ def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> sounding = [sum(stem_ids[frame] == 0 for stem_ids in assignments.values()) for frame in range(frame_count)] assert sounding == [1] * frame_count + + +class TestStemsCarryTheirOwnSound: + """Three recordings sounding one after another, never together. + + Each stem is matched against its own recording, so the frames it holds fall inside the span + it sounds in and the channels stand quiet everywhere else. A stem heard on its own therefore + plays what was recorded on it. + """ + + def _recordings(self, config: Config, tmp_path: Path) -> Tuple[Tuple[Path, ...], List[range]]: + """Writes one recording per tone, each sounding over its own span of whole frames.""" + sample_rate = config.library.sample_rate + frame_length = config.library.frame_length + frames = int(sample_rate * _DISJOINT_DURATION_SECONDS) // frame_length + span_frames = frames // len(_DISJOINT_TONES) + + paths: List[Path] = [] + spans: List[range] = [] + for index, frequency in enumerate(_DISJOINT_TONES): + audio = np.zeros(frames * frame_length, dtype=np.float32) + start, stop = index * span_frames, (index + 1) * span_frames + samples = slice(start * frame_length, stop * frame_length) + time = np.arange(samples.stop - samples.start) / sample_rate + audio[samples] = _DISJOINT_AMPLITUDE * np.sin(2 * np.pi * frequency * time) + path = tmp_path / f"stem_{index}.wav" + write_wave(path, sample_rate, audio) + paths.append(path) + spans.append(range(start, stop)) + + return tuple(paths), spans + + def _stems_config(self, channels: Sequence[ChannelName]) -> StemsConfig: + """Every stem may take every channel, each on a level of its own.""" + return StemsConfig( + entries=[StemEntry(id=index, channels=list(channels)) for index in range(len(_DISJOINT_TONES))], + hierarchy=StemsHierarchy( + levels=[[index] for index in range(len(_DISJOINT_TONES))], + mode=HierarchyMode.ROUND_ROBIN, + ), + channel_cap=len(channels), + ) + + def _reconstruct(self, tmp_path: Path) -> Tuple[Reconstruction, List[range], Config]: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, library=library) + paths, spans = self._recordings(config, tmp_path) + + reconstruction = reconstructor.reconstruct(list(paths), self._stems_config(config.generation.channels)) + + assert reconstruction is not None + return reconstruction, spans, config + + def test_a_stem_holds_frames_only_where_its_recording_sounds(self, tmp_path: Path) -> None: + reconstruction, spans, _config = self._reconstruct(tmp_path) + + assignments = reconstruction.stems_data.assignments_by_channel + assert assignments + for stem_ids in assignments.values(): + for frame, stem_id in enumerate(stem_ids): + if stem_id != RESTING_STEM_ID: + assert frame in spans[stem_id] + + def test_every_stem_is_heard_where_its_recording_sounds(self, tmp_path: Path) -> None: + """Standing aside where a recording is silent leaves every sounding span answered.""" + reconstruction, spans, _config = self._reconstruct(tmp_path) + + assignments = reconstruction.stems_data.assignments_by_channel + for stem_id, span in enumerate(spans): + held = { + frame for stem_ids in assignments.values() for frame, holder in enumerate(stem_ids) if holder == stem_id + } + assert held == set(span) + + def test_soloing_a_stem_sounds_nothing_outside_its_span(self, tmp_path: Path) -> None: + """A stem heard on its own falls silent beyond the span its recording sounds in. + + The energy its channels put out there is the leakage, and it is what this measures. + """ + reconstruction, spans, config = self._reconstruct(tmp_path) + + frame_length = config.library.frame_length + assignments = reconstruction.stems_data.assignments_by_channel + for stem_id, span in enumerate(spans): + selection = StemSelection.everywhere(frozenset({stem_id}), list(assignments)) + heard = ReconstructionData.from_reconstruction(reconstruction, name="disjoint").waveform_data(selection) + outside = np.array(heard.approximation, copy=True) + for frame in span: + outside[frame * frame_length : (frame + 1) * frame_length] = 0.0 + + assert float(np.sum(outside**2)) == 0.0 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 2db1708fc..d9c5b5572 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -4,7 +4,7 @@ import numpy as np from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_core.audio import load_audio, mix, write_wave +from sampletones_core.audio import mix, write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import PulseInstruction @@ -94,11 +94,16 @@ def test_loads_original_audio_when_source_file_is_available( assert data.original_audio is not None - def test_mixes_several_recorded_paths_into_the_original( + def test_mixes_several_recorded_paths_at_the_balance_they_were_captured_in( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: + """The recordings are scaled together, so one heard alone sounds at its mix level. + + The louder recording is written at twice the quieter one, and it stays twice as loud + once loaded, while their mix reaches the full range. + """ config = Config() first = tmp_path / "kick.wav" second = tmp_path / "snare.wav" @@ -108,19 +113,11 @@ def test_mixes_several_recorded_paths_into_the_original( data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") - load_options = { - "target_sample_rate": config.library.sample_rate, - "normalize": config.general.normalize, - "quantize": config.general.quantize, - } - expected = mix( - [ - load_audio(path=first, **load_options), - load_audio(path=second, **load_options), - ] - ) assert data.original_audio is not None - np.testing.assert_allclose(data.original_audio, expected) + louder, quieter = data.stem_audios + np.testing.assert_allclose(louder, 2.0 * quieter, rtol=1e-6) + np.testing.assert_allclose(data.original_audio, mix([louder, quieter])) + np.testing.assert_allclose(np.max(np.abs(data.original_audio)), 1.0, rtol=1e-6) def test_one_unreadable_stem_costs_the_whole_original( self, diff --git a/tests/unit/sampletones_core/audio/test_io.py b/tests/unit/sampletones_core/audio/test_io.py new file mode 100644 index 000000000..512a2857a --- /dev/null +++ b/tests/unit/sampletones_core/audio/test_io.py @@ -0,0 +1,85 @@ +from pathlib import Path +from typing import Final + +import numpy as np + +from sampletones_core.audio.io import load_audio, load_stems, write_wave +from sampletones_core.configs import Config + +SAMPLE_RATE: Final[int] = Config().library.sample_rate +QUANTIZATION_LEVELS: Final[int] = Config().general.quantization_levels +SAMPLES: Final[int] = 64 + + +def _write(path: Path, level: float, samples: int = SAMPLES) -> Path: + write_wave(path, SAMPLE_RATE, np.full(samples, level, dtype=np.float32)) + return path + + +def _load_stems(*paths: Path, normalize: bool = True, quantize: bool = False) -> tuple: + return load_stems( + paths, + target_sample_rate=SAMPLE_RATE, + normalize=normalize, + quantize=quantize, + quantization_levels=QUANTIZATION_LEVELS, + ) + + +class TestLoadStems: + """A set of recordings loaded onto one scale and one length.""" + + def test_one_recording_reaches_what_loading_it_alone_reaches(self, tmp_path: Path) -> None: + """Scaling a lone recording by the peak of its own sum is normalizing it.""" + path = _write(tmp_path / "alone.wav", 0.4) + + (loaded,) = _load_stems(path) + + np.testing.assert_allclose( + loaded, + load_audio(path, target_sample_rate=SAMPLE_RATE, normalize=True, quantize=False), + ) + + def test_the_recordings_keep_the_balance_they_were_captured_in(self, tmp_path: Path) -> None: + louder, quieter = _load_stems( + _write(tmp_path / "loud.wav", 0.6), + _write(tmp_path / "quiet.wav", 0.2), + ) + + np.testing.assert_allclose(louder, 3.0 * quieter, rtol=1e-6) + + def test_the_set_reaches_the_full_range(self, tmp_path: Path) -> None: + recordings = _load_stems( + _write(tmp_path / "first.wav", 0.6), + _write(tmp_path / "second.wav", 0.2), + ) + + np.testing.assert_allclose(np.max(np.abs(sum(recordings))), 1.0, rtol=1e-6) + + def test_a_shorter_recording_runs_on_in_silence(self, tmp_path: Path) -> None: + longer, shorter = _load_stems( + _write(tmp_path / "longer.wav", 0.5), + _write(tmp_path / "shorter.wav", 0.5, samples=SAMPLES // 2), + ) + + assert len(shorter) == len(longer) == SAMPLES + np.testing.assert_array_equal(shorter[SAMPLES // 2 :], np.zeros(SAMPLES // 2, dtype=np.float32)) + + def test_silence_stays_silent(self, tmp_path: Path) -> None: + """A set holding no sound has no peak to scale by, so it comes back as it went in.""" + recordings = _load_stems(_write(tmp_path / "silent.wav", 0.0)) + + np.testing.assert_array_equal(recordings[0], np.zeros(SAMPLES, dtype=np.float32)) + + def test_unscaled_loading_keeps_the_captured_levels(self, tmp_path: Path) -> None: + louder, quieter = _load_stems( + _write(tmp_path / "loud.wav", 0.6), + _write(tmp_path / "quiet.wav", 0.2), + normalize=False, + ) + + np.testing.assert_allclose(np.max(np.abs(louder)), 0.6, rtol=1e-6) + np.testing.assert_allclose(np.max(np.abs(quieter)), 0.2, rtol=1e-6) + + def test_no_paths_reach_no_recordings(self) -> None: + assert _load_stems() == () diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py index 6ea14faef..dcb80d9ac 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py @@ -1,9 +1,10 @@ -from typing import Any, Dict, Final +from typing import Any, Dict, Final, List import numpy as np import pytest from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import STEM_ACTIVITY_FLOOR from sampletones_core.constants.enums import ChannelName from sampletones_core.fft import Fragment, Window from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor @@ -82,8 +83,32 @@ def synthetic_fragment( config: Config, window: Window, ) -> Fragment: - active_instruction = next(instrument for instrument in library_data.keys() if instrument.on) - return library_data[active_instruction].get_fragment(0, config, window) + """A frame of sound a channel renders, the stand-in for what one stem contributes.""" + return _renderable_fragments(library_data, config, window)[0] + + +@pytest.fixture +def audible_fragments( + library_data: InstructionLibraryData, + config: Config, + window: Window, +) -> List[Fragment]: + """The distinct sounds a case hands its stems, each loud enough for a channel to render.""" + return _renderable_fragments(library_data, config, window) + + +def _renderable_fragments( + library_data: InstructionLibraryData, + config: Config, + window: Window, +) -> List[Fragment]: + """The library's frames loud enough to render, which is what the assignment asks of a stem.""" + fragments = [ + library_data[instruction].get_fragment(0, config, window) + for instruction in library_data.keys() + if instruction.on + ] + return [fragment for fragment in fragments if float(np.max(np.abs(fragment.audio))) > STEM_ACTIVITY_FLOOR] @pytest.fixture diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py index a3b784f66..ac166b879 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py @@ -14,6 +14,7 @@ get_remaining_generator_classes, ) from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher, ScoredCandidate +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker @@ -58,3 +59,12 @@ def greedy_baseline( def lattice_width() -> int: """The width a greedy decoder reads, which is what the equivalence baseline assumes.""" return SINGLE_STATE_LATTICE_WIDTH + + +def shared_frames(fragment: Fragment, stems_config: StemsConfig) -> Dict[int, Fragment]: + """The frame every stem of ``stems_config`` contributes, one entry apiece. + + Stems sounding alike leave hierarchy order, the channel cap and tie resolution as the only + things deciding the frame, which is what a case using this states. + """ + return {entry.id: fragment for entry in stems_config.entries} diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py index 96a301858..b581bdf15 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -18,7 +18,7 @@ from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment -from .conftest import greedy_baseline +from .conftest import greedy_baseline, shared_frames RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197) @@ -47,7 +47,7 @@ def test_matches_the_greedy_baseline_exactly( stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels)) assignment = assign_frame( - synthetic_fragment, + shared_frames(synthetic_fragment, stems_config), stems_config, channels, matcher, @@ -70,7 +70,7 @@ def test_matches_the_baseline_with_all_four_channels( stems_config = _config({0: all_channels}, [[0]], HierarchyMode.STRICT, len(all_channels)) assignment = assign_frame( - synthetic_fragment, + shared_frames(synthetic_fragment, stems_config), stems_config, all_channels, matcher, @@ -102,7 +102,7 @@ def test_ownership_and_picks_hold_across_widths( ) narrow = assign_frame( - synthetic_fragment, + shared_frames(synthetic_fragment, stems_config), stems_config, all_channels, matcher, @@ -110,7 +110,7 @@ def test_ownership_and_picks_hold_across_widths( SINGLE_STATE_LATTICE_WIDTH, ) wide = assign_frame( - synthetic_fragment, + shared_frames(synthetic_fragment, stems_config), stems_config, all_channels, matcher, @@ -127,27 +127,28 @@ def test_ownership_and_picks_hold_across_widths( class TestStrictDisjointStems: - def test_matches_sequential_per_subset_baselines( + def test_each_stem_answers_its_own_recording( self, - synthetic_fragment: Fragment, + audible_fragments: List[Fragment], channels: Dict[ChannelName, GeneratorUnion], matcher: FrameMatcher, extractor: FeatureExtractor, ) -> None: + """Two stems sounding differently each answer their own frame over their own channels. + + A stem's picks are the greedy reconstruction of the sound it contributes, so the channels + it holds carry that recording and the other stem takes no part in them. + """ + assert len(audible_fragments) >= 2 + first_fragment, second_fragment = audible_fragments[0], audible_fragments[-1] subset_pulse_triangle = { ChannelName.PULSE1: channels[ChannelName.PULSE1], ChannelName.TRIANGLE: channels[ChannelName.TRIANGLE], } subset_noise = {ChannelName.NOISE: channels[ChannelName.NOISE]} - baseline_first = greedy_baseline(synthetic_fragment, subset_pulse_triangle, matcher, extractor) - residual = synthetic_fragment - for candidate in baseline_first.values(): - residual = extractor.subtract(residual, candidate.approximation) - baseline_second = greedy_baseline(residual, subset_noise, matcher, extractor) - - expected = dict(baseline_first) - expected.update(baseline_second) + expected = dict(greedy_baseline(first_fragment, subset_pulse_triangle, matcher, extractor)) + expected.update(greedy_baseline(second_fragment, subset_noise, matcher, extractor)) stems_config = _config( {0: subset_pulse_triangle, 1: subset_noise}, @@ -157,7 +158,7 @@ def test_matches_sequential_per_subset_baselines( ) assignment = assign_frame( - synthetic_fragment, + {0: first_fragment, 1: second_fragment}, stems_config, channels, matcher, @@ -186,7 +187,7 @@ def test_invariants_and_determinism( stems_config = _random_setup(rng, tuple(all_channels)) assignment = assign_frame( - fragment, + shared_frames(fragment, stems_config), stems_config, all_channels, matcher, @@ -194,7 +195,7 @@ def test_invariants_and_determinism( SINGLE_STATE_LATTICE_WIDTH, ) repeat = assign_frame( - fragment, + shared_frames(fragment, stems_config), stems_config, all_channels, matcher, diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py index 0d44754ed..b73d7339b 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Sequence, Tuple +from typing import Dict, Final, List, Sequence, Tuple import numpy as np import pytest @@ -16,7 +16,10 @@ from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from .conftest import shared_frames + DEFAULT_CHANNELS: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] +LOUDER_STEM_SCALE: Final[float] = 4.0 def _config( @@ -41,7 +44,7 @@ def _assign( lattice_width: int = SINGLE_STATE_LATTICE_WIDTH, ) -> StemFrameAssignment: return assign_frame( - fragment, + shared_frames(fragment, stems_config), stems_config, channels, matcher, @@ -110,6 +113,145 @@ def test_a_channel_no_stem_may_occupy_stays_out_of_the_frame( assert assignment.resting == () +class TestSoundingStems: + """A stem takes a channel where its own recording sounds, and stands aside where it does not.""" + + def test_a_stem_sounding_nothing_leaves_the_channel_to_one_that_does( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + """A silent stem picking first passes, so the channel reaches the stem behind it. + + This is what keeps a recording quiet through a passage from sounding that passage on the + channels it holds elsewhere. + """ + stems_config = _config( + {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]}, + [[0], [1]], + HierarchyMode.STRICT, + 1, + ) + + assignment = assign_frame( + {0: synthetic_fragment * 0.0, 1: synthetic_fragment}, + stems_config, + channels, + matcher, + extractor, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert [choice.stem_id for choice in assignment.choices] == [1] + assert assignment.resting == () + + def test_a_frame_no_stem_sounds_in_rests_every_channel( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + """Every covered channel still answers the frame, each holding its null instruction.""" + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + silent = synthetic_fragment * 0.0 + + assignment = assign_frame( + {0: silent}, + stems_config, + channels, + matcher, + extractor, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert assignment.choices == () + assert set(assignment.resting) == set(DEFAULT_CHANNELS) + for rest in assignment.rests: + assert not rest.column[0].instruction.on + + +class TestBidsWithinALevel: + """Two stems sharing a level compete for a channel by what each still has to render.""" + + def _pulse_cost( + self, + fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> float: + pulse = channels[ChannelName.PULSE1] + return matcher.score_candidates(fragment, {pulse.class_name(): pulse})[0].cost + + def test_the_louder_stem_takes_the_channel( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + """The louder recording wins the channel though the quieter one is the closer match. + + A cost is a fraction of its own recording's energy, so the quiet stem scores the better + cost; weighting that cost by the energy behind it is what sends the channel where more + sound is waiting. The winner follows the recordings, not the place a stem holds. + """ + quiet = synthetic_fragment + loud = synthetic_fragment * LOUDER_STEM_SCALE + assert self._pulse_cost(quiet, channels, matcher) < self._pulse_cost(loud, channels, matcher) + + stems_config = _config( + {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]}, + [[0, 1]], + HierarchyMode.STRICT, + 1, + ) + + for loud_stem_id in (0, 1): + assignment = assign_frame( + {loud_stem_id: loud, 1 - loud_stem_id: quiet}, + stems_config, + channels, + matcher, + extractor, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert [choice.stem_id for choice in assignment.choices] == [loud_stem_id] + + def test_a_level_of_its_own_takes_the_channel_first( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + extractor: FeatureExtractor, + ) -> None: + """Levels pick in the order they are listed, so the first level takes the channel. + + Bids settle a level's own competition; precedence between levels stays the hierarchy's, + which is what a reader arranges the levels to say. + """ + stems_config = _config( + {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]}, + [[0], [1]], + HierarchyMode.STRICT, + 1, + ) + + assignment = assign_frame( + {0: synthetic_fragment, 1: synthetic_fragment * LOUDER_STEM_SCALE}, + stems_config, + channels, + matcher, + extractor, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert [choice.stem_id for choice in assignment.choices] == [0] + + class TestColumns: """Every channel leaves the frame with the alternatives the decoder reads.""" From c7de4eaee64cddef3e4253c34067f37e13deb945 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 03:10:11 +0200 Subject: [PATCH 072/142] Renamed: the project's sample pool into its voices --- src/sampletones_application/application.py | 30 ++--- .../coordinators/tabs/sequencer.py | 74 ++++++------ .../logic/history/fingerprint.py | 6 +- .../logic/project/controller.py | 65 +++++------ .../logic/reconstruction/edit.py | 6 +- .../logic/sequencer/browser.py | 6 +- .../logic/sequencer/clipboard/samples.py | 12 +- .../logic/sequencer/clipboard/tracker.py | 10 +- .../logic/sequencer/history_detail.py | 54 ++++----- .../playback/synthesizer/synthesizer.py | 12 +- .../logic/sequencer/samples.py | 86 +++++++------- .../logic/sequencer/tracker/block.py | 2 +- .../logic/sequencer/tracker/reader.py | 8 +- .../logic/sequencer/tracker/tracker.py | 65 +++++------ .../logic/sequencer/tracker/writer.py | 8 +- .../logic/shared/project_source.py | 2 +- .../services/retune/retune.py | 4 +- .../services/retune/sample.py | 4 +- .../ui/panels/sequencer/samples.py | 108 +++++++++--------- .../ui/panels/sequencer/tracker.py | 18 +-- .../view_model/sequencer/samples.py | 8 +- src/sampletones_core/compatibility/fields.py | 7 ++ .../compatibility/project/__init__.py | 3 +- .../compatibility/project/v1_1.py | 14 +-- .../compatibility/project/v1_2.py | 92 +++++++++++++++ src/sampletones_core/constants/general.py | 4 +- src/sampletones_core/exporters/slices.py | 4 +- .../formats/bitphase/builder.py | 20 ++-- .../formats/famitracker/builder.py | 14 +-- src/sampletones_core/performance/rows.py | 18 +-- src/sampletones_core/performance/song.py | 8 +- src/sampletones_core/performance/state.py | 14 +-- src/sampletones_core/project/__init__.py | 4 +- src/sampletones_core/project/container.py | 39 ++++--- src/sampletones_core/project/document.py | 8 +- .../project/instruments/__init__.py | 9 -- .../project/instruments/instrument.py | 21 ---- src/sampletones_core/project/patterns/row.py | 16 +-- src/sampletones_core/project/project.py | 18 +-- src/sampletones_core/project/song.py | 12 +- src/sampletones_core/project/tuning.py | 4 +- .../project/voices/__init__.py | 13 +++ src/sampletones_core/project/voices/loop.py | 3 + .../{instruments => voices}/note_off.py | 0 .../project/voices/note_on.py | 14 +++ .../project/{instruments => voices}/record.py | 8 ++ .../project/{instruments => voices}/sample.py | 22 +++- src/sampletones_core/utils/display.py | 34 +++--- src/sampletones_shared/application.py | 2 +- tests/integration/assets/reconstruction.py | 9 +- tests/integration/assets/song_loader.py | 8 +- .../integration/bitphase/test_btp_pipeline.py | 2 +- tests/integration/conftest.py | 8 +- tests/integration/nsf/conftest.py | 2 +- tests/integration/nsf/corpus.py | 4 +- tests/integration/nsf/songs.py | 4 +- tests/integration/nsf/test_backend.py | 4 +- .../nsf/test_compression_report.py | 2 +- tests/integration/nsf/test_driver_audio.py | 2 +- tests/integration/nsf/test_driver_trace.py | 2 +- tests/integration/nsf/test_nsf_pipeline.py | 2 +- tests/suite/performance.py | 15 ++- tests/suite/sequencer.py | 26 ++--- .../coordinators/tabs/test_sequencer.py | 20 ++-- .../logic/history/test_fingerprint.py | 2 +- .../logic/project/test_controller.py | 90 ++++++--------- .../logic/project/test_manager.py | 4 +- .../logic/sequencer/clipboard/test_tracker.py | 21 ++-- .../logic/sequencer/playback/conftest.py | 13 ++- .../sequencer/playback/test_synthesizer.py | 50 ++++---- .../sequencer/playback/test_tick_clock.py | 4 +- .../logic/sequencer/test_history_detail.py | 6 +- .../logic/sequencer/test_samples.py | 35 +++--- .../logic/sequencer/tracker/test_adjuster.py | 15 ++- .../logic/sequencer/tracker/test_reader.py | 2 +- .../logic/sequencer/tracker/test_tracker.py | 29 ++--- .../logic/sequencer/tracker/test_writer.py | 20 ++-- .../logic/shared/test_project_source.py | 2 +- .../services/retune/test_retune.py | 6 +- .../test_application_retune.py | 12 +- .../test_application_sample_rebind.py | 2 +- .../sampletones_application/test_startup.py | 4 +- .../panels/sequencer/test_panel_tab_gate.py | 6 +- .../ui/panels/sequencer/test_samples_keys.py | 18 +-- .../ui/panels/sequencer/test_samples_menu.py | 24 ++-- .../sequencer/test_samples_selection.py | 12 +- .../sequencer/test_tracker_context_menu.py | 6 +- .../view_model/sequencer/test_samples.py | 2 +- .../sampletones_core/audio/test_processing.py | 2 +- .../compatibility/project/test_v1_1.py | 55 +++------ .../compatibility/project/test_v1_2.py | 47 ++++++++ .../compatibility/test_json.py | 13 +-- .../sampletones_core/exporters/test_slices.py | 4 +- .../formats/bitphase/test_project_builder.py | 22 ++-- .../formats/famitracker/conftest.py | 19 +-- .../formats/famitracker/test_builder.py | 4 +- .../formats/famitracker/test_footprint.py | 4 +- .../sampletones_core/performance/test_rows.py | 28 ++--- .../project/patterns/test_channel.py | 4 +- .../project/patterns/test_pattern.py | 12 +- .../project/test_container.py | 23 ++-- .../sampletones_core/project/test_models.py | 40 +++---- .../project/test_serialization.py | 7 +- .../sampletones_core/project/test_song.py | 22 ++-- .../project/test_structure.py | 30 ++--- .../sampletones_core/project/test_tuning.py | 8 +- .../{instruments => voices}/__init__.py | 0 .../{instruments => voices}/test_sample.py | 7 +- .../sampletones_core/utils/test_display.py | 59 +++++----- .../compression/test_seeds.py | 2 +- 110 files changed, 1017 insertions(+), 903 deletions(-) create mode 100644 src/sampletones_core/compatibility/project/v1_2.py delete mode 100644 src/sampletones_core/project/instruments/__init__.py delete mode 100644 src/sampletones_core/project/instruments/instrument.py create mode 100644 src/sampletones_core/project/voices/__init__.py create mode 100644 src/sampletones_core/project/voices/loop.py rename src/sampletones_core/project/{instruments => voices}/note_off.py (100%) create mode 100644 src/sampletones_core/project/voices/note_on.py rename src/sampletones_core/project/{instruments => voices}/record.py (57%) rename src/sampletones_core/project/{instruments => voices}/sample.py (56%) create mode 100644 tests/unit/sampletones_core/compatibility/project/test_v1_2.py rename tests/unit/sampletones_core/project/{instruments => voices}/__init__.py (100%) rename tests/unit/sampletones_core/project/{instruments => voices}/test_sample.py (78%) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 155e95dd6..95346c644 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -157,7 +157,7 @@ from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.stage import ExportStage -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode from sampletones_core.types.feature import FeatureValue @@ -1023,10 +1023,10 @@ def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) - def _edit_project_sample(self, sample_id: str) -> None: - sample = self.project_manager.current.sample(sample_id) + def _edit_project_sample(self, voice_id: str) -> None: + sample = self.project_manager.current.voice(voice_id) if sample is None: - logger.warning(f"Cannot edit unknown project sample: {sample_id}") + logger.warning(f"Cannot edit unknown project sample: {voice_id}") return self.reconstruction_manager.load_reconstruction_object( @@ -1036,7 +1036,7 @@ def _edit_project_sample(self, sample_id: str) -> None: def _rebind_replaced_sample( self, - sample_id: str, + voice_id: str, reconstruction: Reconstruction, ) -> None: """Points the open Reconstructions-tab document at the reconstruction replacing the one it edits. @@ -1046,10 +1046,10 @@ def _rebind_replaced_sample( reconstruction, which is what identifies the open document as belonging to it. Args: - sample_id: The sample receiving a new reconstruction. + voice_id: The sample receiving a new reconstruction. reconstruction: The reconstruction the sample is about to hold. """ - sample = self.project_manager.current.sample(sample_id) + sample = self.project_manager.current.voice(voice_id) if sample is None or sample.reconstruction is not self.reconstruction_manager.reconstruction: return @@ -1096,18 +1096,18 @@ def _on_reconstruction_updated( edit.reconstruction, ) - def _edit_detail(self, sample_id: str, edit: ReconstructionEdit) -> HistoryDetail: + def _edit_detail(self, voice_id: str, edit: ReconstructionEdit) -> HistoryDetail: """The history line an edit reads as: the feature it moved, or the recording it took out.""" match edit: case InstrumentEdit(): return self._sequencer_tab.reconstruction_edit_detail( - sample_id, + voice_id, edit.channel_name, edit.feature_key, ) case StemRemoval(): return self._sequencer_tab.reconstruction_stem_detail( - sample_id, + voice_id, edit.stem_name, ) @@ -1120,7 +1120,7 @@ def _retune_samples_for_rate(self, nes_frequency: int) -> None: """ targets = [ (sample.id, sample.reconstruction) - for sample in self.project_manager.current.samples + for sample in self.project_manager.current.voices if sample.reconstruction.config.nes_frequency != nes_frequency ] if not targets: @@ -1160,7 +1160,7 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: open in the Reconstructions tab rebinds so its editor and the project sample stay one object. """ project = self.project_manager.current - sample = project.samples.get(retuned.sample_id) + sample = project.voices.get(retuned.voice_id) if sample is None: return @@ -1175,7 +1175,7 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: coalesce=(nes_frequency,), ): self.project_controller.replace_sample_reconstruction( - retuned.sample_id, + retuned.voice_id, retuned.reconstruction, ) @@ -1319,7 +1319,7 @@ def _owning_project_sample(self) -> Optional[Sample]: if reconstruction is None: return None - for sample in self.project_manager.current.samples: + for sample in self.project_manager.current.voices: if sample.reconstruction is reconstruction: return sample @@ -1352,7 +1352,7 @@ def _reconstruction_title_part(self) -> Optional[ReconstructionTitlePart]: unsaved_changes = self._reconstruction_coordinator.is_unsaved() sample = self._owning_project_sample() if sample is not None: - ordinal = self.project_manager.current.samples.get_index(sample.id) + ordinal = self.project_manager.current.voices.get_index(sample.id) name = SEQUENCER_SAMPLE_TITLE_FORMAT.format( ordinal=format(ordinal, SEQUENCER_SAMPLE_ORDINAL_FORMAT), name=sample.name, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 3ed224e91..ced21012b 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -604,7 +604,7 @@ def _paste_order_block(self, cell: OrderCell) -> None: self._order_block_writer.write(block, cell) def _wire_samples_callbacks(self) -> None: - self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed + self._sequencer_samples_logic.on_voices_changed = self._on_voices_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample self._sequencer_samples_logic.on_autoplay_error = self._on_preview_error self._sequencer_samples_panel.sample_footprint = self._sequencer_samples_logic.build_sample_footprint @@ -615,18 +615,18 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.set_sample_loop, detail=self._history_detail.set_sample_loop, ) - self._sequencer_samples_panel.on_remove_requested = self._remove_sample + self._sequencer_samples_panel.on_remove_requested = self._remove_voice self._sequencer_samples_panel.on_play_requested = self._sequencer_samples_logic.play_sample self._sequencer_samples_panel.on_move_requested = self._undoable( HistoryAction.MOVE_SAMPLE, - self._sequencer_samples_logic.move_sample, - detail=self._history_detail.move_sample, + self._sequencer_samples_logic.move_voice, + detail=self._history_detail.move_voice, ) self._sequencer_samples_panel.on_rename_committed = self._submit_rename self._sequencer_samples_panel.on_duplicate_requested = self._undoable( HistoryAction.DUPLICATE_SAMPLE, - self._sequencer_samples_logic.duplicate_sample, - detail=self._history_detail.duplicate_sample, + self._sequencer_samples_logic.duplicate_voice, + detail=self._history_detail.duplicate_voice, ) def _wire_browser_callbacks(self) -> None: @@ -651,7 +651,7 @@ def _wire_playback_callbacks(self) -> None: def _wire_project_callbacks(self) -> None: self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings self._project_controller.on_song_changed = self._on_song_changed - self._project_controller.on_samples_changed = self._sequencer_samples_logic.push_samples + self._project_controller.on_voices_changed = self._sequencer_samples_logic.push_samples self._project_controller.on_project_replaced = self._on_project_replaced def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: @@ -801,7 +801,7 @@ def _edit_row_key( self, row_index: int, channel: Optional[ChannelName], - sample_id: Optional[str], + voice_id: Optional[str], transpose: Optional[int], volume: Optional[int], ) -> CoalesceKey: @@ -813,7 +813,7 @@ def _edit_row_key( """ return ( *self._cell_key(row_index, channel), - sample_id is not None, + voice_id is not None, transpose is not None, volume is not None, ) @@ -873,24 +873,24 @@ def refresh_history(self) -> None: def reconstruction_edit_detail( self, - sample_id: str, + voice_id: str, channel_name: ChannelName, feature_key: FeatureKey, ) -> HistoryDetail: """Describes a reconstruction edit for the project history's detail line.""" return self._history_detail.edit_reconstruction( - sample_id, + voice_id, channel_name, feature_key, ) def reconstruction_stem_detail( self, - sample_id: str, + voice_id: str, stem_name: str, ) -> HistoryDetail: """Describes a recording taken out of a reconstruction for the project history.""" - return self._history_detail.remove_stem(sample_id, stem_name) + return self._history_detail.remove_stem(voice_id, stem_name) def _build_history_view_model(self) -> HistoryViewModel: cursor = self._history.cursor @@ -1192,7 +1192,7 @@ def replace_reconstruction(self, filepath: Path) -> None: self._reconcile_nes_frequency( reconstruction, lambda adopt_frequency: self._commit_replace_reconstruction( - selection.sample_id, + selection.voice_id, reconstruction, filepath.stem, adopt_frequency=adopt_frequency, @@ -1202,7 +1202,7 @@ def replace_reconstruction(self, filepath: Path) -> None: def _commit_replace_reconstruction( self, - sample_id: str, + voice_id: str, reconstruction: Reconstruction, name: str, *, @@ -1217,7 +1217,7 @@ def _commit_replace_reconstruction( and the substitution share a single history entry, so one undo restores the previous rate, name, and audio together. """ - detail = self._history_detail.replace_sample(sample_id, name) + detail = self._history_detail.replace_sample(voice_id, name) with self._history.transaction( HistoryAction.REPLACE_SAMPLE, detail=detail, @@ -1225,9 +1225,9 @@ def _commit_replace_reconstruction( if adopt_frequency is not None: self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency) - self._sequencer_samples_logic.rename_sample(sample_id, name) - self._on_sample_reconstruction_replaced(sample_id, reconstruction) - self._sequencer_browser_logic.replace_reconstruction(sample_id, reconstruction) + self._sequencer_samples_logic.rename_voice(voice_id, name) + self._on_sample_reconstruction_replaced(voice_id, reconstruction) + self._sequencer_browser_logic.replace_reconstruction(voice_id, reconstruction) def _replace_target_label(self) -> Optional[str]: """The indexed label of the sample a browser replacement would overwrite, while one is selected.""" @@ -1237,8 +1237,8 @@ def _replace_target_label(self) -> Optional[str]: return selection.label - def _dispatch_edit_sample(self, sample_id: str) -> None: - self._on_edit_sample_requested(sample_id) + def _dispatch_edit_sample(self, voice_id: str) -> None: + self._on_edit_sample_requested(voice_id) def _on_tracker_play_from_row(self, row_index: int) -> None: """Starts playback from the right-clicked row of the frame the tracker is showing.""" @@ -1247,59 +1247,59 @@ def _on_tracker_play_from_row(self, row_index: int) -> None: row_index, ) - def _on_samples_changed( + def _on_voices_changed( self, view_model: SequencerSamplesViewModel, ) -> None: self._sequencer_samples_panel.update_view(view_model) self._sequencer_tracker_panel.update_samples(view_model) - def _on_sample_selected(self, sample_id: str) -> None: + def _on_sample_selected(self, voice_id: str) -> None: self._sequencer_tracker_panel.deselect_cell() self._sequencer_order_panel.deselect_cell() - self._sequencer_samples_logic.request_autoplay(sample_id) - logger.debug(f"Sequencer sample selected: {sample_id}") + self._sequencer_samples_logic.request_autoplay(voice_id) + logger.debug(f"Sequencer sample selected: {voice_id}") - def _remove_sample(self, sample_id: str) -> None: + def _remove_voice(self, voice_id: str) -> None: """Removes a sample, confirming first only when a pattern still references it. An unused sample is dropped silently; a referenced one would clear every row that points at it, so the user confirms that loss first. """ - if not self._sequencer_samples_logic.is_sample_used(sample_id): - self._perform_remove_sample(sample_id) + if not self._sequencer_samples_logic.is_voice_used(voice_id): + self._perform_remove_voice(voice_id) return - name = self._sequencer_samples_logic.sample_name(sample_id) + name = self._sequencer_samples_logic.sample_name(voice_id) self._dialogs.show_confirmation( tag=TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE, title=self._language_manager["global.dialog.title.remove_sample"], message=self._language_manager["global.dialog.message.remove_sample"].format(name=name), - on_confirm=lambda: self._perform_remove_sample(sample_id), + on_confirm=lambda: self._perform_remove_voice(voice_id), ok_label=self._language_manager["global.dialog.label.remove"], ) - def _perform_remove_sample(self, sample_id: str) -> None: - detail = self._history_detail.remove_sample(sample_id) + def _perform_remove_voice(self, voice_id: str) -> None: + detail = self._history_detail.remove_voice(voice_id) with self._history.transaction( HistoryAction.REMOVE_SAMPLE, detail=detail, ): - self._sequencer_samples_logic.remove_sample(sample_id) + self._sequencer_samples_logic.remove_voice(voice_id) - def _submit_rename(self, sample_id: str, name: str) -> None: + def _submit_rename(self, voice_id: str, name: str) -> None: """Applies an inline rename, ignoring a blank name so the sample keeps its current one.""" stripped = name.strip() if stripped: - detail = self._history_detail.rename_sample( - self._sequencer_samples_logic.sample_name(sample_id), + detail = self._history_detail.rename_voice( + self._sequencer_samples_logic.sample_name(voice_id), stripped, ) with self._history.transaction( HistoryAction.RENAME_SAMPLE, detail=detail, ): - self._sequencer_samples_logic.rename_sample(sample_id, stripped) + self._sequencer_samples_logic.rename_voice(voice_id, stripped) def _request_nes_frequency_change(self, nes_frequency: int) -> None: """Applies a NES-frequency change, confirming first when it would re-time existing samples. diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index cea0eba01..52b69966d 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -25,10 +25,10 @@ def fingerprint_project( project.settings.model_dump_json(), project.song.model_dump_json(), ] - for sample in project.samples: + for sample in project.voices: parts.append(sample.id) parts.append(sample.name) - parts.append(str(sample.loop)) + parts.append(str(sample.loop_point)) parts.append(reconstruction_hash(sample.reconstruction)) combined = "|".join(parts) @@ -62,5 +62,5 @@ def hash(self, reconstruction: Reconstruction) -> str: return cached[1] def prune(self, projects: Iterable[Project]) -> None: - live = {id(sample.reconstruction) for project in projects for sample in project.samples} + live = {id(sample.reconstruction) for project in projects for sample in project.voices} self._hashes = {key: value for key, value in self._hashes.items() if key in live} diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 23b65f0d2..3e84f348f 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -6,9 +6,9 @@ from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE from sampletones_core.exports.request import ProjectExport from sampletones_core.project import Project -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp @@ -38,7 +38,7 @@ def __init__(self, project_manager: ProjectManager) -> None: self.on_project_replaced: Optional[VoidCallback] = None self.on_info_changed: Optional[VoidCallback] = None self.on_settings_changed: Optional[VoidCallback] = None - self.on_samples_changed: Optional[VoidCallback] = None + self.on_voices_changed: Optional[VoidCallback] = None self.on_song_changed: Optional[VoidCallback] = None self.on_mutation: Optional[VoidCallback] = None self.on_saved: Optional[VoidCallback] = None @@ -66,11 +66,11 @@ def name(self) -> str: @property def has_samples(self) -> bool: - return bool(self.project.samples) + return bool(self.project.voices) @property def sample_count(self) -> int: - return len(self.project.samples) + return len(self.project.voices) @property def is_dirty(self) -> bool: @@ -195,12 +195,12 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: """ reconstruction.detach_source() sample = Sample(name=name, reconstruction=reconstruction) - self.project.samples.append(sample) + self.project.voices.append(sample) self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) return sample - def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstruction) -> None: + def replace_sample_reconstruction(self, voice_id: str, reconstruction: Reconstruction) -> None: """Substitutes a sample's reconstruction, detaching its local source-audio origin. The sample keeps its id, so every pattern row referencing it stays valid and the tracker @@ -208,54 +208,55 @@ def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstr reconstruction, the project stays a self-contained, shareable artifact. """ reconstruction.detach_source() - self.project.samples[sample_id].reconstruction = reconstruction + self.project.voices[voice_id].reconstruction = reconstruction self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def rename_sample(self, sample_id: str, name: str) -> None: - self.project.samples[sample_id].name = name + def rename_voice(self, voice_id: str, name: str) -> None: + self.project.voices[voice_id].name = name self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def set_sample_loop(self, sample_id: str, loop: bool) -> None: - self.project.samples[sample_id].loop = loop + def set_voice_loop_point(self, voice_id: str, loop_point: Optional[int]) -> None: + """Sets the tick a voice's instructions repeat from, or ``None`` where it plays once.""" + self.project.voices[voice_id].loop_point = loop_point self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) - def is_sample_used(self, sample_id: str) -> bool: - return self.song.references_sample(sample_id) + def is_voice_used(self, voice_id: str) -> bool: + return self.song.references_voice(voice_id) - def remove_sample(self, sample_id: str) -> None: - self.project.samples.pop(sample_id) - self.song.clear_sample_references(sample_id) + def remove_voice(self, voice_id: str) -> None: + self.project.voices.pop(voice_id) + self.song.clear_voice_references(voice_id) self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def duplicate_sample(self, sample_id: str) -> Sample: - """Appends an independent copy of a sample (same name and loop flag). + def duplicate_voice(self, voice_id: str) -> Sample: + """Appends an independent copy of a voice (same name and loop point). - The copy is appended, so existing samples keep their positions; it keeps the + The copy is appended, so existing voices keep their positions; it keeps the source name (like duplicated patterns), leaving renaming to the user. """ - clone = self.project.samples[sample_id].clone() - self.project.samples.append(clone) + clone = self.project.voices[voice_id].clone() + self.project.voices.append(clone) self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) return clone - def move_sample(self, sample_id: str, to_index: int) -> None: - """Reorders the sample pool. + def move_voice(self, voice_id: str, to_index: int) -> None: + """Reorders the voice pool. - Pattern rows reference samples by stable id, so reordering keeps every + Pattern rows reference voices by stable id, so reordering keeps every reference valid; it only changes the positional index the tracker displays, hence ``on_song_changed`` fires so the grid re-renders those indices. """ - self.project.samples.move(sample_id, to_index) + self.project.voices.move(voice_id, to_index) self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) self._announce(self.on_song_changed) def add_pattern(self, channel: ChannelName) -> int: diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py index b9da5ada0..41c20a1fd 100644 --- a/src/sampletones_application/logic/reconstruction/edit.py +++ b/src/sampletones_application/logic/reconstruction/edit.py @@ -18,9 +18,9 @@ class InstrumentEdit: channel_name: ChannelName feature_key: FeatureKey - def coalesce_key(self, sample_id: str) -> Optional[CoalesceKey]: + def coalesce_key(self, voice_id: str) -> Optional[CoalesceKey]: """Consecutive edits of one sample run together, so a graph movement records one entry.""" - return (sample_id,) + return (voice_id,) @dataclass(frozen=True) @@ -30,7 +30,7 @@ class StemRemoval: reconstruction: Reconstruction stem_name: str - def coalesce_key(self, _sample_id: str) -> Optional[CoalesceKey]: + def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]: """Each removal stands on its own, so one undo puts one recording back.""" return None diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py index 43fc35655..27c105706 100644 --- a/src/sampletones_application/logic/sequencer/browser.py +++ b/src/sampletones_application/logic/sequencer/browser.py @@ -3,7 +3,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.browser.manager import BrowserManager -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import Tree from sampletones_shared.utils.callbacks import CallbackMixin @@ -51,7 +51,7 @@ def add_reconstruction( def replace_reconstruction( self, - sample_id: str, + voice_id: str, reconstruction: Reconstruction, ) -> None: """Substitutes an existing sample's reconstruction with an already-loaded one. @@ -59,4 +59,4 @@ def replace_reconstruction( The sample keeps its identity, so the patterns referencing it sound the new reconstruction while their rows stay as they were. """ - self._controller.replace_sample_reconstruction(sample_id, reconstruction) + self._controller.replace_sample_reconstruction(voice_id, reconstruction) diff --git a/src/sampletones_application/logic/sequencer/clipboard/samples.py b/src/sampletones_application/logic/sequencer/clipboard/samples.py index 2e4099521..5459c5c6e 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/samples.py +++ b/src/sampletones_application/logic/sequencer/clipboard/samples.py @@ -6,7 +6,7 @@ class SampleDirectory(Protocol): """The samples a note can name, read the way a grid prints them: by list position.""" - def position_of(self, sample_id: str) -> Optional[int]: ... + def position_of(self, voice_id: str) -> Optional[int]: ... def sample_at(self, position: int) -> Optional[str]: ... @@ -21,17 +21,17 @@ class ProjectSampleDirectory: def __init__(self, project_controller: ProjectController) -> None: self._controller = project_controller - def position_of(self, sample_id: str) -> Optional[int]: + def position_of(self, voice_id: str) -> Optional[int]: """Where a sample stands in the list, present while the project holds it.""" - samples = self._controller.project.samples - if samples.get(sample_id) is None: + samples = self._controller.project.voices + if samples.get(voice_id) is None: return None - return samples.get_index(sample_id) + return samples.get_index(voice_id) def sample_at(self, position: int) -> Optional[str]: """The sample a position names, present while the list reaches that far.""" - samples = self._controller.project.samples + samples = self._controller.project.voices if 0 <= position < len(samples): return samples[position].id diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py index cb5cb88f7..b191e5122 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/tracker.py +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -18,7 +18,7 @@ MIN_TRANSPOSE, SILENT_VOLUME, ) -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.utils.display import ( NOTE_OFF, display_id, @@ -156,8 +156,8 @@ def _state_note( match notes[key]: case NoteOff(): return NOTE_OFF - case str() as sample_id: - position = self._samples.position_of(sample_id) + case str() as voice_id: + position = self._samples.position_of(voice_id) return state_mixed(NOTE_WIDTH) if position is None else display_id(position) case _: return display_id(None) @@ -238,8 +238,8 @@ def _read_note(self, field: str) -> Optional[FieldReading[BlockNote]]: if position is None: return None - sample_id = self._samples.sample_at(position) - return FieldReading.mixed() if sample_id is None else FieldReading.of(sample_id) + voice_id = self._samples.sample_at(position) + return FieldReading.mixed() if voice_id is None else FieldReading.of(voice_id) @staticmethod def _read_transpose(field: str) -> Optional[FieldReading[int]]: diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 8a90c43cd..225b3dfb1 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -88,15 +88,15 @@ def edit_row( self, row_index: int, channel: Optional[ChannelName], - sample_id: Optional[str], + voice_id: Optional[str], transpose: Optional[int], volume: Optional[int], ) -> Segments: - affected = self._edit_row_channels(channel, sample_id, row_index) + affected = self._edit_row_channels(channel, voice_id, row_index) segments = list(self._location(row_index, channel, affected)) - if sample_id is not None: + if voice_id is not None: segments.append(self._arrow()) - segments.append(self._sample(sample_id)) + segments.append(self._sample(voice_id)) if transpose is not None: segments.append(self._subcolumn(SubColumn.TRANSPOSE)) @@ -228,51 +228,51 @@ def set_master_entry( def add_sample(self, name: str) -> Segments: return (self._name(name),) - def remove_sample(self, sample_id: str) -> Segments: + def remove_voice(self, voice_id: str) -> Segments: return ( - self._sample(sample_id, colon=True), - self._name(self._samples_logic.sample_name(sample_id)), + self._sample(voice_id, colon=True), + self._name(self._samples_logic.sample_name(voice_id)), ) - def replace_sample(self, sample_id: str, name: str) -> Segments: + def replace_sample(self, voice_id: str, name: str) -> Segments: """Describes a reconstruction substitution as the sample's position and the two names. ``name`` is the incoming reconstruction's, read against the sample's current one, so the caller builds this detail while the sample still holds the reconstruction being replaced. """ return ( - self._sample(sample_id, colon=True), - self._name(self._samples_logic.sample_name(sample_id)), + self._sample(voice_id, colon=True), + self._name(self._samples_logic.sample_name(voice_id)), self._arrow(), self._name(name), ) - def rename_sample(self, old_name: str, new_name: str) -> Segments: + def rename_voice(self, old_name: str, new_name: str) -> Segments: return (self._name(old_name), self._arrow(), self._name(new_name)) - def move_sample(self, sample_id: str, to_index: int) -> Segments: + def move_voice(self, voice_id: str, to_index: int) -> Segments: return ( - self._sample(sample_id), + self._sample(voice_id), self._arrow(), self._value(display_id(to_index)), ) - def duplicate_sample(self, sample_id: str) -> Segments: + def duplicate_voice(self, voice_id: str) -> Segments: return ( - self._sample(sample_id, colon=True), - self._name(self._samples_logic.sample_name(sample_id)), + self._sample(voice_id, colon=True), + self._name(self._samples_logic.sample_name(voice_id)), ) - def set_sample_loop(self, sample_id: str, loop: bool) -> Segments: + def set_sample_loop(self, voice_id: str, loop: bool) -> Segments: word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF return ( - self._sample(sample_id, colon=True), + self._sample(voice_id, colon=True), HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE), ) def edit_reconstruction( self, - sample_id: str, + voice_id: str, channel_name: ChannelName, feature_key: FeatureKey, ) -> Segments: @@ -283,15 +283,15 @@ def edit_reconstruction( tab plots it with — mirroring the tracker rows. """ return ( - self._sample(sample_id, colon=True), + self._sample(voice_id, colon=True), self._channel([channel_name]), self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]), ) - def remove_stem(self, sample_id: str, stem_name: str) -> Segments: + def remove_stem(self, voice_id: str, stem_name: str) -> Segments: """Describes a recording taken out of a sample's reconstruction: its position and name.""" return ( - self._sample(sample_id, colon=True), + self._sample(voice_id, colon=True), self._name(stem_name), ) @@ -301,14 +301,14 @@ def value(self, number: int) -> Segments: def _edit_row_channels( self, channel: Optional[ChannelName], - sample_id: Optional[str], + voice_id: Optional[str], row_index: int, ) -> List[ChannelName]: if channel is not None: return [channel] - if sample_id is not None: - return self._tracker_logic.used_generators(sample_id) + if voice_id is not None: + return self._tracker_logic.used_generators(voice_id) return self._tracker_logic.relevant_channels(row_index) @@ -417,11 +417,11 @@ def _name(self, text: str) -> HistoryDetailSegment: def _sample( self, - sample_id: str, + voice_id: str, *, colon: bool = False, ) -> HistoryDetailSegment: - position = self._samples_logic.sample_position(sample_id) + position = self._samples_logic.sample_position(voice_id) text = f"{position}:" if colon else position return HistoryDetailSegment(text=text, role=HistoryDetailRole.SAMPLE) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 5fd054bb2..22a9511cd 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -196,13 +196,13 @@ def _render_channel( if row is not None and apply_row(state.performance, row): state.generator.reset() - sample_id = state.performance.sample_id - if sample_id is None or channel_name not in self._active_channels(): + voice_id = state.performance.voice_id + if voice_id is None or channel_name not in self._active_channels(): return silence(frames.total) return self._synthesize_ticks( state, - sample_id, + voice_id, project, channel_name, frames, @@ -211,12 +211,12 @@ def _render_channel( def _synthesize_ticks( self, state: ChannelState, - sample_id: str, + voice_id: str, project: Project, channel_name: ChannelName, frames: RowFrames, ) -> np.ndarray: - sample = project.sample(sample_id) + sample = project.voice(voice_id) if sample is None: return silence(frames.total) @@ -233,7 +233,7 @@ def _synthesize_ticks( state, instructions, silence_frame[:frame_length], - sample.loop, + sample.loops, frame_length, voice, ) diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py index 49051bf3c..bfc8715fd 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -14,9 +14,10 @@ from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.audio import AudioDeviceManager from sampletones_core.formats.famitracker.footprint import reconstruction_footprints -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction -from sampletones_core.utils.display import display_sample +from sampletones_core.utils.display import display_voice from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback @@ -50,34 +51,34 @@ def __init__( self._scheduling = scheduling self._pending_autoplay_sample: Optional[str] = None - self.on_samples_changed: Optional[Callable[[SequencerSamplesViewModel], None]] = None + self.on_voices_changed: Optional[Callable[[SequencerSamplesViewModel], None]] = None self.on_edit_sample_requested: Optional[StringCallback] = None self.on_autoplay_error: Optional[Callable[[Exception], None]] = None def build_samples(self) -> SequencerSamplesViewModel: entries = tuple( SampleEntryViewModel( - sample_id=sample.id, + voice_id=sample.id, name=sample.name, - loop=sample.loop, + loop=sample.loops, ) - for sample in self._controller.project.samples + for sample in self._controller.project.voices ) return SequencerSamplesViewModel(samples=entries) def push_samples(self) -> None: - self.call(self.on_samples_changed, self.build_samples()) + self.call(self.on_voices_changed, self.build_samples()) def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: return self._controller.add_sample(reconstruction, name) - def rename_sample(self, sample_id: str, name: str) -> None: - self._controller.rename_sample(sample_id, name) + def rename_voice(self, voice_id: str, name: str) -> None: + self._controller.rename_voice(voice_id, name) - def is_sample_used(self, sample_id: str) -> bool: - return self._controller.is_sample_used(sample_id) + def is_voice_used(self, voice_id: str) -> bool: + return self._controller.is_voice_used(voice_id) - def build_sample_footprint(self, sample_id: str) -> Optional[SampleFootprintViewModel]: + def build_sample_footprint(self, voice_id: str) -> Optional[SampleFootprintViewModel]: """Measures one sample's instruments as the module export writes them. A sample carries its own loop flag, and a looping instrument is compiled to the shortest @@ -85,57 +86,62 @@ def build_sample_footprint(self, sample_id: str) -> Optional[SampleFootprintView single sample on demand keeps a pool edit clear of an export it was not asked for. Args: - sample_id: The sample to measure. + voice_id: The sample to measure. Returns: Optional[SampleFootprintViewModel]: The sample's byte figures, or ``None`` while the pool holds no such sample. """ - sample = self._controller.project.samples.get(sample_id) + sample = self._controller.project.voices.get(voice_id) if sample is None: return None return SampleFootprintViewModel.from_footprints( - reconstruction_footprints(sample.reconstruction, loop=sample.loop) + reconstruction_footprints(sample.reconstruction, loop=sample.loops) ) - def sample_name(self, sample_id: str) -> str: - return self._controller.project.samples[sample_id].name + def sample_name(self, voice_id: str) -> str: + return self._controller.project.voices[voice_id].name - def sample_position(self, sample_id: str) -> str: + def sample_position(self, voice_id: str) -> str: """Returns the sample's hex list position, matching how the tracker labels it.""" - return display_sample( - samples=self._controller.project.samples, - sample_id=sample_id, + return display_voice( + voices=self._controller.project.voices, + voice_id=voice_id, ) - def remove_sample(self, sample_id: str) -> None: - self._controller.remove_sample(sample_id) + def remove_voice(self, voice_id: str) -> None: + self._controller.remove_voice(voice_id) - def move_sample(self, sample_id: str, to_index: int) -> None: - self._controller.move_sample(sample_id, to_index) + def move_voice(self, voice_id: str, to_index: int) -> None: + self._controller.move_voice(voice_id, to_index) - def duplicate_sample(self, sample_id: str) -> None: - self._controller.duplicate_sample(sample_id) + def duplicate_voice(self, voice_id: str) -> None: + self._controller.duplicate_voice(voice_id) - def set_sample_loop(self, sample_id: str, loop: bool) -> None: - self._controller.set_sample_loop(sample_id, loop) + def set_sample_loop(self, voice_id: str, loop: bool) -> None: + """Turns the list's loop tick into the point the voice repeats from. - def request_edit(self, sample_id: str) -> None: + The list offers looping as a switch, and a voice that loops repeats the whole of its + instructions, which is the point at their start. + """ + self._controller.set_voice_loop_point(voice_id, WHOLE_LOOP_POINT if loop else None) + + def request_edit(self, voice_id: str) -> None: self.cancel_autoplay() - self.call(self.on_edit_sample_requested, sample_id) + self.call(self.on_edit_sample_requested, voice_id) - def play_sample(self, sample_id: str) -> None: + def play_sample(self, voice_id: str) -> None: """Plays a sample on demand, regardless of the autoplay setting. Explicit playback is intentional, so it uses ``NORMAL`` priority and thereby preempts the sequencer song / reconstruction players. """ - self._play_sample(sample_id, priority=PlaybackPriority.NORMAL) + self._play_sample(voice_id, priority=PlaybackPriority.NORMAL) - def request_autoplay(self, sample_id: str) -> None: + def request_autoplay(self, voice_id: str) -> None: """Schedules a debounced preview that a following double-click can cancel.""" - self._pending_autoplay_sample = sample_id + self._pending_autoplay_sample = voice_id CallbackQueue.add( self._execute_autoplay, priority=self._scheduling.priorities.schedule, @@ -149,18 +155,18 @@ def _execute_autoplay(self) -> None: if self._pending_autoplay_sample is None: return - sample_id = self._pending_autoplay_sample + voice_id = self._pending_autoplay_sample self._pending_autoplay_sample = None if self._session_manager.autoplay: - self._play_sample(sample_id, priority=PlaybackPriority.PREVIEW) + self._play_sample(voice_id, priority=PlaybackPriority.PREVIEW) def _play_sample( self, - sample_id: str, + voice_id: str, *, priority: PlaybackPriority, ) -> None: - sample = self._controller.project.samples.get(sample_id) + sample = self._controller.project.voices.get(voice_id) if sample is None: return @@ -173,6 +179,6 @@ def _play_sample( except (PlaybackError, ValueError) as exception: logger.error_with_traceback( exception, - f"Failed to preview sample: {sample_id}", + f"Failed to preview sample: {voice_id}", ) self.call(self.on_autoplay_error, exception) diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py index a55019326..fc223e39f 100644 --- a/src/sampletones_application/logic/sequencer/tracker/block.py +++ b/src/sampletones_application/logic/sequencer/tracker/block.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Dict, Optional, Tuple, Union -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.voices.note_off import NoteOff BlockNote = Union[str, NoteOff] BlockKey = Tuple[int, int] diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index 70b085e83..00e700c86 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -8,9 +8,9 @@ ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from sampletones_shared.utils.agreement import Agreement from .block import BlockKey, BlockNote, TrackerBlock @@ -90,8 +90,8 @@ def _note_of(row: Optional[Row]) -> Optional[BlockNote]: column it is written into. """ match row.command if row is not None else None: - case Instrument() as instrument: - return instrument.sample_id + case NoteOn() as instrument: + return instrument.voice_id case NoteOff() as note_off: return note_off case None: diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index e510b8d89..23e497273 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -12,11 +12,11 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import NoteCommand, Row +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.utils.display import ( display_command, display_id, @@ -187,7 +187,7 @@ def write_cell( self, row_index: int, channel: Optional[ChannelName], - sample_id: Optional[str], + voice_id: Optional[str], transpose: Optional[int], volume: Optional[int], ) -> None: @@ -196,8 +196,8 @@ def write_cell( An edit names one subcolumn, so a sample takes the write whenever one arrives, and an offset lands on its own otherwise. """ - if sample_id is not None: - self.place_note(row_index, channel, sample_id) + if voice_id is not None: + self.place_note(row_index, channel, voice_id) elif transpose is not None or volume is not None: self.set_cell_subcolumn( row_index, @@ -210,18 +210,15 @@ def place_note( self, row_index: int, channel: Optional[ChannelName], - sample_id: str, + voice_id: str, ) -> None: if channel is None: - self.set_sample_instrument(row_index, sample_id) + self.set_sample_instrument(row_index, voice_id) else: self.set_row( channel, row_index, - command=Instrument( - sample_id=sample_id, - channel_name=channel, - ), + command=NoteOn(voice_id=voice_id), ) def cut_note( @@ -334,7 +331,7 @@ def clear_subcolumn_all_generators( def set_sample_instrument( self, row_index: int, - sample_id: Optional[str], + voice_id: Optional[str], ) -> None: """Places a sample across the channels its reconstruction uses. @@ -343,11 +340,11 @@ def set_sample_instrument( cleared so the row reflects exactly that sample. Clearing an empty sample id wipes the whole row. """ - if sample_id is None: + if voice_id is None: self.clear_all_channels(row_index) return - sample = self._controller.project.samples.get(sample_id) + sample = self._controller.project.voices.get(voice_id) if sample is None: return @@ -357,10 +354,7 @@ def set_sample_instrument( self.set_row( channel, row_index, - command=Instrument( - sample_id=sample_id, - channel_name=channel, - ), + command=NoteOn(voice_id=voice_id), ) else: self.clear_row(channel, row_index) @@ -486,13 +480,13 @@ def select_frame(self, frame_index: int) -> None: self._frame_index = frame_index self.push_tracker() - def holds_sample(self, sample_id: str) -> bool: + def holds_sample(self, voice_id: str) -> bool: """Whether the project holds the sample a note names, which is what makes the note placeable.""" - return self._controller.project.samples.get(sample_id) is not None + return self._controller.project.voices.get(voice_id) is not None - def used_generators(self, sample_id: str) -> List[ChannelName]: + def used_generators(self, voice_id: str) -> List[ChannelName]: """The channels a sample provides instructions for, empty when it is unknown.""" - sample = self._controller.project.samples.get(sample_id) + sample = self._controller.project.voices.get(voice_id) if sample is None: return [] @@ -576,27 +570,28 @@ def _referenced_generators_from_rows( self, rows: Dict[ChannelName, Optional[Row]], ) -> FrozenSet[ChannelName]: - """The channels spanned by the samples referenced on a row. + """The channels spanned by the voices referenced on a row. - Each referenced sample contributes the channels its reconstruction covers, - so the sample column reasons about a sample's whole channel span, including - channels whose cells are empty. + Each referenced voice contributes the channels its reconstruction covers, so the sample + column reasons about a voice's whole channel span, including channels whose cells are + empty. A row naming a voice the project no longer holds contributes the channel it sits + on, which keeps that cell reachable while the reference stands. """ relevant: Set[ChannelName] = set() resolved: Set[str] = set() - for row in rows.values(): + for channel, row in rows.items(): command = row.command if row is not None else None - if not isinstance(command, Instrument): + if not isinstance(command, NoteOn): continue - sample_id = command.sample_id - if sample_id in resolved: + voice_id = command.voice_id + if voice_id in resolved: continue - resolved.add(sample_id) - sample = self._controller.project.samples.get(sample_id) + resolved.add(voice_id) + sample = self._controller.project.voices.get(voice_id) if sample is None: - relevant.add(command.channel_name) + relevant.add(channel) else: relevant.update(self._used_generators(sample)) @@ -628,7 +623,7 @@ def _build_row( def _build_cell(self, row: Row) -> SequencerCellViewModel: return SequencerCellViewModel( instrument=display_command( - self._controller.project.samples, + self._controller.project.voices, row.command, ), transpose=display_transpose(row.transpose), diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py index cafb88ce7..8dd19cf4a 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -12,7 +12,7 @@ ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.voices.note_off import NoteOff from .block import BlockKey, BlockNote, TrackerBlock from .tracker import SequencerTrackerLogic @@ -91,9 +91,9 @@ def _write_note( match note: case NoteOff(): self._tracker.cut_note(row_index, channel) - case str() as sample_id: - if self._tracker.holds_sample(sample_id): - self._tracker.place_note(row_index, channel, sample_id) + case str() as voice_id: + if self._tracker.holds_sample(voice_id): + self._tracker.place_note(row_index, channel, voice_id) case None: self._tracker.clear_cell_subcolumn( row_index, diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py index 208546746..588695d17 100644 --- a/src/sampletones_application/logic/shared/project_source.py +++ b/src/sampletones_application/logic/shared/project_source.py @@ -16,7 +16,7 @@ def snapshot_project(project: Project) -> Project: snapshot. """ shared_reconstructions: Dict[int, object] = { - id(sample.reconstruction): sample.reconstruction for sample in project.samples + id(sample.reconstruction): sample.reconstruction for sample in project.voices } return copy.deepcopy(project, shared_reconstructions) diff --git a/src/sampletones_application/services/retune/retune.py b/src/sampletones_application/services/retune/retune.py index 64e6d01e1..ec59998ae 100644 --- a/src/sampletones_application/services/retune/retune.py +++ b/src/sampletones_application/services/retune/retune.py @@ -37,12 +37,12 @@ def is_running(self) -> bool: def _run(self, targets: List[RetuneTarget], nes_frequency: int) -> None: try: - for sample_id, reconstruction in targets: + for voice_id, reconstruction in targets: retuned = reconstruction.with_nes_frequency(nes_frequency) self._emit( ServiceSuccess( value=RetunedSample( - sample_id=sample_id, + voice_id=voice_id, reconstruction=retuned, ) ) diff --git a/src/sampletones_application/services/retune/sample.py b/src/sampletones_application/services/retune/sample.py index 4dfacbefc..26628ec0d 100644 --- a/src/sampletones_application/services/retune/sample.py +++ b/src/sampletones_application/services/retune/sample.py @@ -7,9 +7,9 @@ class RetunedSample: """A sample's reconstruction re-synthesized to a new NES frequency. - Carries the ``sample_id`` so the caller can swap the retuned reconstruction into + Carries the ``voice_id`` so the caller can swap the retuned reconstruction into the right project sample as each result arrives. """ - sample_id: str + voice_id: str reconstruction: Reconstruction diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 066d119aa..ba7082d84 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -110,9 +110,9 @@ def __init__( self._shortcuts = shortcut_source self._row_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_TABLE, SUF_HANDLER_REGISTRY) self._rename_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, SUF_HANDLER_REGISTRY) - self._selected_sample_id: Optional[str] = None + self._selected_voice_id: Optional[str] = None self._selected_row: Optional[int] = None - self._editing_sample_id: Optional[str] = None + self._editing_voice_id: Optional[str] = None self._entries: Tuple[SampleEntryViewModel, ...] = () self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) @@ -214,7 +214,7 @@ def _create_samples_table(self) -> None: def update_view(self, view_model: SequencerSamplesViewModel) -> None: self._entries = view_model.samples - self._editing_sample_id = None + self._editing_voice_id = None self._rebuild() def _rebuild(self) -> None: @@ -230,7 +230,7 @@ def _rebuild(self) -> None: for position, entry in enumerate(self._entries): self._build_sample_row(position, entry) if self._selected_row is None: - self._selected_sample_id = None + self._selected_voice_id = None def _build_sample_row( self, @@ -241,7 +241,7 @@ def _build_sample_row( self._build_id_cell(row_id, position, entry) self._build_name_cell(row_id, position, entry) self._build_loop_cell(row_id, entry) - if entry.sample_id == self._selected_sample_id: + if entry.voice_id == self._selected_voice_id: self._selected_row = position self._highlight_selected_row(position) @@ -273,7 +273,7 @@ def _build_id_cell( id_selectable = dpg.add_selectable( parent=id_cell, label=display_id(position), - user_data=(position, entry.sample_id), + user_data=(position, entry.voice_id), callback=self._on_sample_selected, ) FontRegistry.bind_to_item(id_selectable, Font.MONO_SMALL) @@ -286,7 +286,7 @@ def _build_name_cell( entry: SampleEntryViewModel, ) -> None: name_cell = dpg.add_table_cell(parent=row_id) - if entry.sample_id == self._editing_sample_id: + if entry.voice_id == self._editing_voice_id: self._build_name_input(name_cell, entry) else: self._build_name_selectable(name_cell, position, entry) @@ -300,7 +300,7 @@ def _build_name_selectable( name_selectable = dpg.add_selectable( parent=name_cell, label=entry.name, - user_data=(position, entry.sample_id), + user_data=(position, entry.voice_id), callback=self._on_sample_selected, ) FontRegistry.bind_to_item(name_selectable, Font.MONO_SMALL) @@ -331,7 +331,7 @@ def _build_loop_cell( loop_checkbox = dpg.add_checkbox( parent=loop_cell, default_value=entry.loop, - user_data=entry.sample_id, + user_data=entry.voice_id, callback=self._on_loop_toggled, ) FontRegistry.bind_to_item(loop_checkbox, Font.REGULAR_SMALL) @@ -342,7 +342,7 @@ def _on_sample_selected( _app_data: bool, user_data: Tuple[int, str], ) -> None: - position, sample_id = user_data + position, voice_id = user_data dpg.set_value(sender, False) if self._selected_row is not None: dpg.unhighlight_table_row( @@ -351,9 +351,9 @@ def _on_sample_selected( ) self._selected_row = position - self._selected_sample_id = sample_id + self._selected_voice_id = voice_id self._highlight_selected_row(position) - self.call(self.on_sample_selected, sample_id) + self.call(self.on_sample_selected, voice_id) @property def selection(self) -> Optional[SampleSelection]: @@ -363,15 +363,15 @@ def selection(self) -> Optional[SampleSelection]: whatever the table currently shows. Lets an operation hosted by another panel of the tab address the selection without keeping a copy of it. """ - if self._selected_sample_id is None or self._selected_row is None: + if self._selected_voice_id is None or self._selected_row is None: return None - entry = self._entry_for(self._selected_sample_id) + entry = self._entry_for(self._selected_voice_id) if entry is None: return None return SampleSelection( - sample_id=entry.sample_id, + voice_id=entry.voice_id, position=self._selected_row, name=entry.name, ) @@ -390,7 +390,7 @@ def deselect(self) -> None: ) self._selected_row = None - self._selected_sample_id = None + self._selected_voice_id = None def _keys_active(self) -> bool: """Whether the samples panel owns the next key. @@ -403,10 +403,10 @@ def _keys_active(self) -> bool: if not self._tab_active(): return False - if self._editing_sample_id is not None: + if self._editing_voice_id is not None: return True - return self._selected_sample_id is not None and not self._router.is_field_focused + return self._selected_voice_id is not None and not self._router.is_field_focused def _on_key_pressed(self, event: KeyEvent) -> bool: """Applies a samples key to the selected sample, reporting whether the panel consumed it. @@ -415,21 +415,21 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: leaves unnamed goes to the application's global shortcuts. """ shortcut_id = self._shortcuts.action(ShortcutCategory.SAMPLES, event) - if self._editing_sample_id is not None: + if self._editing_voice_id is not None: return self._cancel_edit(shortcut_id) - sample_id = self._selected_sample_id - if sample_id is None or shortcut_id is None: + voice_id = self._selected_voice_id + if voice_id is None or shortcut_id is None: return False - if self._move_sample(shortcut_id): + if self._move_voice(shortcut_id): return True match shortcut_id: case ShortcutId.SAMPLES_REMOVE_SAMPLE: - self.call(self.on_remove_requested, sample_id) + self.call(self.on_remove_requested, voice_id) case ShortcutId.SAMPLES_RENAME_SAMPLE: - self._start_rename(sample_id) + self._start_rename(voice_id) case _: return False @@ -447,28 +447,28 @@ def _cancel_edit(self, shortcut_id: Optional[ShortcutId]) -> bool: self._cancel_rename() return True - def _move_sample(self, shortcut_id: ShortcutId) -> bool: + def _move_voice(self, shortcut_id: ShortcutId) -> bool: """Moves the selected sample up, down, to the top or to the bottom of the list. Returns whether the action was one of the moves, so a boundary with nowhere to go still counts as consumed and stays out of the global shortcuts. """ direction = MOVE_DIRECTIONS.get(shortcut_id) - if direction is None or self._selected_sample_id is None or self._selected_row is None: + if direction is None or self._selected_voice_id is None or self._selected_row is None: return False target = direction.target(self._selected_row, len(self._entries)) if target is not None: - self.call(self.on_move_requested, self._selected_sample_id, target) + self.call(self.on_move_requested, self._selected_voice_id, target) return True - def _start_rename(self, sample_id: str) -> None: + def _start_rename(self, voice_id: str) -> None: """Turns the sample's name cell into a focused text input.""" - if self._entry_for(sample_id) is None: + if self._entry_for(voice_id) is None: return - self._editing_sample_id = sample_id + self._editing_voice_id = voice_id self._rebuild() FrameCallbackManager.set_frame_callback(lambda: dpg.focus_item(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME)) @@ -478,20 +478,20 @@ def _commit_rename(self) -> None: Clears the edit before notifying so the input's deactivated handler, fired during the teardown rebuild, sees the edit already finished. """ - if self._editing_sample_id is None: + if self._editing_voice_id is None: return - sample_id = self._editing_sample_id + voice_id = self._editing_voice_id name = dpg.get_value(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME) - self._editing_sample_id = None - self.call(self.on_rename_committed, sample_id, name) + self._editing_voice_id = None + self.call(self.on_rename_committed, voice_id, name) self._rebuild() def _cancel_rename(self) -> None: - if self._editing_sample_id is None: + if self._editing_voice_id is None: return - self._editing_sample_id = None + self._editing_voice_id = None self._rebuild() def _on_rename_enter(self, _sender: Sender, _app_data: str) -> None: @@ -520,8 +520,8 @@ def _on_sample_double_clicked( clicked_item = app_data[1] user_data = dpg.get_item_user_data(clicked_item) if user_data is not None: - _, sample_id = user_data - self.call(self.on_sample_edit_requested, sample_id) + _, voice_id = user_data + self.call(self.on_sample_edit_requested, voice_id) def _on_sample_clicked( self, @@ -536,19 +536,19 @@ def _on_sample_clicked( if user_data is None: return - position, sample_id = user_data - self._show_context_menu(position, sample_id) + position, voice_id = user_data + self._show_context_menu(position, voice_id) - def _entry_for(self, sample_id: str) -> Optional[SampleEntryViewModel]: - return next((entry for entry in self._entries if entry.sample_id == sample_id), None) + def _entry_for(self, voice_id: str) -> Optional[SampleEntryViewModel]: + return next((entry for entry in self._entries if entry.voice_id == voice_id), None) - def _show_context_menu(self, position: int, sample_id: str) -> None: - entry = self._entry_for(sample_id) + def _show_context_menu(self, position: int, voice_id: str) -> None: + entry = self._entry_for(voice_id) if entry is None: return target = SampleSelection( - sample_id=sample_id, + voice_id=voice_id, position=position, name=entry.name, ) @@ -556,7 +556,7 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: header = dpg.add_text(target.label) FontRegistry.bind_to_item(header, Font.MONO_BOLD) add_detail_items( - self._footprint_items(sample_id), + self._footprint_items(voice_id), color=self._detail_color, tooltip=self._tip_size_bytes, ) @@ -565,20 +565,20 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: context_label(self._language_manager, ContextElements.PLAY), lambda: self.call( self.on_play_requested, - sample_id, + voice_id, ), ) dpg.add_separator() self.add_action_items(target) - def _footprint_items(self, sample_id: str) -> List[Tuple[str, str]]: + def _footprint_items(self, voice_id: str) -> List[Tuple[str, str]]: """The byte figures the menu prints for a sample: its total, then each channel that plays. The figures are asked for as the menu opens, so they name what the sample occupies at the moment a reader looks. A channel standing by is written by no export, so it costs nothing and the menu names the channels that do. """ - footprint = self.query(self.sample_footprint, sample_id, default=None) + footprint = self.query(self.sample_footprint, voice_id, default=None) if footprint is None: return [] @@ -624,7 +624,7 @@ def add_action_items(self, target: SampleSelection) -> None: self._language_manager, SequencerInstrumentsElements.CONTEXT_EDIT, ), - callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id), + callback=lambda: self.call(self.on_sample_edit_requested, target.voice_id), ) dpg.add_menu_item( label=self._label( @@ -632,14 +632,14 @@ def add_action_items(self, target: SampleSelection) -> None: SequencerInstrumentsElements.CONTEXT_RENAME, ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), - callback=lambda: self._start_rename(target.sample_id), + callback=lambda: self._start_rename(target.voice_id), ) dpg.add_menu_item( label=self._label( self._language_manager, SequencerInstrumentsElements.CONTEXT_DUPLICATE, ), - callback=lambda: self.call(self.on_duplicate_requested, target.sample_id), + callback=lambda: self.call(self.on_duplicate_requested, target.voice_id), ) dpg.add_separator() dpg.add_menu_item( @@ -648,7 +648,7 @@ def add_action_items(self, target: SampleSelection) -> None: SequencerInstrumentsElements.CONTEXT_REMOVE, ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), - callback=lambda: self.call(self.on_remove_requested, target.sample_id), + callback=lambda: self.call(self.on_remove_requested, target.voice_id), ) dpg.add_separator() for move in SAMPLE_MOVES: @@ -667,7 +667,7 @@ def _add_move_item( enabled=position is not None, callback=lambda: self.call( self.on_move_requested, - target.sample_id, + target.voice_id, position, ), ) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 06a1cfd72..8f9dd91e5 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1013,7 +1013,7 @@ def _update_caret(self) -> None: clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, ) - def _resolve_sample_id( + def _resolve_voice_id( self, sample_index: int, ) -> Optional[Tuple[int, str]]: @@ -1022,7 +1022,7 @@ def _resolve_sample_id( samples = self._current_samples.samples sample_index = max(0, min(sample_index, len(samples) - 1)) - return sample_index, samples[sample_index].sample_id + return sample_index, samples[sample_index].voice_id def _handle_edit_action(self, action: EditAction) -> None: """Commits a single-subcolumn edit. @@ -1038,12 +1038,12 @@ def _handle_edit_action(self, action: EditAction) -> None: self.call(self.on_set_note_off, row, channel) return - sample_id: Optional[str] = None + voice_id: Optional[str] = None if action.sample_index is not None: - resolved = self._resolve_sample_id(action.sample_index) + resolved = self._resolve_voice_id(action.sample_index) sample_index = resolved[0] if resolved is not None else None - sample_id = resolved[1] if resolved is not None else None + voice_id = resolved[1] if resolved is not None else None self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = tracker_display.format_committed( SubColumn.INSTRUMENT, sample_index, @@ -1065,7 +1065,7 @@ def _handle_edit_action(self, action: EditAction) -> None: self.on_set_row, row, channel, - sample_id, + voice_id, action.transpose, action.volume, ) @@ -1412,7 +1412,7 @@ def _add_instrument_submenu(self, cell: TrackerCursor) -> None: for index, sample in enumerate(samples): dpg.add_menu_item( label=tracker_display.indexed_label(index, sample.name), - user_data=(cell.row, cell.channel, sample.sample_id), + user_data=(cell.row, cell.channel, sample.voice_id), callback=self._on_set_instrument_menu, ) @@ -1449,8 +1449,8 @@ def _on_set_instrument_menu( _app_data: None, user_data: Tuple[int, Optional[ChannelName], str], ) -> None: - row_index, channel, sample_id = user_data - self.call(self.on_set_row, row_index, channel, sample_id, None, None) + row_index, channel, voice_id = user_data + self.call(self.on_set_row, row_index, channel, voice_id, None, None) def _on_transpose_menu( self, diff --git a/src/sampletones_application/view_model/sequencer/samples.py b/src/sampletones_application/view_model/sequencer/samples.py index fe105a2dd..2006bb0f9 100644 --- a/src/sampletones_application/view_model/sequencer/samples.py +++ b/src/sampletones_application/view_model/sequencer/samples.py @@ -2,11 +2,11 @@ from pydantic import BaseModel -from sampletones_core.utils.display import display_sample_label +from sampletones_core.utils.display import display_voice_label class SampleEntryViewModel(BaseModel, frozen=True): - sample_id: str + voice_id: str name: str loop: bool @@ -19,14 +19,14 @@ class SampleSelection(BaseModel, frozen=True): selection the same way the samples panel displays it. """ - sample_id: str + voice_id: str position: int name: str @property def label(self) -> str: """The sample's list label, matching how the samples panel and tracker name it.""" - return display_sample_label(self.position, self.name) + return display_voice_label(self.position, self.name) class SequencerSamplesViewModel(BaseModel, frozen=True): diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index 2fec290a1..2555cb432 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -32,3 +32,10 @@ CHANNEL_NAME: Final = "channel_name" GENERATOR_NAME: Final = "generator_name" + +SAMPLES: Final = "samples" +VOICES: Final = "voices" +KIND: Final = "kind" +KIND_SAMPLE: Final = "sample" +SAMPLE_ID: Final = "sample_id" +VOICE_ID: Final = "voice_id" diff --git a/src/sampletones_core/compatibility/project/__init__.py b/src/sampletones_core/compatibility/project/__init__.py index ca3fac1d0..32515b045 100644 --- a/src/sampletones_core/compatibility/project/__init__.py +++ b/src/sampletones_core/compatibility/project/__init__.py @@ -3,5 +3,6 @@ from sampletones_core.compatibility.update import VersionUpdate from .v1_1 import V1_1 +from .v1_2 import V1_2 -UPDATES: Final[Tuple[VersionUpdate, ...]] = (V1_1,) +UPDATES: Final[Tuple[VersionUpdate, ...]] = (V1_1, V1_2) diff --git a/src/sampletones_core/compatibility/project/v1_1.py b/src/sampletones_core/compatibility/project/v1_1.py index 30c063905..0a3b3771f 100644 --- a/src/sampletones_core/compatibility/project/v1_1.py +++ b/src/sampletones_core/compatibility/project/v1_1.py @@ -73,22 +73,12 @@ def _renamed_pool(channel: SerializedData) -> SerializedData: def _renamed_pattern(pattern: SerializedData) -> SerializedData: rows = pattern.get(ROWS) - if not isinstance(rows, dict): + if not isinstance(rows, list): return pattern return { **pattern, - ROWS: { - index: ( - _renamed_row(row) - if isinstance( - row, - dict, - ) - else row - ) - for index, row in rows.items() - }, + ROWS: [_renamed_row(row) if isinstance(row, dict) else row for row in rows], } diff --git a/src/sampletones_core/compatibility/project/v1_2.py b/src/sampletones_core/compatibility/project/v1_2.py new file mode 100644 index 000000000..8d4589c45 --- /dev/null +++ b/src/sampletones_core/compatibility/project/v1_2.py @@ -0,0 +1,92 @@ +from typing import Final, List + +from sampletones_core.compatibility.fields import ( + CHANNELS, + COMMAND, + KIND, + KIND_SAMPLE, + PATTERNS, + ROWS, + SAMPLE_ID, + SAMPLES, + SONG, + VOICE_ID, + VOICES, +) +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.update import VersionUpdate +from sampletones_shared.deployment.version import Version +from sampletones_shared.types.data import SerializedData + + +def update(data: SerializedData) -> SerializedData: + """Gathers a project's samples into its voices, and names each row's voice alone. + + Project format 1.1 held the pool under ``samples`` and a row's note command as a sample id + beside the channel slice it named. Project format 1.2 holds the pool under ``voices``, each + record stating the ``kind`` of voice it carries, and a note command names the voice by id: the + channel a voice sounds on is the one whose pattern holds the row. + """ + updated = dict(data) + samples = data.get(SAMPLES) + if isinstance(samples, list): + updated.pop(SAMPLES, None) + updated[VOICES] = [{KIND: KIND_SAMPLE, **sample} if isinstance(sample, dict) else sample for sample in samples] + + song = data.get(SONG) + if isinstance(song, dict): + updated[SONG] = _updated_song(song) + + return updated + + +def _updated_song(song: SerializedData) -> SerializedData: + channels = song.get(CHANNELS) + if not isinstance(channels, dict): + return song + + return { + **song, + CHANNELS: { + name: _updated_pool(channel) if isinstance(channel, dict) else channel for name, channel in channels.items() + }, + } + + +def _updated_pool(channel: SerializedData) -> SerializedData: + patterns = channel.get(PATTERNS) + if not isinstance(patterns, dict): + return channel + + return { + **channel, + PATTERNS: { + index: _updated_pattern(pattern) if isinstance(pattern, dict) else pattern + for index, pattern in patterns.items() + }, + } + + +def _updated_pattern(pattern: SerializedData) -> SerializedData: + rows = pattern.get(ROWS) + if not isinstance(rows, list): + return pattern + + updated_rows: List[SerializedData] = [_updated_row(row) if isinstance(row, dict) else row for row in rows] + return {**pattern, ROWS: updated_rows} + + +def _updated_row(row: SerializedData) -> SerializedData: + command = row.get(COMMAND) + if not isinstance(command, dict) or SAMPLE_ID not in command: + return row + + return {**row, COMMAND: {VOICE_ID: command[SAMPLE_ID]}} + + +V1_2: Final[VersionUpdate] = VersionUpdate( + kind=ObjectKind.PROJECT, + base=Version.model_validate("1.1"), + target=Version.model_validate("1.2"), + apply=update, +) diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py index 230569e1c..aa3b5dcf9 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -27,8 +27,8 @@ "B-", ) -MIN_TRANSPOSE: Final[int] = -24 -MAX_TRANSPOSE: Final[int] = 36 +MAX_TRANSPOSE: Final[int] = PITCH_RANGE +MIN_TRANSPOSE: Final[int] = -PITCH_RANGE ARPEGGIO_MIN: Final[int] = -128 ARPEGGIO_MAX: Final[int] = 127 diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index da4765603..00f9797ae 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -6,8 +6,8 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project +from sampletones_core.project.voices.sample import Sample @dataclass(frozen=True) @@ -71,7 +71,7 @@ def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: SampleSlice: Each slice alongside the index it takes in the instrument table. """ index = 0 - for sample in project.samples: + for sample in project.voices: features_by_channel = sample.reconstruction.export() for channel in ChannelName.items(): features = features_by_channel[channel] diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index fb66f2093..59080d470 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -62,10 +62,10 @@ NoteName, ) from sampletones_core.formats.bitphase.tuning import generate_tuning_table -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED @@ -336,7 +336,7 @@ def _build_voice_table( envelopes = features_to_envelopes( sample_slice.features, sample_slice.channel, - loop=sample_slice.sample.loop, + loop=sample_slice.sample.loops, ) voice = _build_voice( sample_slice.index, @@ -352,12 +352,10 @@ def _build_voice_table( return voices, by_reference -def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice: - voice = voices.get((reference.sample_id, reference.channel_name)) +def _resolve_voice(reference: NoteOn, channel: ChannelName, voices: VoiceTable) -> Voice: + voice = voices.get((reference.voice_id, channel)) if voice is None: - raise ValueError( - f"Row references sample '{reference.sample_id}' slice " f"'{reference.channel_name}' that has no instrument" - ) + raise ValueError(f"Row references voice '{reference.voice_id}' on channel '{channel}' with no instrument") return voice @@ -387,7 +385,7 @@ def _row_cell( """Converts one tracker line to the Bitphase row that plays it. Raises: - ValueError: If the line references a sample slice that has no instrument. + ValueError: If the line references a voice that has no instrument on this channel. """ volume = _volume_column(row.volume) cell = BitphaseRow(volume=volume) @@ -398,8 +396,8 @@ def _row_cell( note=NoteCell(name=int(NoteName.OFF)), volume=volume, ) - case Instrument() as reference: - voice = _resolve_voice(reference, voices) + case NoteOn() as reference: + voice = _resolve_voice(reference, channel_generator, voices) pitch = voice.initial_pitch + (row.transpose or 0) cell = _trigger_row( voice, diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 3cd2b76e6..d8d5d196e 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -52,12 +52,12 @@ MIN_OCTAVE, NoteValue, ) -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from sampletones_shared.application import SAMPLETONES_COPYRIGHT @@ -117,7 +117,7 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst sample_slice.index, sample_slice.instrument_name, sample_slice.features, - loop=sample_slice.sample.loop, + loop=sample_slice.sample.loops, ) ) slots[sample_slice.key] = sample_slice.slot @@ -153,12 +153,12 @@ def _row_cell( match row.command: case NoteOff(): note = int(NoteValue.HALT) - case Instrument() as reference: - slot = slots.get((reference.sample_id, reference.channel_name)) + case NoteOn() as reference: + slot = slots.get((reference.voice_id, channel_generator)) if slot is None: raise ValueError( - f"Row references sample '{reference.sample_id}' slice " - f"'{reference.channel_name}' that has no instrument" + f"Row references voice '{reference.voice_id}' on channel " + f"'{channel_generator}' with no instrument" ) instrument = slot.index note, octave = _note_and_octave( diff --git a/src/sampletones_core/performance/rows.py b/src/sampletones_core/performance/rows.py index a35e56553..b6956aa7c 100644 --- a/src/sampletones_core/performance/rows.py +++ b/src/sampletones_core/performance/rows.py @@ -3,11 +3,11 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.performance.state import ChannelPerformance -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn def resolve_row( @@ -46,10 +46,10 @@ def resolve_row( def apply_row(performance: ChannelPerformance, row: Row) -> bool: """Moves a channel onto the row it has reached, and reports whether the note starts over. - A note column names the sample to sound and begins it, taking the transpose and volume the - row states or the defaults where it states neither. A row naming no note leaves the sample - playing and changes only the columns it fills in, which is how a transpose or a volume bends - a note already sounding. + A note column names the voice to sound and begins it, taking the transpose and volume the row + states or the defaults where it states neither. A row naming no note leaves the voice playing + and changes only the columns it fills in, which is how a transpose or a volume bends a note + already sounding. Args: performance: What the channel carries; updated in place. @@ -59,14 +59,14 @@ def apply_row(performance: ChannelPerformance, row: Row) -> bool: bool: Whether the channel starts over, which is where a phase-continuous voice resets. """ match row.command: - case Instrument() as instrument: - performance.sample_id = instrument.sample_id + case NoteOn() as note_on: + performance.voice_id = note_on.voice_id performance.tick_index = 0 performance.transpose = row.transpose if row.transpose is not None else 0 performance.volume = row.volume if row.volume is not None else MAX_VOLUME return True case NoteOff(): - performance.sample_id = None + performance.voice_id = None performance.tick_index = 0 return True case None: diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index b5327752a..43456f0ce 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -12,9 +12,9 @@ from sampletones_core.performance.state import ChannelPerformance from sampletones_core.performance.ticks import sound_tick from sampletones_core.performance.voice import SampleVoice -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.song_position import SongPosition +from sampletones_core.project.voices.sample import Sample from sampletones_core.timing.song import SongTiming @@ -60,7 +60,7 @@ def song_instructions( streams[channel_name].extend( _channel_ticks( - project.sample(performance.sample_id) if performance.sample_id is not None else None, + project.voice(performance.voice_id) if performance.voice_id is not None else None, channel_name, performance, ticks, @@ -83,7 +83,7 @@ def _channel_ticks( """One channel's instructions across a single row. A channel with nothing to sound rests for the whole row and keeps the tick it had reached, - so a sample removed from the project leaves the rows that named it silent while the rows + so a voice removed from the project leaves the rows that named it silent while the rows around them play on. Args: @@ -109,7 +109,7 @@ def _channel_ticks( instruction = sound_tick( performance, instructions, - loop=sample.loop, + loop=sample.loops, voice=voice, ) sounded.append(resting if instruction is None else instruction) diff --git a/src/sampletones_core/performance/state.py b/src/sampletones_core/performance/state.py index ae7374858..a7930074c 100644 --- a/src/sampletones_core/performance/state.py +++ b/src/sampletones_core/performance/state.py @@ -10,9 +10,9 @@ class ChannelPerformance: """What one channel carries from row to row while a song plays. - A pattern states a channel's instrument, transpose, and volume only where it changes them, so - the channel keeps the last of each until another row states otherwise. The tick index is how - far into the sounding sample's instructions the channel has played, which is what lets a note + A pattern states a channel's voice, transpose, and volume only where it changes them, so the + channel keeps the last of each until another row states otherwise. The tick index is how far + into the sounding voice's instructions the channel has played, which is what lets a note sustain across rows. The channel carries a value per envelope dimension too, which is what an instrument leaving a @@ -20,14 +20,14 @@ class ChannelPerformance: channel keeps the last one written for as long as the song runs. Attributes: - sample_id: The sample the channel is sounding, or ``None`` while it is silent. - tick_index: How many ticks of that sample's instructions the channel has played. + voice_id: The voice the channel is sounding, or ``None`` while it is silent. + tick_index: How many ticks of that voice's instructions the channel has played. transpose: The semitone offset a row last set. volume: The level a row last set. feature_values: The value the channel holds for each envelope dimension. """ - sample_id: Optional[str] = field(default=None) + voice_id: Optional[str] = field(default=None) tick_index: int = field(default=0) transpose: int = field(default=0) volume: int = field(default=MAX_VOLUME) @@ -39,7 +39,7 @@ def reset(self) -> None: The envelope dimensions return to the values a channel holds from the start of a song, so a pass through the song sounds the same however the previous one left them. """ - self.sample_id = None + self.voice_id = None self.tick_index = 0 self.transpose = 0 self.volume = MAX_VOLUME diff --git a/src/sampletones_core/project/__init__.py b/src/sampletones_core/project/__init__.py index 5cf443901..9def964de 100644 --- a/src/sampletones_core/project/__init__.py +++ b/src/sampletones_core/project/__init__.py @@ -1,16 +1,16 @@ from .container import ProjectContainer from .info import ProjectInfo -from .instruments.instrument import Instrument from .patterns.channel import Channel from .patterns.pattern import Pattern from .patterns.row import Row from .project import Project from .settings import ProjectSettings from .song import Song +from .voices.note_on import NoteOn __all__ = [ "Channel", - "Instrument", + "NoteOn", "Pattern", "Project", "ProjectContainer", diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index 9fa73c0b7..2195e5eb8 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -7,9 +7,9 @@ from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.compatibility.upgrade import upgrade_json from sampletones_core.project.document import ProjectDocument -from sampletones_core.project.instruments.record import SampleRecord -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project +from sampletones_core.project.voices.record import SampleRecord +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION @@ -39,9 +39,9 @@ class ProjectContainer: The archive (``.stp``) is a zip holding a single ``project.json`` -- the validated :class:`ProjectDocument` -- plus one ``reconstructions/.stn`` per - unique reconstruction in its existing binary format. Samples embed - reconstructions in memory but reference them by ``reconstruction_id`` on disk, - so a reconstruction shared by several samples is stored exactly once. + unique reconstruction in its existing binary format. A sample embeds its + reconstruction in memory but references it by ``reconstruction_id`` on disk, + so a reconstruction shared by several voices is stored exactly once. The entire JSON shape lives in :class:`ProjectDocument`; this class only maps the domain to and from it and manages the reconstruction archive. It is a @@ -115,13 +115,14 @@ def _build_document(project: Project) -> ProjectDocument: metadata=project.metadata, info=project.info, settings=project.settings, - samples=[ + voices=[ SampleRecord( - id=sample.id, - name=sample.name, - reconstruction_id=sample.reconstruction.id, + id=voice.id, + name=voice.name, + reconstruction_id=voice.reconstruction.id, + loop_point=voice.loop_point, ) - for sample in project.samples + for voice in project.voices ], song=project.song, ) @@ -131,30 +132,34 @@ def _build_project( document: ProjectDocument, reconstructions: Dict[str, Reconstruction], ) -> Project: - samples: IdentifiedCollection[Sample] = IdentifiedCollection() - for record in document.samples: + voices: IdentifiedCollection[Sample] = IdentifiedCollection() + for record in document.voices: reconstruction = reconstructions[record.reconstruction_id] - samples.append(ProjectContainer._restore_sample(record, reconstruction)) + voices.append(ProjectContainer._restore_sample(record, reconstruction)) return Project( metadata=document.metadata, info=document.info, settings=document.settings, - samples=samples, + voices=voices, song=document.song, ) @staticmethod def _restore_sample(record: SampleRecord, reconstruction: Reconstruction) -> Sample: - sample = Sample(name=record.name, reconstruction=reconstruction) + sample = Sample( + name=record.name, + reconstruction=reconstruction, + loop_point=record.loop_point, + ) sample.id = record.id return sample @staticmethod def _unique_reconstructions(project: Project) -> Dict[str, Reconstruction]: reconstructions: Dict[str, Reconstruction] = {} - for sample in project.samples: - reconstructions[sample.reconstruction.id] = sample.reconstruction + for voice in project.voices: + reconstructions[voice.reconstruction.id] = voice.reconstruction return reconstructions diff --git a/src/sampletones_core/project/document.py b/src/sampletones_core/project/document.py index 63dfcc0b3..31d9a9525 100644 --- a/src/sampletones_core/project/document.py +++ b/src/sampletones_core/project/document.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field from sampletones_core.data import Metadata -from sampletones_core.project.instruments.record import SampleRecord +from sampletones_core.project.voices.record import SampleRecord from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from .info import ProjectInfo @@ -14,8 +14,8 @@ class ProjectDocument(BaseModel): """The single, validated schema for a project's ``project.json``. - It embeds the domain :class:`Song` and represents samples as lightweight records, - since their reconstructions live as separate ``.stn`` members of the archive. + It embeds the domain :class:`Song` and represents each voice as a lightweight record, + since a sample's reconstruction lives as a separate ``.stn`` member of the archive. ``extra="ignore"`` lets it accept older or unknown fields, and ``format_version`` carries the schema version that drives upgrades. """ @@ -30,5 +30,5 @@ class ProjectDocument(BaseModel): metadata: Metadata info: ProjectInfo settings: ProjectSettings - samples: List[SampleRecord] + voices: List[SampleRecord] song: Song diff --git a/src/sampletones_core/project/instruments/__init__.py b/src/sampletones_core/project/instruments/__init__.py deleted file mode 100644 index 576c12dee..000000000 --- a/src/sampletones_core/project/instruments/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .instrument import Instrument -from .record import SampleRecord -from .sample import Sample - -__all__ = [ - "Instrument", - "Sample", - "SampleRecord", -] diff --git a/src/sampletones_core/project/instruments/instrument.py b/src/sampletones_core/project/instruments/instrument.py deleted file mode 100644 index 64fdda203..000000000 --- a/src/sampletones_core/project/instruments/instrument.py +++ /dev/null @@ -1,21 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field - -from sampletones_core.constants.enums import ChannelName - - -class Instrument(BaseModel): - """A reference to a single NES-channel slice of a sample's reconstruction. - - A reconstruction may span up to four channels (two pulse, triangle, noise). - A subinstrument pins one of those channels for use on a tracker row. The - sample is referenced by its stable ``id``, so the reference survives - reordering of the samples collection. - """ - - model_config = ConfigDict(frozen=True) - - sample_id: str = Field(..., description="Stable id of the referenced sample.") - channel_name: ChannelName = Field( - ..., - description="Which reconstruction channel-slice to use.", - ) diff --git a/src/sampletones_core/project/patterns/row.py b/src/sampletones_core/project/patterns/row.py index 54af3b1ee..96aae1158 100644 --- a/src/sampletones_core/project/patterns/row.py +++ b/src/sampletones_core/project/patterns/row.py @@ -8,16 +8,16 @@ MIN_TRANSPOSE, SILENT_VOLUME, ) -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn -NoteCommand = Union[Instrument, NoteOff] +NoteCommand = Union[NoteOn, NoteOff] class Row(BaseModel): """A single tracker line on one channel. - The note column holds a :data:`NoteCommand`: an :class:`Instrument` reference, a + The note column holds a :data:`NoteCommand`: a :class:`NoteOn` naming the voice to start, a :class:`NoteOff`, or ``None`` for an empty cell. Transpose and volume are independent optional columns. A fully empty row (no command, no transpose, no volume) is a blank line. """ @@ -26,13 +26,13 @@ class Row(BaseModel): command: Optional[NoteCommand] = Field( default=None, - description="Note-column command: a sample reference, a note-off, or None for an empty cell.", + description="Note-column command: a voice reference, a note-off, or None for an empty cell.", ) transpose: Optional[int] = Field( default=None, ge=MIN_TRANSPOSE, le=MAX_TRANSPOSE, - description="Note pitch, or None for an empty cell.", + description="Semitones from the voice's reference pitch, or None for an empty cell.", ) volume: Optional[int] = Field( default=None, @@ -44,6 +44,6 @@ class Row(BaseModel): def is_empty(self) -> bool: return self.command is None and self.transpose is None and self.volume is None - def references_sample(self, sample_id: str) -> bool: + def references_voice(self, voice_id: str) -> bool: command = self.command - return isinstance(command, Instrument) and command.sample_id == sample_id + return isinstance(command, NoteOn) and command.voice_id == voice_id diff --git a/src/sampletones_core/project/project.py b/src/sampletones_core/project/project.py index 531b8a00a..1434c05c7 100644 --- a/src/sampletones_core/project/project.py +++ b/src/sampletones_core/project/project.py @@ -3,7 +3,7 @@ from typing import Optional from sampletones_core.data import Metadata -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_core.structures import IdentifiedCollection from sampletones_shared.constants.project import ( DEFAULT_PROJECT_AUTHOR, @@ -20,8 +20,8 @@ class Project: """The top-level container for everything a user composes. - Owns the samples (each embedding its own reconstruction) and the song - arrangement. References inside the song point at samples by their stable + Owns the voices (each a sample embedding its own reconstruction) and the song + arrangement. References inside the song point at voices by their stable ``id``; the :class:`IdentifiedCollection` resolves those ids in O(1) while also exposing reorder-safe positions for the UI. """ @@ -31,13 +31,13 @@ def __init__( metadata: Metadata, info: ProjectInfo, settings: ProjectSettings, - samples: IdentifiedCollection[Sample], + voices: IdentifiedCollection[Sample], song: Song, ) -> None: self.metadata: Metadata = metadata self.info: ProjectInfo = info self.settings: ProjectSettings = settings - self.samples: IdentifiedCollection[Sample] = samples + self.voices: IdentifiedCollection[Sample] = voices self.song: Song = song @classmethod @@ -62,12 +62,12 @@ def create( metadata=Metadata.default(), info=info, settings=settings, - samples=IdentifiedCollection(), + voices=IdentifiedCollection(), song=Song.empty(rows_per_pattern), ) - def sample(self, sample_id: str) -> Optional[Sample]: - return self.samples.get(sample_id) + def voice(self, voice_id: str) -> Optional[Sample]: + return self.voices.get(voice_id) def __repr__(self) -> str: - return f"Project(title={self.info.title!r}, samples={len(self.samples)})" + return f"Project(title={self.info.title!r}, voices={len(self.voices)})" diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py index d3e3862f2..459a820fa 100644 --- a/src/sampletones_core/project/song.py +++ b/src/sampletones_core/project/song.py @@ -146,21 +146,21 @@ def remove_pattern(self, channel: ChannelName, index: int) -> None: if frame.get(channel) == index: frame[channel] = None - def references_sample(self, sample_id: str) -> bool: - """Whether any row in any pattern of any channel still points at the sample.""" + def references_voice(self, voice_id: str) -> bool: + """Whether any row in any pattern of any channel still points at the voice.""" return any( - row.references_sample(sample_id) + row.references_voice(voice_id) for channel in self.channels.values() for pattern in channel.patterns.values() for row in pattern.rows ) - def clear_sample_references(self, sample_id: str) -> None: - """Clears the note-column command of every row that points at a removed sample.""" + def clear_voice_references(self, voice_id: str) -> None: + """Clears the note-column command of every row that points at a removed voice.""" for channel in self.channels.values(): for pattern in channel.patterns.values(): pattern.rows = [ - row.model_copy(update={"command": None}) if row.references_sample(sample_id) else row + row.model_copy(update={"command": None}) if row.references_voice(voice_id) else row for row in pattern.rows ] diff --git a/src/sampletones_core/project/tuning.py b/src/sampletones_core/project/tuning.py index 7cefd5ec5..fb0bd0464 100644 --- a/src/sampletones_core/project/tuning.py +++ b/src/sampletones_core/project/tuning.py @@ -19,7 +19,7 @@ def tuning_from_project(project: Project) -> Tuning: a project holding none takes the tuning a reconstruction is built against by default. Args: - project: The project whose samples state the tuning. + project: The project whose voices state the tuning. Returns: Tuning: The tuning every sample of the project was reconstructed against. @@ -28,7 +28,7 @@ def tuning_from_project(project: Project) -> Tuning: ValueError: If the samples were reconstructed against tunings that differ, which one timer table sounds only one of. """ - tunings: Set[Tuning] = {sample.reconstruction.config.tuning for sample in project.samples} + tunings: Set[Tuning] = {voice.reconstruction.config.tuning for voice in project.voices} if not tunings: return UNTUNED_PROJECT diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py new file mode 100644 index 000000000..bce31c41c --- /dev/null +++ b/src/sampletones_core/project/voices/__init__.py @@ -0,0 +1,13 @@ +from .loop import WHOLE_LOOP_POINT +from .note_off import NoteOff +from .note_on import NoteOn +from .record import SampleRecord +from .sample import Sample + +__all__ = [ + "WHOLE_LOOP_POINT", + "NoteOff", + "NoteOn", + "Sample", + "SampleRecord", +] diff --git a/src/sampletones_core/project/voices/loop.py b/src/sampletones_core/project/voices/loop.py new file mode 100644 index 000000000..4fdc83205 --- /dev/null +++ b/src/sampletones_core/project/voices/loop.py @@ -0,0 +1,3 @@ +from typing import Final + +WHOLE_LOOP_POINT: Final[int] = 0 diff --git a/src/sampletones_core/project/instruments/note_off.py b/src/sampletones_core/project/voices/note_off.py similarity index 100% rename from src/sampletones_core/project/instruments/note_off.py rename to src/sampletones_core/project/voices/note_off.py diff --git a/src/sampletones_core/project/voices/note_on.py b/src/sampletones_core/project/voices/note_on.py new file mode 100644 index 000000000..0608ffaa5 --- /dev/null +++ b/src/sampletones_core/project/voices/note_on.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class NoteOn(BaseModel): + """A note-on command in a tracker row's note column: start the named voice on this channel. + + The voice is referenced by its stable ``id``, so the reference survives reordering of the + project's voice collection. The channel a voice sounds on is the one whose pattern holds the + row, which is what lets one voice be started on any channel it suits. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + voice_id: str = Field(..., description="Stable id of the voice to start.") diff --git a/src/sampletones_core/project/instruments/record.py b/src/sampletones_core/project/voices/record.py similarity index 57% rename from src/sampletones_core/project/instruments/record.py rename to src/sampletones_core/project/voices/record.py index 03f5722b1..dc5c7bf21 100644 --- a/src/sampletones_core/project/instruments/record.py +++ b/src/sampletones_core/project/voices/record.py @@ -1,3 +1,5 @@ +from typing import Literal, Optional + from pydantic import BaseModel, Field @@ -5,9 +7,15 @@ class SampleRecord(BaseModel): """The on-disk form of a sample: its identity plus a reference to the reconstruction stored separately in the archive.""" + kind: Literal["sample"] = Field(default="sample", description="Which kind of voice this record carries.") id: str = Field(..., description="Stable sample id.") name: str = Field(..., description="Sample name.") reconstruction_id: str = Field( ..., description="Id of the reconstruction stored in the archive.", ) + loop_point: Optional[int] = Field( + default=None, + ge=0, + description="Tick the sample's instructions repeat from, or None where it plays once.", + ) diff --git a/src/sampletones_core/project/instruments/sample.py b/src/sampletones_core/project/voices/sample.py similarity index 56% rename from src/sampletones_core/project/instruments/sample.py rename to src/sampletones_core/project/voices/sample.py index 672abb09b..b7749b701 100644 --- a/src/sampletones_core/project/instruments/sample.py +++ b/src/sampletones_core/project/voices/sample.py @@ -1,32 +1,44 @@ -from typing import Self +from typing import Optional, Self from uuid import uuid4 from sampletones_core.reconstructions import Reconstruction class Sample: + """A reconstruction placed in a project as a playable voice. + + The reconstruction carries one instruction stream per channel; the sample adds what a song + needs of it — a name, a stable id the tracker rows reference, and the tick its instructions + repeat from while a note is held. + """ + def __init__( self, name: str, reconstruction: Reconstruction, *, - loop: bool = False, + loop_point: Optional[int] = None, ) -> None: self.id: str = uuid4().hex self.name: str = name self.reconstruction: Reconstruction = reconstruction - self.loop: bool = loop + self.loop_point: Optional[int] = loop_point + + @property + def loops(self) -> bool: + """Whether the sample repeats its instructions rather than playing them once.""" + return self.loop_point is not None def clone(self) -> Self: """Return an independent copy with a fresh id. The reconstruction is deep-copied so the copy can be edited independently of - the original; the name and loop flag are carried over. + the original; the name and loop point are carried over. """ return type(self)( name=self.name, reconstruction=self.reconstruction.model_copy(deep=True), - loop=self.loop, + loop_point=self.loop_point, ) def __hash__(self) -> int: diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index fe330b6ee..ed1b0672b 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -1,8 +1,8 @@ from typing import Final, Optional, Union -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.structures import IdentifiedCollection from sampletones_shared.constants.symbols import MINUS, PLUS @@ -32,37 +32,37 @@ def display_id(value: Optional[int]) -> str: return display_value(value, hexadecimal=True) -def display_sample( +def display_voice( *, - samples: IdentifiedCollection[Sample], - sample_id: Optional[str] = None, + voices: IdentifiedCollection[Sample], + voice_id: Optional[str] = None, ) -> str: """ - Render a sample reference as its current list position (not its uuid). + Render a voice reference as its current list position (not its uuid). """ - if sample_id is not None and samples.get(sample_id) is not None: - return display_id(samples.get_index(sample_id)) + if voice_id is not None and voices.get(voice_id) is not None: + return display_id(voices.get_index(voice_id)) return display_id(None) -def display_sample_label(position: int, name: str) -> str: - """Render an instrument's list label as ``": "`` (e.g. ``"1A: Bass"``).""" +def display_voice_label(position: int, name: str) -> str: + """Render a voice's list label as ``": "`` (e.g. ``"1A: Bass"``).""" return f"{display_id(position)}: {name}" def display_command( - samples: IdentifiedCollection[Sample], - command: Optional[Union[Instrument, NoteOff]], + voices: IdentifiedCollection[Sample], + command: Optional[Union[NoteOn, NoteOff]], ) -> str: - """Render a row's note-column command: a sample's list position, ``--`` for note-off, or ``..``.""" + """Render a row's note-column command: a voice's list position, ``--`` for note-off, or ``..``.""" match command: case NoteOff(): return NOTE_OFF - case Instrument(): - return display_sample(samples=samples, sample_id=command.sample_id) + case NoteOn(): + return display_voice(voices=voices, voice_id=command.voice_id) case None: - return display_sample(samples=samples, sample_id=None) + return display_voice(voices=voices, voice_id=None) def display_volume(value: Optional[int]) -> str: diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index e6eb24b80..2b1c72d75 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -8,7 +8,7 @@ SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.0" SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2" -SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1" +SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.2" SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" SAMPLETONES_AUTHOR: Final[str] = "Jakim" diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 21b81e83c..7a4b0d328 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -17,7 +17,8 @@ InstructionLibraryData, InstructionLibraryFragment, ) -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry @@ -154,7 +155,11 @@ def make_sample( if played != expected_slices: raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") - return Sample(name=name, reconstruction=reconstruction, loop=loop) + return Sample( + name=name, + reconstruction=reconstruction, + loop_point=WHOLE_LOOP_POINT if loop else None, + ) def load_instrument_catalog( diff --git a/tests/integration/assets/song_loader.py b/tests/integration/assets/song_loader.py index 7d2822d12..e8f48294c 100644 --- a/tests/integration/assets/song_loader.py +++ b/tests/integration/assets/song_loader.py @@ -1,13 +1,13 @@ from typing import Any, Dict, List, Mapping, Optional from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_yaml @@ -39,7 +39,7 @@ def _row(spec: RowSpec, channel: ChannelName, samples_by_name: Mapping[str, Samp if channel not in sample.reconstruction.instructions: raise ValueError(f"Sample '{sample_name}' has no '{channel.value}' slice for the {channel.value} channel") - command = Instrument(sample_id=sample.id, channel_name=channel) + command = NoteOn(voice_id=sample.id) return Row(command=command, transpose=transpose, volume=volume) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index 6882361a6..12d11cbec 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -81,7 +81,7 @@ def at_tempo(project: Project, tempo: int) -> Project: metadata=project.metadata, info=project.info, settings=project.settings.model_copy(update={"tempo": tempo}), - samples=project.samples, + voices=project.voices, song=project.song, ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 43170357f..8d6b920c8 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -3,9 +3,9 @@ import pytest -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.sample import Sample from sampletones_core.structures import IdentifiedCollection from tests.integration.assets.module_config import ModuleConfig, load_module_config from tests.integration.assets.reconstruction import load_instrument_catalog @@ -41,9 +41,9 @@ def instrument_catalog(audio_directory: Path, synth_config: SynthConfig) -> Dict @pytest.fixture(scope="session") def integration_project(instrument_catalog: Dict[str, Sample], module_config: ModuleConfig) -> Project: - samples: IdentifiedCollection[Sample] = IdentifiedCollection() + voices: IdentifiedCollection[Sample] = IdentifiedCollection() for sample in instrument_catalog.values(): - samples.append(sample) + voices.append(sample) settings = ProjectSettings( tempo=module_config.tempo, @@ -51,6 +51,6 @@ def integration_project(instrument_catalog: Dict[str, Sample], module_config: Mo nes_frequency=module_config.nes_frequency, ) project = Project.create(title=module_config.title, author=module_config.author, settings=settings) - project.samples = samples + project.voices = voices project.song = load_song(SONG_PATH, instrument_catalog) return project diff --git a/tests/integration/nsf/conftest.py b/tests/integration/nsf/conftest.py index f8edf209d..1a2567f14 100644 --- a/tests/integration/nsf/conftest.py +++ b/tests/integration/nsf/conftest.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_player.builder import song_from_reconstruction from sampletones_player.driver.image import DriverImage from sampletones_player.song import Song diff --git a/tests/integration/nsf/corpus.py b/tests/integration/nsf/corpus.py index 9ed998151..767aacaa9 100644 --- a/tests/integration/nsf/corpus.py +++ b/tests/integration/nsf/corpus.py @@ -11,10 +11,10 @@ PulseInstruction, TriangleInstruction, ) -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.tuning import tuning_from_project +from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table from sampletones_core.timing import SongTiming from sampletones_player.builder import ( @@ -76,7 +76,7 @@ def _sample_project( settings: ProjectSettings, ) -> Project: project = Project.create(settings=settings) - project.samples.append(sample) + project.voices.append(sample) return project diff --git a/tests/integration/nsf/songs.py b/tests/integration/nsf/songs.py index fb1a7c121..db464f778 100644 --- a/tests/integration/nsf/songs.py +++ b/tests/integration/nsf/songs.py @@ -22,8 +22,8 @@ def lengthened(project: Project, frames: int) -> Project: rows_per_pattern=project.song.rows_per_pattern, settings=project.settings, ) - for sample in project.samples: - longer.samples.append(sample) + for sample in project.voices: + longer.voices.append(sample) longer.song = project.song.model_copy(deep=True) while longer.song.order_length() < frames: diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 8e87c7f49..6f1a4dbc4 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -15,9 +15,9 @@ from sampletones_core.generators.render import render_channels from sampletones_core.instructions import InstructionUnion from sampletones_core.performance import song_instructions -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.tuning import tuning_from_project +from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table from sampletones_player.builder import ( SONG_START, @@ -71,7 +71,7 @@ def sample_request(sample: Sample) -> SampleExport: name=instrument_slice_name(sample.name, channel), channel=channel, features=features, - loop=sample.loop, + loop=sample.loops, nes_frequency=config.nes_frequency, tuning=config.tuning, ) diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py index 5ed273b42..085b778c6 100644 --- a/tests/integration/nsf/test_compression_report.py +++ b/tests/integration/nsf/test_compression_report.py @@ -6,8 +6,8 @@ import pytest -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project +from sampletones_core.project.voices.sample import Sample from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.decode import decode_planes from sampletones_player.compression.dictionary.table import phrase_table diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py index 13c992ae2..efb8bd062 100644 --- a/tests/integration/nsf/test_driver_audio.py +++ b/tests/integration/nsf/test_driver_audio.py @@ -7,7 +7,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.generators.render import render_channels from sampletones_core.instructions import InstructionUnion -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table from sampletones_player.builder import song_from_reconstruction from tests.integration.nsf.console.instructions import instructions_from_trace diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py index c66eeaa5c..992fee796 100644 --- a/tests/integration/nsf/test_driver_trace.py +++ b/tests/integration/nsf/test_driver_trace.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_player.builder import song_from_reconstruction from sampletones_player.driver.image import DriverImage from sampletones_player.nsf.song import song_to_bytes diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py index 09dbeb669..fda31e4cf 100644 --- a/tests/integration/nsf/test_nsf_pipeline.py +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -5,7 +5,7 @@ import pytest from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_player.builder import song_from_reconstruction from sampletones_player.driver.image import DriverImage diff --git a/tests/suite/performance.py b/tests/suite/performance.py index 6e1927981..e6de088e6 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -11,11 +11,12 @@ PulseInstruction, TriangleInstruction, ) -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from tests.suite.stems import single_entry_stems_data @@ -118,8 +119,12 @@ def project_with_sample( reaching back into the collection for an id it already knows. """ project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings) - sample = Sample(name=name, reconstruction=reconstruction, loop=loop) - project.samples.append(sample) + sample = Sample( + name=name, + reconstruction=reconstruction, + loop_point=WHOLE_LOOP_POINT if loop else None, + ) + project.voices.append(sample) return project, sample @@ -139,7 +144,7 @@ def place_instrument( project.song.rows_per_pattern, ) pattern.rows[row_index] = Row( - command=Instrument(sample_id=sample.id, channel_name=channel_name), + command=NoteOn(voice_id=sample.id), transpose=transpose, volume=volume, ) diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 7b7a2cc12..56faefd9e 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -25,9 +25,9 @@ PulseInstruction, TriangleInstruction, ) -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import NoteCommand +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import ( BLANK, @@ -170,7 +170,7 @@ def parse_block( rows: Sequence[str], *, first_subcolumn: SubColumn, - sample_ids: Sequence[str], + voice_ids: Sequence[str], ) -> TrackerBlock: """Reads a block written the way the grid draws it, one line per row. @@ -179,7 +179,7 @@ def parse_block( the block begins on. A ``?`` states that the block says nothing about that cell, which is what leaves it out of the maps entirely. - A note names its sample by the position the grid prints, resolved through ``sample_ids``; + A note names its sample by the position the grid prints, resolved through ``voice_ids``; ``!!`` names a sample no project holds. Raises: @@ -203,7 +203,7 @@ def parse_block( match SUBCOLUMNS[slot_offset % len(SUBCOLUMNS)]: case SubColumn.INSTRUMENT: - notes[key] = parse_note(token, sample_ids) + notes[key] = parse_note(token, voice_ids) case SubColumn.TRANSPOSE: transposes[key] = parse_transpose(token) case SubColumn.VOLUME: @@ -220,7 +220,7 @@ def fill_frame( tracker_logic: SequencerTrackerLogic, rows: Sequence[str], *, - sample_ids: Sequence[str], + voice_ids: Sequence[str], ) -> None: """Writes a frame stated the way the grid draws it, one channel cell at a time. @@ -235,13 +235,13 @@ def fill_frame( row_index, channel, cell.split(), - sample_ids, + voice_ids, ) def parse_note( token: str, - sample_ids: Sequence[str], + voice_ids: Sequence[str], ) -> Optional[BlockNote]: """The note a token names: a sample by the position it prints, a cut, or emptiness.""" if token == display_id(None): @@ -253,7 +253,7 @@ def parse_note( if token == UNKNOWN_SAMPLE: return UNKNOWN_SAMPLE_ID - return sample_ids[int(token, HEXADECIMAL_BASE)] + return voice_ids[int(token, HEXADECIMAL_BASE)] def parse_transpose(token: str) -> Optional[int]: @@ -304,14 +304,14 @@ def _fill_cell( row_index: int, channel: ChannelName, tokens: Sequence[str], - sample_ids: Sequence[str], + voice_ids: Sequence[str], ) -> None: """Writes the values one channel cell states, passing over a cell that states none. A cell is written whole where it carries anything, so the row it lands on materialises exactly once however many of its subcolumns hold a value. """ - note = parse_note(tokens[0], sample_ids) + note = parse_note(tokens[0], voice_ids) transpose = parse_transpose(tokens[1]) volume = parse_volume(tokens[2]) if note is None and transpose is None and volume is None: @@ -334,8 +334,8 @@ def _command( match note: case NoteOff(): return note - case str() as sample_id: - return Instrument(sample_id=sample_id, channel_name=channel) + case str() as voice_id: + return NoteOn(voice_id=voice_id) case None: return None diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d34099693..ae075a188 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -118,11 +118,11 @@ def test_unused_sample_is_removed_without_confirmation( self, samples_coordinator: SequencerTabCoordinator, ) -> None: - samples_coordinator._sequencer_samples_logic.is_sample_used.return_value = False + samples_coordinator._sequencer_samples_logic.is_voice_used.return_value = False - samples_coordinator._remove_sample("abc") + samples_coordinator._remove_voice("abc") - samples_coordinator._sequencer_samples_logic.remove_sample.assert_called_once_with("abc") + samples_coordinator._sequencer_samples_logic.remove_voice.assert_called_once_with("abc") samples_coordinator._dialogs.show_confirmation.assert_not_called() def test_used_sample_prompts_confirmation_before_removing( @@ -130,19 +130,19 @@ def test_used_sample_prompts_confirmation_before_removing( samples_coordinator: SequencerTabCoordinator, ) -> None: logic = samples_coordinator._sequencer_samples_logic - logic.is_sample_used.return_value = True + logic.is_voice_used.return_value = True logic.sample_name.return_value = "lead" - samples_coordinator._remove_sample("abc") + samples_coordinator._remove_voice("abc") samples_coordinator._dialogs.show_confirmation.assert_called_once() - logic.remove_sample.assert_not_called() + logic.remove_voice.assert_not_called() confirmation = samples_coordinator._dialogs.show_confirmation.call_args.kwargs assert confirmation["message"] == "Remove lead?" confirmation["on_confirm"]() - logic.remove_sample.assert_called_once_with("abc") + logic.remove_voice.assert_called_once_with("abc") class TestSubmitRename: @@ -152,7 +152,7 @@ def test_submit_rename_trims_whitespace( ) -> None: samples_coordinator._submit_rename("abc", " bass ") - samples_coordinator._sequencer_samples_logic.rename_sample.assert_called_once_with("abc", "bass") + samples_coordinator._sequencer_samples_logic.rename_voice.assert_called_once_with("abc", "bass") def test_submit_rename_ignores_blank_name( self, @@ -628,7 +628,7 @@ def replace_coordinator() -> SequencerTabCoordinator: instance._sequencer_samples_logic = MagicMock() instance._sequencer_samples_panel = MagicMock() instance._sequencer_samples_panel.selection = SampleSelection( - sample_id="bass-id", + voice_id="bass-id", position=26, name="bass", ) @@ -674,7 +674,7 @@ def test_selected_sample_is_renamed_and_substituted( replace_coordinator.replace_reconstruction(Path("/reconstructions/kick_02.stn")) - replace_coordinator._sequencer_samples_logic.rename_sample.assert_called_once_with( + replace_coordinator._sequencer_samples_logic.rename_voice.assert_called_once_with( "bass-id", "kick_02", ) diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index a1f08ffdd..207387993 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -161,7 +161,7 @@ def test_eviction_prunes_cache_to_retained_reconstructions( sample = controller.add_sample(reconstruction_factory(), name="lead") with history.transaction(HistoryAction.REMOVE_SAMPLE): - controller.remove_sample(sample.id) + controller.remove_voice(sample.id) with history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index d636cef4f..13cfb84e7 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -9,7 +9,8 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer -from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction @@ -59,12 +60,12 @@ def test_add_sample_appends_and_emits( ) -> None: controller = _controller() emitted: List[str] = [] - controller.on_samples_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("samples") sample = controller.add_sample(reconstruction_factory(), name="lead") - assert list(controller.project.samples) == [sample] - assert controller.project.sample(sample.id) is sample + assert list(controller.project.voices) == [sample] + assert controller.project.voice(sample.id) is sample assert emitted == ["samples"] def test_add_sample_detaches_source_but_keeps_object_identity( @@ -92,13 +93,13 @@ def test_remove_sample_purges_row_references( ChannelName.PULSE1, pattern_id, 0, - command=Instrument(sample_id=sample.id, channel_name=ChannelName.PULSE1), + command=NoteOn(voice_id=sample.id), volume=15, ) - controller.remove_sample(sample.id) + controller.remove_voice(sample.id) - assert controller.project.sample(sample.id) is None + assert controller.project.voice(sample.id) is None assert song.pattern(ChannelName.PULSE1, pattern_id).rows[0].command is None def test_is_sample_used_reflects_pattern_references( @@ -109,19 +110,16 @@ def test_is_sample_used_reflects_pattern_references( sample = controller.add_sample(reconstruction_factory(), name="lead") pattern_id = controller.project.song.order[0][ChannelName.PULSE1] - assert controller.is_sample_used(sample.id) is False + assert controller.is_voice_used(sample.id) is False controller.set_row( ChannelName.PULSE1, pattern_id, 0, - command=Instrument( - sample_id=sample.id, - channel_name=ChannelName.PULSE1, - ), + command=NoteOn(voice_id=sample.id), ) - assert controller.is_sample_used(sample.id) is True + assert controller.is_voice_used(sample.id) is True def test_move_sample_reorders_pool( self, @@ -132,14 +130,14 @@ def test_move_sample_reorders_pool( controller.add_sample(reconstruction_factory(), name="second") controller.add_sample(reconstruction_factory(), name="third") - controller.move_sample(first.id, 2) + controller.move_voice(first.id, 2) - assert [sample.name for sample in controller.project.samples] == [ + assert [sample.name for sample in controller.project.voices] == [ "second", "third", "first", ] - assert controller.project.samples.get_index(first.id) == 2 + assert controller.project.voices.get_index(first.id) == 2 def test_move_sample_preserves_row_references( self, @@ -154,17 +152,14 @@ def test_move_sample_preserves_row_references( ChannelName.PULSE1, pattern_id, 0, - command=Instrument( - sample_id=sample.id, - channel_name=ChannelName.PULSE1, - ), + command=NoteOn(voice_id=sample.id), ) - controller.move_sample(sample.id, 1) + controller.move_voice(sample.id, 1) row = song.pattern(ChannelName.PULSE1, pattern_id).rows[0] assert row.command is not None - assert row.command.sample_id == sample.id + assert row.command.voice_id == sample.id def test_move_sample_emits_samples_and_song_changes( self, reconstruction_factory: Callable[[], Reconstruction] @@ -173,10 +168,10 @@ def test_move_sample_emits_samples_and_song_changes( sample = controller.add_sample(reconstruction_factory(), name="lead") controller.add_sample(reconstruction_factory(), name="pad") emitted: List[str] = [] - controller.on_samples_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("samples") controller.on_song_changed = lambda: emitted.append("song") - controller.move_sample(sample.id, 1) + controller.move_voice(sample.id, 1) assert "samples" in emitted assert "song" in emitted @@ -187,16 +182,16 @@ def test_duplicate_sample_appends_independent_copy( controller = _controller() source = controller.add_sample(reconstruction_factory(), name="lead") - clone = controller.duplicate_sample(source.id) + clone = controller.duplicate_voice(source.id) assert clone.id != source.id assert clone.name == source.name assert clone.reconstruction is not source.reconstruction - assert [sample.name for sample in controller.project.samples] == [ + assert [sample.name for sample in controller.project.voices] == [ "lead", "lead", ] - assert controller.project.samples.get_index(clone.id) == 1 + assert controller.project.voices.get_index(clone.id) == 1 def test_duplicate_sample_emits_samples_change( self, @@ -205,9 +200,9 @@ def test_duplicate_sample_emits_samples_change( controller = _controller() source = controller.add_sample(reconstruction_factory(), name="lead") emitted: List[str] = [] - controller.on_samples_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("samples") - controller.duplicate_sample(source.id) + controller.duplicate_voice(source.id) assert emitted == ["samples"] @@ -223,7 +218,7 @@ def test_replace_sample_reconstruction_swaps_content_and_keeps_identity( assert sample.reconstruction is replacement assert sample.name == "lead" - assert controller.project.sample(sample.id) is sample + assert controller.project.voice(sample.id) is sample def test_replace_sample_reconstruction_detaches_source( self, @@ -250,10 +245,7 @@ def test_replace_sample_reconstruction_preserves_row_references( ChannelName.PULSE1, pattern_id, 0, - command=Instrument( - sample_id=sample.id, - channel_name=ChannelName.PULSE1, - ), + command=NoteOn(voice_id=sample.id), ) controller.replace_sample_reconstruction( @@ -263,7 +255,7 @@ def test_replace_sample_reconstruction_preserves_row_references( row = song.pattern(ChannelName.PULSE1, pattern_id).rows[0] assert row.command is not None - assert row.command.sample_id == sample.id + assert row.command.voice_id == sample.id def test_replace_sample_reconstruction_emits_samples_and_song_changes( self, @@ -272,7 +264,7 @@ def test_replace_sample_reconstruction_emits_samples_and_song_changes( controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") emitted: List[str] = [] - controller.on_samples_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("samples") controller.on_song_changed = lambda: emitted.append("song") controller.replace_sample_reconstruction( @@ -298,17 +290,14 @@ def test_set_row_replaces_row( ChannelName.PULSE1, pattern_id, 2, - command=Instrument( - sample_id=sample.id, - channel_name=ChannelName.PULSE1, - ), + command=NoteOn(voice_id=sample.id), transpose=0, volume=10, ) row = song.pattern(ChannelName.PULSE1, pattern_id).rows[2] assert row.command is not None - assert row.command.sample_id == sample.id + assert row.command.voice_id == sample.id assert row.transpose == 0 assert row.volume == 10 @@ -354,10 +343,7 @@ def test_controller_edits_survive_save_load( ChannelName.PULSE1, pattern_id, 0, - command=Instrument( - sample_id=sample.id, - channel_name=ChannelName.PULSE1, - ), + command=NoteOn(voice_id=sample.id), volume=12, ) @@ -367,10 +353,10 @@ def test_controller_edits_survive_save_load( assert loaded.info.title == "Round" assert loaded.settings.tempo == 96 - assert [stored.name for stored in loaded.samples] == ["lead"] + assert [stored.name for stored in loaded.voices] == ["lead"] loaded_row = loaded.song.pattern(ChannelName.PULSE1, pattern_id).rows[0] assert loaded_row.command is not None - assert loaded_row.command.sample_id == sample.id + assert loaded_row.command.voice_id == sample.id assert loaded_row.volume == 12 @@ -390,7 +376,7 @@ def test_sample_count_tracks_the_pool( controller.add_sample(reconstruction_factory(), name="pad") assert controller.sample_count == 2 - controller.remove_sample(sample.id) + controller.remove_voice(sample.id) assert controller.sample_count == 1 def test_is_dirty_false_initially(self) -> None: @@ -510,8 +496,8 @@ def test_set_sample_loop_toggles_loop_flag( ) -> None: controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") - controller.set_sample_loop(sample.id, True) - assert controller.project.sample(sample.id).loop is True + controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) + assert controller.project.voice(sample.id).loop_point == WHOLE_LOOP_POINT class TestPatternManagement: @@ -558,7 +544,7 @@ def test_sample_holds_reconstruction_by_reference( reconstruction = reconstruction_factory() sample = controller.add_sample(reconstruction, name="lead") - assert controller.project.sample(sample.id).reconstruction is reconstruction + assert controller.project.voice(sample.id).reconstruction is reconstruction def test_in_place_reconstruction_edit_is_visible_through_project( self, reconstruction_factory: Callable[[], Reconstruction] @@ -583,7 +569,7 @@ def test_in_place_reconstruction_edit_is_visible_through_project( (), ) - stored = controller.project.sample(sample.id).reconstruction + stored = controller.project.voice(sample.id).reconstruction assert stored.get_channel_instructions(ChannelName.PULSE1) == new_instructions diff --git a/tests/unit/sampletones_application/logic/project/test_manager.py b/tests/unit/sampletones_application/logic/project/test_manager.py index dcfc14110..66c243026 100644 --- a/tests/unit/sampletones_application/logic/project/test_manager.py +++ b/tests/unit/sampletones_application/logic/project/test_manager.py @@ -12,7 +12,7 @@ class TestProjectManager: def test_starts_with_a_clean_default_project(self) -> None: manager = ProjectManager() assert set(manager.current.song.channels) == set(ChannelName.items()) - assert len(manager.current.samples) == 0 + assert len(manager.current.voices) == 0 assert manager.is_dirty is False def test_mark_updated_sets_dirty(self) -> None: @@ -25,7 +25,7 @@ def test_new_replaces_with_clean_project(self) -> None: manager.mark_updated() manager.new() assert manager.is_dirty is False - assert len(manager.current.samples) == 0 + assert len(manager.current.voices) == 0 def test_save_load_round_trip(self, tmp_path: Path) -> None: manager = ProjectManager() diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py index d37a9b775..277fcc71a 100644 --- a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py @@ -9,7 +9,8 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.constants.general import MAX_TRANSPOSE, MIN_TRANSPOSE +from sampletones_core.project.voices.note_off import NoteOff SAMPLE_IDS: List[str] = ["kick", "snare", "hat"] @@ -17,18 +18,18 @@ class FakeSampleDirectory: """A list of samples, standing where the project's own list would.""" - def __init__(self, sample_ids: List[str]) -> None: - self._sample_ids = sample_ids + def __init__(self, voice_ids: List[str]) -> None: + self._voice_ids = voice_ids - def position_of(self, sample_id: str) -> Optional[int]: - if sample_id not in self._sample_ids: + def position_of(self, voice_id: str) -> Optional[int]: + if voice_id not in self._voice_ids: return None - return self._sample_ids.index(sample_id) + return self._voice_ids.index(voice_id) def sample_at(self, position: int) -> Optional[str]: - if 0 <= position < len(self._sample_ids): - return self._sample_ids[position] + if 0 <= position < len(self._voice_ids): + return self._voice_ids[position] return None @@ -270,8 +271,8 @@ class RefusalCase: RefusalCase("a slot past the grid", "SampleToNES/1 tracker rows=1 slots=13..15\n01 +00 F"), RefusalCase("a word in a note field", f"{HEADER}\nxx +00 F\n01 +00 F"), RefusalCase("an unsigned transpose", f"{HEADER}\n01 12 F\n01 +00 F"), - RefusalCase("a transpose past the range", f"{HEADER}\n01 +40 F\n01 +00 F"), - RefusalCase("a transpose below the range", f"{HEADER}\n01 -40 F\n01 +00 F"), + RefusalCase("a transpose past the range", f"{HEADER}\n01 +{MAX_TRANSPOSE + 1:02X} F\n01 +00 F"), + RefusalCase("a transpose below the range", f"{HEADER}\n01 -{abs(MIN_TRANSPOSE) + 1:02X} F\n01 +00 F"), RefusalCase("a volume past the range", f"{HEADER}\n01 +00 FF\n01 +00 F"), RefusalCase("dots and marks in one field", f"{HEADER}\n.? +00 F\n01 +00 F"), ] diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 94ef8a853..310a2fc39 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -11,9 +11,10 @@ from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction @@ -51,7 +52,7 @@ def add_sample( ) -> Sample: sample = controller.add_sample(reconstruction, name) if loop: - controller.set_sample_loop(sample.id, loop=True) + controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) return sample @@ -60,7 +61,7 @@ def place_row( *, channel: ChannelName, row_index: int = 0, - sample_id: str, + voice_id: str, transpose: int | None = None, volume: int | None = None, ) -> None: @@ -69,7 +70,7 @@ def place_row( channel, pattern_index, row_index, - command=Instrument(sample_id=sample_id, channel_name=channel), + command=NoteOn(voice_id=voice_id), transpose=transpose, volume=volume, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index a0fecb883..60b15f298 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -63,7 +63,7 @@ class SynthesizerContext: mask: MaskProvider chunks: List[np.ndarray] = field(default_factory=list) tick_snapshots: Dict[str, int] = field(default_factory=dict) - sample_id_snapshots: Dict[str, Optional[str]] = field(default_factory=dict) + voice_id_snapshots: Dict[str, Optional[str]] = field(default_factory=dict) def _make_context() -> SynthesizerContext: @@ -123,7 +123,7 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) def render_row_0_and_assert_defaults(context: SynthesizerContext) -> None: @@ -154,7 +154,7 @@ def place_pulse_sample_with_modifiers(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, transpose=5, volume=8, ) @@ -191,21 +191,21 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) def render_row_0_and_record_state(context: SynthesizerContext) -> None: _render(context) context.tick_snapshots["after_row_0"] = _performance(context).tick_index - context.sample_id_snapshots["triggered"] = _performance(context).sample_id - assert _performance(context).sample_id is not None + context.voice_id_snapshots["triggered"] = _performance(context).voice_id + assert _performance(context).voice_id is not None def render_empty_row_1_and_assert_tick_advanced( context: SynthesizerContext, ) -> None: _render(context) assert _performance(context).tick_index > context.tick_snapshots["after_row_0"] - assert _performance(context).sample_id == context.sample_id_snapshots["triggered"] + assert _performance(context).voice_id == context.voice_id_snapshots["triggered"] BaseTestScenario( label="sustain — empty row continues previous note", @@ -236,7 +236,7 @@ def setup(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, volume=15, ) place_modifier_row( @@ -249,7 +249,7 @@ def setup(context: SynthesizerContext) -> None: def render_row_0_and_record_state(context: SynthesizerContext) -> None: _render(context) context.tick_snapshots["after_row_0"] = _performance(context).tick_index - context.sample_id_snapshots["after_row_0"] = _performance(context).sample_id + context.voice_id_snapshots["after_row_0"] = _performance(context).voice_id assert _performance(context).volume == 15 def render_modifier_row_and_assert_volume_changed( @@ -257,7 +257,7 @@ def render_modifier_row_and_assert_volume_changed( ) -> None: _render(context) assert _performance(context).volume == 0 - assert _performance(context).sample_id == context.sample_id_snapshots["after_row_0"] + assert _performance(context).voice_id == context.voice_id_snapshots["after_row_0"] assert _performance(context).tick_index > context.tick_snapshots["after_row_0"] BaseTestScenario( @@ -284,7 +284,7 @@ def setup(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, transpose=0, ) place_modifier_row( @@ -296,7 +296,7 @@ def setup(context: SynthesizerContext) -> None: def render_row_0_and_record_sample(context: SynthesizerContext) -> None: _render(context) - context.sample_id_snapshots["triggered"] = _performance(context).sample_id + context.voice_id_snapshots["triggered"] = _performance(context).voice_id assert _performance(context).transpose == 0 def render_modifier_row_and_assert_transpose_changed( @@ -304,7 +304,7 @@ def render_modifier_row_and_assert_transpose_changed( ) -> None: _render(context) assert _performance(context).transpose == 7 - assert _performance(context).sample_id == context.sample_id_snapshots["triggered"] + assert _performance(context).voice_id == context.voice_id_snapshots["triggered"] BaseTestScenario( label="modifier-only row changes transpose without retriggering", @@ -335,7 +335,7 @@ def place_pulse_sample(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) def mute_pulse1(context: SynthesizerContext) -> None: @@ -377,13 +377,13 @@ def place_looping_pulse_sample(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) def mute_pulse1_and_render_row_0(context: SynthesizerContext) -> None: context.mask.mute(ChannelName.PULSE1) assert np.allclose(_render(context), 0.0) - assert _performance(context).sample_id is not None + assert _performance(context).voice_id is not None def unmute_pulse1_and_render_row_1(context: SynthesizerContext) -> None: context.mask.active = ALL_CHANNELS @@ -500,7 +500,7 @@ def place_looped_sample_then_note_off(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) place_note_off(_controller(context), channel=ChannelName.PULSE1, row_index=1) @@ -510,7 +510,7 @@ def render_row_0_and_assert_audible(context: SynthesizerContext) -> None: def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None: audio = _render(context) assert np.all(audio == 0.0) - assert _performance(context).sample_id is None + assert _performance(context).voice_id is None BaseTestScenario( label="note-off silences a looped voice and clears channel state", @@ -541,7 +541,7 @@ def place_two_instruction_loop_sample(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) def render_row_0_and_assert_non_silence(context: SynthesizerContext) -> None: @@ -585,7 +585,7 @@ def place_one_instruction_non_loop_sample(context: SynthesizerContext) -> None: _controller(context), channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) def render_row_0_and_assert_first_tick_audible_rest_silent( @@ -621,7 +621,7 @@ def place_loop_then_append_empty_frame(context: SynthesizerContext) -> None: controller, channel=ChannelName.PULSE1, row_index=0, - sample_id=sample.id, + voice_id=sample.id, ) controller.append_frame() @@ -635,7 +635,7 @@ def render_into_empty_second_frame_and_assert_sustained( audio, (order_position, _) = context.synthesizer.render_row() assert order_position == 1 assert not np.all(audio == 0.0) - assert _performance(context).sample_id is not None + assert _performance(context).voice_id is not None BaseTestScenario( label="looped voice carries across an empty (None-slot) next frame", @@ -847,7 +847,7 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non controller = make_controller() recon = make_pulse_reconstruction(count=12) sample = add_sample(controller, recon) - place_row(controller, channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id) + place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) controller.set_nes_frequency(60) @@ -859,7 +859,7 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non synthesizer.render_row() assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30) - assert pulse_state.performance.sample_id is not None + assert pulse_state.performance.voice_id is not None class TestChannelHeldValues: @@ -883,7 +883,7 @@ def _place( _controller(context), channel=ChannelName.PULSE1, row_index=row_index, - sample_id=sample.id, + voice_id=sample.id, ) @staticmethod diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index e74020610..50c237c17 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -224,7 +224,7 @@ def test_a_sounding_channel_fills_every_tick(self) -> None: controller = make_controller() reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) - place_row(controller, channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id) + place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() @@ -237,7 +237,7 @@ def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> N controller = make_controller() reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) - place_row(controller, channel=ChannelName.PULSE1, row_index=0, sample_id=sample.id) + place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 7be6c8f36..160056663 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -316,7 +316,7 @@ def test_remove_sample_shows_position_and_name(self) -> None: sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) - assert _pairs(formatter.remove_sample(sample.id)) == [ + assert _pairs(formatter.remove_voice(sample.id)) == [ ("00:", HistoryDetailRole.SAMPLE), ("Bass", HistoryDetailRole.NAME), ] @@ -336,7 +336,7 @@ def test_replace_sample_shows_position_and_both_names(self) -> None: def test_rename_sample_shows_old_and_new(self) -> None: formatter = _formatter(_controller()) - assert _pairs(formatter.rename_sample("Bass", "Kick")) == [ + assert _pairs(formatter.rename_voice("Bass", "Kick")) == [ ("Bass", HistoryDetailRole.NAME), (">", HistoryDetailRole.SEPARATOR), ("Kick", HistoryDetailRole.NAME), @@ -347,7 +347,7 @@ def test_move_sample_shows_source_position_and_destination(self) -> None: sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) - assert _pairs(formatter.move_sample(sample.id, 5)) == [ + assert _pairs(formatter.move_voice(sample.id, 5)) == [ ("00", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), ("05", HistoryDetailRole.VALUE), diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index 4bd89c7e7..149023ffe 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -10,7 +10,8 @@ from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import reconstruction_footprints -from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction from tests.suite.sequencer import sample_reconstruction @@ -47,14 +48,14 @@ def _logic_with_mocks() -> Tuple[ def _place_instrument( controller: ProjectController, channel: ChannelName, - sample_id: str, + voice_id: str, ) -> None: pattern_index = controller.project.song.order[0][channel] controller.set_row( channel, pattern_index, 0, - command=Instrument(sample_id=sample_id, channel_name=channel), + command=NoteOn(voice_id=voice_id), ) @@ -75,7 +76,7 @@ def test_false_for_unreferenced_sample( ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - assert logic.is_sample_used(sample.id) is False + assert logic.is_voice_used(sample.id) is False def test_true_after_placing_in_a_pattern( self, @@ -84,7 +85,7 @@ def test_true_after_placing_in_a_pattern( controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") _place_instrument(controller, ChannelName.PULSE1, sample.id) - assert logic.is_sample_used(sample.id) is True + assert logic.is_voice_used(sample.id) is True class TestRemoveSample: @@ -95,9 +96,9 @@ def test_removes_unused_sample_from_pool( controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - logic.remove_sample(sample.id) + logic.remove_voice(sample.id) - assert controller.project.sample(sample.id) is None + assert controller.project.voice(sample.id) is None def test_removing_used_sample_clears_its_references( self, reconstruction_factory: Callable[[], Reconstruction] @@ -106,10 +107,10 @@ def test_removing_used_sample_clears_its_references( sample = controller.add_sample(reconstruction_factory(), name="lead") _place_instrument(controller, ChannelName.PULSE1, sample.id) - logic.remove_sample(sample.id) + logic.remove_voice(sample.id) - assert controller.project.sample(sample.id) is None - assert logic.is_sample_used(sample.id) is False + assert controller.project.voice(sample.id) is None + assert logic.is_voice_used(sample.id) is False class TestMoveSample: @@ -121,9 +122,9 @@ def test_move_sample_reorders_pool( first = controller.add_sample(reconstruction_factory(), name="first") controller.add_sample(reconstruction_factory(), name="second") - logic.move_sample(first.id, 1) + logic.move_voice(first.id, 1) - assert [sample.name for sample in controller.project.samples] == [ + assert [sample.name for sample in controller.project.voices] == [ "second", "first", ] @@ -137,9 +138,9 @@ def test_duplicate_sample_appends_copy( controller, logic = _logic() source = controller.add_sample(reconstruction_factory(), name="lead") - logic.duplicate_sample(source.id) + logic.duplicate_voice(source.id) - assert [sample.name for sample in controller.project.samples] == [ + assert [sample.name for sample in controller.project.voices] == [ "lead", "lead", ] @@ -156,7 +157,7 @@ def test_lists_added_samples_in_insertion_order( view_model = logic.build_samples() - assert [entry.sample_id for entry in view_model.samples] == [ + assert [entry.voice_id for entry in view_model.samples] == [ first.id, second.id, ] @@ -185,7 +186,7 @@ def test_it_measures_the_sample_under_its_own_loop_flag( ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - controller.set_sample_loop(sample.id, True) + controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) footprint = logic.build_sample_footprint(sample.id) @@ -202,7 +203,7 @@ def test_a_looping_sample_costs_less_than_a_one_shot( sample = controller.add_sample(reconstruction_factory(), name="lead") one_shot = logic.build_sample_footprint(sample.id) - controller.set_sample_loop(sample.id, True) + controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) looping = logic.build_sample_footprint(sample.id) assert one_shot is not None and looping is not None diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py index ac0771b12..0a0cc74b9 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py @@ -13,12 +13,15 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_TRANSPOSE +from sampletones_core.utils.display import display_transpose from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.sequencer import fill_frame, render_frame, sample_reconstruction FRAME_ROWS: Final[int] = 3 EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." +HIGHEST: Final[str] = display_transpose(MAX_TRANSPOSE) LEAD: Final[str] = "00" @@ -29,7 +32,7 @@ class Grid: controller: ProjectController logic: SequencerTrackerLogic adjuster: TrackerRegionAdjuster - sample_ids: Tuple[str, ...] + voice_ids: Tuple[str, ...] @pytest.fixture @@ -50,7 +53,7 @@ def grid() -> Grid: controller=controller, logic=logic, adjuster=TrackerRegionAdjuster(logic), - sample_ids=(lead.id,), + voice_ids=(lead.id,), ) @@ -196,14 +199,14 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="a shift stops at the transpose range", - frame=(".. +20 . | .. ... . | .. ... . | .. ... .",), + frame=(f".. {HIGHEST} . | .. ... . | .. ... . | .. ... .",), region=_region( (ChannelName.PULSE1, SubColumn.TRANSPOSE), (ChannelName.PULSE1, SubColumn.TRANSPOSE), ), delta=12, expected=( - ".. +24 . | .. ... . | .. ... . | .. ... .", + f".. {HIGHEST} . | .. ... . | .. ... . | .. ... .", EMPTY, EMPTY, ), @@ -220,7 +223,7 @@ def test_the_frame_after_a_shift( grid: Grid, test_case: TestCase, ) -> None: - fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids) grid.adjuster.adjust_transpose(test_case.region, test_case.delta) @@ -305,7 +308,7 @@ def test_the_frame_after_a_shift( grid: Grid, test_case: TestCase, ) -> None: - fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids) grid.adjuster.adjust_volume(test_case.region, test_case.delta) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py index f19c044dd..2a5b3f58c 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -12,7 +12,7 @@ from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.voices.note_off import NoteOff from tests.suite.sequencer import sample_reconstruction diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index d853d3260..1948b96d6 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -4,9 +4,9 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from sampletones_shared.constants.symbols import MIXED from tests.suite.sequencer import sample_reconstruction @@ -28,14 +28,14 @@ def _row( def _place_instrument( controller: ProjectController, channel: ChannelName, - sample_id: str, + voice_id: str, ) -> None: pattern_index = controller.project.song.order[0][channel] controller.set_row( channel, pattern_index, 0, - command=Instrument(sample_id=sample_id, channel_name=channel), + command=NoteOn(voice_id=voice_id), ) @@ -121,13 +121,13 @@ def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None: logic.write_cell(0, None, sample.id, None, None) for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): - assert isinstance(_row(controller, channel).command, Instrument) + assert isinstance(_row(controller, channel).command, NoteOn) for channel in (ChannelName.PULSE2, ChannelName.NOISE): assert _row(controller, channel).command is None - def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: - """A cell re-targets the sample onto its own channel, whichever channels the sample covers.""" + def test_a_sample_in_a_channel_cell_lands_on_that_channel(self) -> None: + """A cell writes the voice into its own channel's pattern, whichever channels the sample covers.""" controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( @@ -138,9 +138,8 @@ def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: logic.write_cell(0, ChannelName.NOISE, sample.id, None, None) command = _row(controller, ChannelName.NOISE).command - assert isinstance(command, Instrument) - assert command.sample_id == sample.id - assert command.channel_name == ChannelName.NOISE + assert isinstance(command, NoteOn) + assert command.voice_id == sample.id assert _row(controller, ChannelName.PULSE1).command is None def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None: @@ -299,9 +298,8 @@ def test_fills_only_used_generators(self) -> None: for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): command = _row(controller, channel).command - assert isinstance(command, Instrument) - assert command.sample_id == sample.id - assert command.channel_name == channel + assert isinstance(command, NoteOn) + assert command.voice_id == sample.id for channel in (ChannelName.PULSE2, ChannelName.NOISE): assert _row(controller, channel).command is None @@ -318,10 +316,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: ChannelName.PULSE2, pattern_index, 0, - command=Instrument( - sample_id=stale.id, - channel_name=ChannelName.PULSE2, - ), + command=NoteOn(voice_id=stale.id), volume=15, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py index d1a02d5a5..522f0e726 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -37,7 +37,7 @@ class Grid: controller: ProjectController logic: SequencerTrackerLogic writer: TrackerBlockWriter - sample_ids: Tuple[str, ...] + voice_ids: Tuple[str, ...] @pytest.fixture @@ -64,7 +64,7 @@ def grid() -> Grid: controller=controller, logic=logic, writer=TrackerBlockWriter(logic), - sample_ids=(lead.id, bass.id), + voice_ids=(lead.id, bass.id), ) @@ -302,11 +302,11 @@ def test_the_frame_after_a_paste( grid: Grid, test_case: TestCase, ) -> None: - fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids) block = parse_block( test_case.block, first_subcolumn=test_case.first_subcolumn, - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) grid.writer.write(block, test_case.origin) @@ -322,7 +322,7 @@ def test_a_single_cell_block_matches_the_edit_it_stands_for(self, grid: Grid) -> block = parse_block( ("+02",), first_subcolumn=SubColumn.TRANSPOSE, - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE1)) pasted = render_frame(grid.logic) @@ -340,7 +340,7 @@ def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None: fill_frame( grid.logic, ("00 +02 5 | 00 +03 6 | .. ... . | .. ... .",), - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) grid.writer.clear( @@ -358,7 +358,7 @@ def test_a_region_over_the_sample_column_empties_the_channels_it_governs(self, g fill_frame( grid.logic, ("00 +02 5 | 00 +02 5 | .. ... . | .. ... 5",), - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) grid.writer.clear( @@ -383,7 +383,7 @@ def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) "00 +02 5 | 00 ... . | .. ... . | ~~ ... 3", ".. ... . | 01 +00 0 | .. +07 . | .. ... .", ), - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) before = render_frame(grid.logic) region = TrackerRegion( @@ -412,7 +412,7 @@ def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Gr block = parse_block( ("+02",), first_subcolumn=SubColumn.TRANSPOSE, - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2)) @@ -427,7 +427,7 @@ def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid: block = parse_block( ("? ? ?",), first_subcolumn=SubColumn.INSTRUMENT, - sample_ids=grid.sample_ids, + voice_ids=grid.voice_ids, ) grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2)) diff --git a/tests/unit/sampletones_application/logic/shared/test_project_source.py b/tests/unit/sampletones_application/logic/shared/test_project_source.py index 4d8b081ca..ddea9bf89 100644 --- a/tests/unit/sampletones_application/logic/shared/test_project_source.py +++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py @@ -37,7 +37,7 @@ def test_reconstruction_audio_is_shared( snapshot = snapshot_project(project_controller.project) - assert snapshot.samples[sample.id].reconstruction is sample.reconstruction + assert snapshot.voices[sample.id].reconstruction is sample.reconstruction class TestASnapshotIsASource(BaseTestSuite): diff --git a/tests/unit/sampletones_application/services/retune/test_retune.py b/tests/unit/sampletones_application/services/retune/test_retune.py index 0c1d1759e..e30d59b63 100644 --- a/tests/unit/sampletones_application/services/retune/test_retune.py +++ b/tests/unit/sampletones_application/services/retune/test_retune.py @@ -21,7 +21,7 @@ def test_emits_one_success_per_target(self) -> None: service._run([("a", first), ("b", second)], 60) assert [type(result) for result in results] == [ServiceSuccess, ServiceSuccess] - assert [result.value.sample_id for result in results] == ["a", "b"] + assert [result.value.voice_id for result in results] == ["a", "b"] assert results[0].value.reconstruction is first.with_nes_frequency.return_value def test_retunes_each_target_to_the_requested_rate(self) -> None: @@ -46,7 +46,7 @@ def test_emits_service_error_when_a_retune_raises(self) -> None: assert len(results) == 1 assert isinstance(results[0], ServiceError) - def test_result_carries_the_sample_id_and_retuned_reconstruction(self) -> None: + def test_result_carries_the_voice_id_and_retuned_reconstruction(self) -> None: service = SampleRetuneService() results: List[Any] = [] service.subscribe(results.append) @@ -54,7 +54,7 @@ def test_result_carries_the_sample_id_and_retuned_reconstruction(self) -> None: service._run([("lead", _reconstruction(retuned))], 60) - assert results[0].value == RetunedSample(sample_id="lead", reconstruction=retuned) + assert results[0].value == RetunedSample(voice_id="lead", reconstruction=retuned) class TestSampleRetuneServiceStart: diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index 3752d1f5a..7f6a86d04 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -6,10 +6,10 @@ from sampletones_application.services.retune import RetunedSample -def _retuned(sample_id: str, rate: int) -> RetunedSample: +def _retuned(voice_id: str, rate: int) -> RetunedSample: reconstruction = MagicMock() reconstruction.config.nes_frequency = rate - return RetunedSample(sample_id=sample_id, reconstruction=reconstruction) + return RetunedSample(voice_id=voice_id, reconstruction=reconstruction) def _app( @@ -20,7 +20,7 @@ def _app( app = Application.__new__(Application) app.project_manager = MagicMock() app.project_manager.current.settings.nes_frequency = current_rate - app.project_manager.current.samples.get.return_value = sample + app.project_manager.current.voices.get.return_value = sample app.reconstruction_manager = MagicMock() app.reconstruction_manager.reconstruction = open_reconstruction app.history = MagicMock() @@ -79,9 +79,9 @@ def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: app._reconstructions_tab.update_reconstruction.assert_not_called() -def _sample(sample_id: str, rate: int) -> MagicMock: +def _sample(voice_id: str, rate: int) -> MagicMock: sample = MagicMock() - sample.id = sample_id + sample.id = voice_id sample.reconstruction.config.nes_frequency = rate return sample @@ -93,7 +93,7 @@ def _app_for_rate( ) -> Application: app = Application.__new__(Application) app.project_manager = MagicMock() - app.project_manager.current.samples = samples + app.project_manager.current.voices = samples app.reconstruction_manager = MagicMock() app.reconstruction_manager.reconstruction = open_reconstruction app.retune_service = MagicMock() diff --git a/tests/unit/sampletones_application/test_application_sample_rebind.py b/tests/unit/sampletones_application/test_application_sample_rebind.py index a434fa712..b3c236d44 100644 --- a/tests/unit/sampletones_application/test_application_sample_rebind.py +++ b/tests/unit/sampletones_application/test_application_sample_rebind.py @@ -10,7 +10,7 @@ def _app( ) -> Application: app = Application.__new__(Application) app.project_manager = MagicMock() - app.project_manager.current.sample.return_value = sample + app.project_manager.current.voice.return_value = sample app.reconstruction_manager = MagicMock() app.reconstruction_manager.reconstruction = open_reconstruction app._reconstructions_tab = MagicMock() diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 441200c1e..7a7a701f2 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -291,7 +291,7 @@ def test_save_as_detaches_open_document_from_the_project( assert app._build_menu_bar_viewmodel().reconstruction_saveable assert app.reconstruction_manager.reconstruction is not original assert sample.reconstruction is original - assert original in [sample.reconstruction for sample in app.project_manager.current.samples] + assert original in [sample.reconstruction for sample in app.project_manager.current.voices] class TestAddOpenReconstructionToSequencer: @@ -340,7 +340,7 @@ def test_embedded_sample_is_a_detached_copy( app._add_current_reconstruction_to_sequencer() - sample = app.project_manager.current.samples[0] + sample = app.project_manager.current.voices[0] assert sample.reconstruction is not app.reconstruction_manager.reconstruction assert sample.reconstruction.audio_filepath == () assert not app._editing_project_sample() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py index 08b1610a8..c3ec6ed86 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -54,15 +54,15 @@ def _samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel: panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) panel._router = KeyRouter() panel._tab_active = tab_active - panel._selected_sample_id = SELECTED_ID - panel._editing_sample_id = None + panel._selected_voice_id = SELECTED_ID + panel._editing_voice_id = None return panel def _renaming_samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel: """A samples panel mid-rename, the one state that keeps the keyboard on its own tab.""" panel = _samples(tab_active) - panel._editing_sample_id = SELECTED_ID + panel._editing_voice_id = SELECTED_ID return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py index 2c24e0c12..79b981ee6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py @@ -10,9 +10,9 @@ from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True), - SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False), + SampleEntryViewModel(voice_id="kick-id", name="Kick", loop=False), + SampleEntryViewModel(voice_id="bass-id", name="Bass", loop=True), + SampleEntryViewModel(voice_id="lead-id", name="Lead", loop=False), ) SELECTED_ID = "bass-id" @@ -37,13 +37,13 @@ def samples(monkeypatch: pytest.MonkeyPatch) -> SamplesPanelFixture: panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) panel._shortcuts = shipped_source() panel._entries = ENTRIES - panel._selected_sample_id = SELECTED_ID + panel._selected_voice_id = SELECTED_ID panel._selected_row = SELECTED_ROW - panel._editing_sample_id = None + panel._editing_voice_id = None fixture = SamplesPanelFixture(panel=panel) panel.on_remove_requested = fixture.removed.append - panel.on_move_requested = lambda sample_id, target: fixture.moved.append((sample_id, target)) + panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target)) monkeypatch.setattr(panel, "_start_rename", fixture.renamed.append) monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None)) return fixture @@ -69,7 +69,7 @@ def test_a_press_the_panel_leaves_unnamed_reaches_the_application(self, samples: assert samples.removed == [] def test_a_press_without_a_selection_reaches_the_application(self, samples: SamplesPanelFixture) -> None: - samples.panel._selected_sample_id = None + samples.panel._selected_voice_id = None assert samples.panel._on_key_pressed(_press("Del")) is False @@ -92,14 +92,14 @@ def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, samples: Samples class TestRenameInProgress: def test_the_cancel_key_drops_the_name_being_edited(self, samples: SamplesPanelFixture) -> None: - samples.panel._editing_sample_id = SELECTED_ID + samples.panel._editing_voice_id = SELECTED_ID assert samples.panel._on_key_pressed(_press("Esc")) is True assert samples.cancelled == [None] def test_every_other_key_stays_with_the_field(self, samples: SamplesPanelFixture) -> None: """A rename keeps the keyboard, so typing a name reaches the input rather than the list.""" - samples.panel._editing_sample_id = SELECTED_ID + samples.panel._editing_voice_id = SELECTED_ID assert samples.panel._on_key_pressed(_press("Del")) is False assert samples.removed == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index 794f9d78f..6b239eac9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -16,13 +16,13 @@ from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint -from sampletones_core.utils.display import display_sample_label +from sampletones_core.utils.display import display_voice_label from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True), - SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False), + SampleEntryViewModel(voice_id="kick-id", name="Kick", loop=False), + SampleEntryViewModel(voice_id="bass-id", name="Bass", loop=True), + SampleEntryViewModel(voice_id="lead-id", name="Lead", loop=False), ) SELECTED_ID = "bass-id" @@ -122,22 +122,22 @@ def _panel( panel._language_manager = _Labels() panel._shortcuts = shipped_source() panel._entries = ENTRIES - panel._selected_sample_id = None if selected_row is None else SELECTED_ID + panel._selected_voice_id = None if selected_row is None else SELECTED_ID panel._selected_row = selected_row - panel._editing_sample_id = editing + panel._editing_voice_id = editing panel._tab_active = lambda: tab_active panel._router = _Router(field_focused=field_focused) panel._detail_color = DETAIL_COLOR panel._lbl_sample_size = SAMPLE_SIZE_LABEL panel._tpl_size_bytes = SIZE_TEMPLATE panel._tip_size_bytes = SIZE_TOOLTIP - panel.sample_footprint = (lambda _sample_id: footprint) if footprint_wired else None + panel.sample_footprint = (lambda _voice_id: footprint) if footprint_wired else None requests = Requests() panel.on_sample_edit_requested = requests.edited.append panel.on_duplicate_requested = requests.duplicated.append panel.on_remove_requested = requests.removed.append - panel.on_move_requested = lambda sample_id, target: requests.moved.append((sample_id, target)) + panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target)) monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) return SamplesPanelFixture(panel=panel, requests=requests) @@ -310,8 +310,8 @@ def test_the_figures_name_the_sample_the_pointer_landed_on(self, monkeypatch: py """The figures are asked for as the menu opens, so they answer for the row right-clicked.""" measured: List[str] = [] - def _measure(sample_id: str) -> SampleFootprintViewModel: - measured.append(sample_id) + def _measure(voice_id: str) -> SampleFootprintViewModel: + measured.append(voice_id) return FOOTPRINT fixture = _panel(monkeypatch) @@ -339,7 +339,7 @@ def test_the_sizes_sit_between_the_sample_name_and_the_actions( _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) assert build_recorder.texts_before_the_first_item() == [ - display_sample_label(SELECTED_ROW, "Bass"), + display_voice_label(SELECTED_ROW, "Bass"), f"{SAMPLE_SIZE_LABEL}: {PULSE_1_BYTES + NOISE_BYTES} B", f"{ContextElements.PULSE_1.value}: {PULSE_1_BYTES} B", f"{ContextElements.NOISE.value}: {NOISE_BYTES} B", @@ -362,7 +362,7 @@ def test_a_menu_with_no_figures_reads_as_it_always_has( ) -> None: _panel(monkeypatch, footprint=None).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) - assert build_recorder.texts_before_the_first_item() == [display_sample_label(SELECTED_ROW, "Bass")] + assert build_recorder.texts_before_the_first_item() == [display_voice_label(SELECTED_ROW, "Bass")] class TestEditActions: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py index e787b42a3..cf72758a5 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py @@ -4,13 +4,13 @@ from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True), + SampleEntryViewModel(voice_id="kick-id", name="Kick", loop=False), + SampleEntryViewModel(voice_id="bass-id", name="Bass", loop=True), ) def _panel( - selected_sample_id: Optional[str], + selected_voice_id: Optional[str], selected_row: Optional[int], entries: Tuple[SampleEntryViewModel, ...] = ENTRIES, ) -> GUISequencerSamplesPanel: @@ -21,7 +21,7 @@ def _panel( """ panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) panel._entries = entries - panel._selected_sample_id = selected_sample_id + panel._selected_voice_id = selected_voice_id panel._selected_row = selected_row return panel @@ -33,7 +33,7 @@ def test_reports_the_highlighted_sample(self) -> None: selection = panel.selection assert selection is not None - assert selection.sample_id == "bass-id" + assert selection.voice_id == "bass-id" assert selection.position == 1 assert selection.name == "Bass" assert selection.label == "01: Bass" @@ -48,7 +48,7 @@ def test_absent_once_the_selected_sample_leaves_the_pool(self) -> None: def test_follows_a_renamed_sample(self) -> None: panel = _panel("kick-id", 0) - panel._entries = (SampleEntryViewModel(sample_id="kick-id", name="Thump", loop=False),) + panel._entries = (SampleEntryViewModel(voice_id="kick-id", name="Thump", loop=False),) selection = panel.selection diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 96b4d70ee..70f341440 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -133,19 +133,19 @@ def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuIt assert calls[0] == (target.region, SEMITONE_STEP) - def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) -> None: + def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) -> None: panel = _panel() panel._current_samples = SequencerSamplesViewModel( samples=( SampleEntryViewModel( - sample_id="lead-id", + voice_id="lead-id", name="lead", loop=False, ), ), ) chosen: List[str] = [] - panel.on_set_row = lambda row, channel, sample_id, transpose, volume: chosen.append(sample_id) + panel.on_set_row = lambda row, channel, voice_id, transpose, volume: chosen.append(voice_id) panel._add_instrument_submenu(_cell(0, ChannelName.PULSE2)) recorder.dispatch_as_dpg() diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py b/tests/unit/sampletones_application/view_model/sequencer/test_samples.py index 3cf52ff50..e0f19e5a5 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_samples.py @@ -18,6 +18,6 @@ def test_label_pairs_the_hex_position_with_the_name( name: str, expected: str, ) -> None: - selection = SampleSelection(sample_id="id", position=position, name=name) + selection = SampleSelection(voice_id="id", position=position, name=name) assert selection.label == expected diff --git a/tests/unit/sampletones_core/audio/test_processing.py b/tests/unit/sampletones_core/audio/test_processing.py index e83b77995..e10dfe4c2 100644 --- a/tests/unit/sampletones_core/audio/test_processing.py +++ b/tests/unit/sampletones_core/audio/test_processing.py @@ -233,7 +233,7 @@ def test_resample(self) -> None: target_sr=target_sample_rate, ) - def test_resample_identical(self) -> None: + def test_revoice_identical(self) -> None: array = np.array([2.0, 3.0], dtype=np.float32) sample_rate = 22050 target_sample_rate = 22050 diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py index 824869f6e..274c735b7 100644 --- a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py +++ b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py @@ -8,6 +8,15 @@ def _pool(extra: Dict[str, Any]) -> Dict[str, Any]: return {"generator": "pulse1", "patterns": {}, **extra} +def _pool_with_row(command: Dict[str, Any]) -> Dict[str, Any]: + return {"generator": "pulse1", "patterns": {"0": {"rows": [{"command": command}]}}} + + +def _first_command(data: Dict[str, Any]) -> Dict[str, Any]: + command: Dict[str, Any] = data["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"][0]["command"] + return command + + class TestProjectV1_1: def test_renames_channel_pool_field(self) -> None: data = {"song": {"channels": {"pulse1": _pool({})}}} @@ -18,58 +27,20 @@ def test_renames_channel_pool_field(self) -> None: assert "generator" not in upgraded["song"]["channels"]["pulse1"] def test_renames_instrument_command_channel(self) -> None: - data = { - "song": { - "channels": { - "pulse1": { - "generator": "pulse1", - "patterns": { - "0": { - "rows": { - "0": { - "command": { - "sample_id": "s", - "generator_name": "pulse1", - } - }, - } - } - }, - } - } - } - } + data = {"song": {"channels": {"pulse1": _pool_with_row({"sample_id": "s", "generator_name": "pulse1"})}}} upgraded = update(data) - command = upgraded["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"]["0"]["command"] + command = _first_command(upgraded) assert command[CHANNEL_NAME] == "pulse1" assert "generator_name" not in command def test_leaves_note_off_commands_untouched(self) -> None: - data = { - "song": { - "channels": { - "pulse1": { - "generator": "pulse1", - "patterns": { - "0": { - "rows": { - "0": { - "command": {}, - } - } - } - }, - } - } - } - } + data = {"song": {"channels": {"pulse1": _pool_with_row({})}}} upgraded = update(data) - command = upgraded["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"]["0"]["command"] - assert command == {} + assert _first_command(upgraded) == {} def test_leaves_the_input_untouched(self) -> None: data = {"song": {"channels": {"pulse1": _pool({})}}} diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_2.py b/tests/unit/sampletones_core/compatibility/project/test_v1_2.py new file mode 100644 index 000000000..ccc03196f --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/project/test_v1_2.py @@ -0,0 +1,47 @@ +from typing import Any, Dict + +from sampletones_core.compatibility.fields import KIND, KIND_SAMPLE, SAMPLES, VOICES +from sampletones_core.compatibility.project.v1_2 import update + + +def _pool_with_row(command: Dict[str, Any]) -> Dict[str, Any]: + return {"name": "pulse1", "patterns": {"0": {"rows": [{"command": command}]}}} + + +def _first_command(data: Dict[str, Any]) -> Dict[str, Any]: + command: Dict[str, Any] = data["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"][0]["command"] + return command + + +class TestProjectV1_2: + def test_gathers_samples_into_voices(self) -> None: + data = {SAMPLES: [{"id": "a", "name": "Lead", "reconstruction_id": "r"}]} + + upgraded = update(data) + + assert SAMPLES not in upgraded + assert upgraded[VOICES] == [{KIND: KIND_SAMPLE, "id": "a", "name": "Lead", "reconstruction_id": "r"}] + + def test_names_the_voice_alone(self) -> None: + data = {"song": {"channels": {"pulse1": _pool_with_row({"sample_id": "a", "channel_name": "pulse1"})}}} + + upgraded = update(data) + + assert _first_command(upgraded) == {"voice_id": "a"} + + def test_leaves_note_off_commands_untouched(self) -> None: + data = {"song": {"channels": {"pulse1": _pool_with_row({})}}} + + assert _first_command(update(data)) == {} + + def test_leaves_the_input_untouched(self) -> None: + data = {SAMPLES: [{"id": "a", "name": "Lead", "reconstruction_id": "r"}]} + + update(data) + + assert SAMPLES in data + + def test_document_without_samples_or_a_song_stays_the_same_shape(self) -> None: + data = {"format_version": "1.1"} + + assert update(data) == data diff --git a/tests/unit/sampletones_core/compatibility/test_json.py b/tests/unit/sampletones_core/compatibility/test_json.py index a586e423b..d739902d8 100644 --- a/tests/unit/sampletones_core/compatibility/test_json.py +++ b/tests/unit/sampletones_core/compatibility/test_json.py @@ -34,13 +34,7 @@ def test_project_upgrade_renames_channel_fields_and_stamps(self) -> None: "channels": { "pulse1": { "generator": "pulse1", - "patterns": { - "0": { - "rows": { - "0": {"command": {"sample_id": "s", "generator_name": "pulse1"}}, - } - } - }, + "patterns": {"0": {"rows": [{"command": {"sample_id": "s", "generator_name": "pulse1"}}]}}, } } }, @@ -54,6 +48,5 @@ def test_project_upgrade_renames_channel_fields_and_stamps(self) -> None: channel = data["song"]["channels"]["pulse1"] assert channel["name"] == "pulse1" assert "generator" not in channel - command = channel["patterns"]["0"]["rows"]["0"]["command"] - assert command["channel_name"] == "pulse1" - assert "generator_name" not in command + command = channel["patterns"]["0"]["rows"][0]["command"] + assert command == {"voice_id": "s"} diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index 6d006e365..52effc096 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -4,9 +4,9 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.slices import iterate_sample_slices -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.sample import Sample from sampletones_core.structures import IdentifiedCollection from tests.suite.sequencer import sample_reconstruction @@ -17,7 +17,7 @@ def _project(samples: Sequence[Sample]) -> Project: collection.append(sample) project = Project.create(title="Slices", author="Tester", settings=ProjectSettings()) - project.samples = collection + project.voices = collection return project diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index 3ca11b7c3..ec285f6ac 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -33,15 +33,15 @@ from sampletones_core.instructions.implementation.pulse import PulseInstruction from sampletones_core.instructions.implementation.triangle import TriangleInstruction from sampletones_core.instructions.instruction import Instruction -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection from tests.suite.stems import single_entry_stems_data @@ -104,26 +104,26 @@ def bass_fixture() -> Sample: @pytest.fixture(name="source") def source_fixture(lead: Sample, bass: Sample) -> Project: - samples: IdentifiedCollection[Sample] = IdentifiedCollection() + voices: IdentifiedCollection[Sample] = IdentifiedCollection() for sample in (lead, bass): - samples.append(sample) + voices.append(sample) pulse_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] pulse_rows[TRIGGER_ROW] = Row( - command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE1), + command=NoteOn(voice_id=lead.id), transpose=0, volume=ROW_VOLUME, ) pulse_rows[NOTE_OFF_ROW] = Row(command=NoteOff()) pulse_rows[TRANSPOSED_ROW] = Row( - command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE1), + command=NoteOn(voice_id=lead.id), transpose=TRANSPOSE, ) pulse_rows[SILENCED_ROW] = Row(volume=SILENT_VOLUME) triangle_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] triangle_rows[TRIGGER_ROW] = Row( - command=Instrument(sample_id=bass.id, channel_name=ChannelName.TRIANGLE), + command=NoteOn(voice_id=bass.id), transpose=0, ) @@ -139,7 +139,7 @@ def source_fixture(lead: Sample, bass: Sample) -> Project: ] project = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) - project.samples = samples + project.voices = voices project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) return project @@ -320,12 +320,12 @@ def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: class TestAnUnbuildableRow: def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] - rows[TRIGGER_ROW] = Row(command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE2)) + rows[TRIGGER_ROW] = Row(command=NoteOn(voice_id=lead.id)) source.song.channels[ChannelName.PULSE2] = Channel( name=ChannelName.PULSE2, patterns={0: Pattern(rows=rows)}, ) source.song.order[0][ChannelName.PULSE2] = 0 - with pytest.raises(ValueError, match="has no instrument"): + with pytest.raises(ValueError, match="with no instrument"): project_to_bitphase(source) diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index d835ad8e5..3d422d475 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -11,15 +11,16 @@ from sampletones_core.instructions.implementation.pulse import PulseInstruction from sampletones_core.instructions.implementation.triangle import TriangleInstruction from sampletones_core.instructions.instruction import Instruction -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.song import Song +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection from tests.suite.stems import single_entry_stems_data @@ -50,7 +51,7 @@ def pulse_sample(name: str, pitch: int, *, loop: bool = False) -> Sample: return Sample( name=name, reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}), - loop=loop, + loop_point=WHOLE_LOOP_POINT if loop else None, ) @@ -86,13 +87,13 @@ def project_fixture() -> ProjectFixture: drum = noise_sample("drum", period=4) bell = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - samples: IdentifiedCollection[Sample] = IdentifiedCollection() + voices: IdentifiedCollection[Sample] = IdentifiedCollection() for sample in (lead, pad, drum, bell): - samples.append(sample) + voices.append(sample) pulse_rows: List[Row] = [Row() for _ in range(8)] pulse_rows[0] = Row( - command=Instrument(sample_id=lead.id, channel_name=ChannelName.PULSE1), + command=NoteOn(voice_id=lead.id), transpose=0, volume=10, ) @@ -101,7 +102,7 @@ def project_fixture() -> ProjectFixture: noise_rows: List[Row] = [Row() for _ in range(8)] noise_rows[0] = Row( - command=Instrument(sample_id=drum.id, channel_name=ChannelName.NOISE), + command=NoteOn(voice_id=drum.id), transpose=0, volume=15, ) @@ -130,7 +131,7 @@ def project_fixture() -> ProjectFixture: project = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) project.info.comment = "a comment" - project.samples = samples + project.voices = voices project.song = song return ProjectFixture(project=project, lead=lead, pad=pad, drum=drum, bell=bell) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index 768790831..8b99912f6 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -28,8 +28,8 @@ SequenceKind, ) from sampletones_core.instructions.implementation.pulse import PulseInstruction -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project +from sampletones_core.project.voices.sample import Sample from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction @@ -94,7 +94,7 @@ def test_exceeding_max_instruments_raises(self) -> None: for number in range(MAX_INSTRUMENTS + 1): instructions = [PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0)] reconstruction = build_reconstruction({ChannelName.PULSE1: instructions}) - project.samples.append(Sample(name=f"sample-{number}", reconstruction=reconstruction)) + project.voices.append(Sample(name=f"sample-{number}", reconstruction=reconstruction)) with pytest.raises(ValueError): build_instrument_table(project) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index b19ac276d..cd61c61b0 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -161,13 +161,13 @@ class TestReconstructionFootprints: def test_one_entry_per_playing_channel(self) -> None: """The sample holds every channel; the two that play are the two an export writes.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) + footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loops) assert set(footprints) == {ChannelName.PULSE1, ChannelName.TRIANGLE} def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None: """Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) + footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loops) pulse = footprints[ChannelName.PULSE1] triangle = footprints[ChannelName.TRIANGLE] assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES diff --git a/tests/unit/sampletones_core/performance/test_rows.py b/tests/unit/sampletones_core/performance/test_rows.py index d5537974b..839ad9e24 100644 --- a/tests/unit/sampletones_core/performance/test_rows.py +++ b/tests/unit/sampletones_core/performance/test_rows.py @@ -6,11 +6,11 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.performance import ChannelPerformance, apply_row, resolve_row -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -25,7 +25,7 @@ def _song() -> Song: song = Song.empty(ROWS_PER_PATTERN) pattern = song.channels[ChannelName.PULSE1].patterns[0] pattern.rows[SOUNDING_ROW] = Row( - command=Instrument(sample_id=SAMPLE_ID, channel_name=ChannelName.PULSE1), + command=NoteOn(voice_id=SAMPLE_ID), ) return song @@ -97,15 +97,15 @@ class TestApplyRow(BaseTestSuite): class TestCase(BaseRegularTestCase): expected: bool row: Row - sample_id: Optional[str] + voice_id: Optional[str] transpose: int volume: int test_cases: Tuple["TestApplyRow.TestCase", ...] = ( TestCase( label="a note column with no modifiers takes the defaults", - row=Row(command=Instrument(sample_id=SAMPLE_ID, channel_name=ChannelName.PULSE1)), - sample_id=SAMPLE_ID, + row=Row(command=NoteOn(voice_id=SAMPLE_ID)), + voice_id=SAMPLE_ID, transpose=0, volume=MAX_VOLUME, expected=True, @@ -113,11 +113,11 @@ class TestCase(BaseRegularTestCase): TestCase( label="a note column takes the modifiers the row states", row=Row( - command=Instrument(sample_id=SAMPLE_ID, channel_name=ChannelName.PULSE1), + command=NoteOn(voice_id=SAMPLE_ID), transpose=5, volume=8, ), - sample_id=SAMPLE_ID, + voice_id=SAMPLE_ID, transpose=5, volume=8, expected=True, @@ -125,7 +125,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a note off silences the channel", row=Row(command=NoteOff()), - sample_id=None, + voice_id=None, transpose=3, volume=8, expected=True, @@ -133,7 +133,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="an empty row leaves everything as it stands", row=Row(), - sample_id=ANOTHER_SAMPLE_ID, + voice_id=ANOTHER_SAMPLE_ID, transpose=3, volume=8, expected=False, @@ -141,7 +141,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a modifier row bends the note already sounding", row=Row(transpose=-2, volume=4), - sample_id=ANOTHER_SAMPLE_ID, + voice_id=ANOTHER_SAMPLE_ID, transpose=-2, volume=4, expected=False, @@ -151,7 +151,7 @@ class TestCase(BaseRegularTestCase): @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_what_the_channel_carries_after_the_row(self, test_case: TestCase) -> None: performance = ChannelPerformance( - sample_id=ANOTHER_SAMPLE_ID, + voice_id=ANOTHER_SAMPLE_ID, tick_index=6, transpose=3, volume=8, @@ -160,7 +160,7 @@ def test_what_the_channel_carries_after_the_row(self, test_case: TestCase) -> No retriggered = apply_row(performance, test_case.row) assert retriggered is test_case.expected - assert performance.sample_id == test_case.sample_id + assert performance.voice_id == test_case.voice_id assert performance.transpose == test_case.transpose assert performance.volume == test_case.volume @@ -170,7 +170,7 @@ def test_the_tick_index_returns_to_the_start_exactly_where_the_note_does( test_case: TestCase, ) -> None: """A row that starts the note over is a row that starts its envelopes over.""" - performance = ChannelPerformance(sample_id=ANOTHER_SAMPLE_ID, tick_index=6) + performance = ChannelPerformance(voice_id=ANOTHER_SAMPLE_ID, tick_index=6) retriggered = apply_row(performance, test_case.row) diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py index 042d0ab7f..1ddd34486 100644 --- a/tests/unit/sampletones_core/project/patterns/test_channel.py +++ b/tests/unit/sampletones_core/project/patterns/test_channel.py @@ -1,7 +1,7 @@ from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.note_on import NoteOn ROWS_PER_PATTERN = 16 @@ -21,7 +21,7 @@ def test_clone_pattern_copies_rows_with_new_identity(self) -> None: channel = _channel() source = channel.patterns[0] source.rows[0] = Row( - instrument=Instrument(sample_id="abc", channel_name=ChannelName.PULSE1), + instrument=NoteOn(voice_id="abc"), volume=10, ) diff --git a/tests/unit/sampletones_core/project/patterns/test_pattern.py b/tests/unit/sampletones_core/project/patterns/test_pattern.py index 0c79cbe33..35230f9c6 100644 --- a/tests/unit/sampletones_core/project/patterns/test_pattern.py +++ b/tests/unit/sampletones_core/project/patterns/test_pattern.py @@ -1,9 +1,9 @@ import pytest from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.note_on import NoteOn _LENGTH = 4 @@ -13,7 +13,7 @@ def _empty_pattern() -> Pattern: def _row_with_instrument() -> Row: - return Row(command=Instrument(sample_id="x", channel_name=ChannelName.PULSE1)) + return Row(command=NoteOn(voice_id="x")) class TestRowIsEmpty: @@ -21,7 +21,7 @@ def test_default_row_is_empty(self) -> None: assert Row().is_empty() def test_row_with_instrument_is_not_empty(self) -> None: - assert not Row(command=Instrument(sample_id="x", channel_name=ChannelName.PULSE1)).is_empty() + assert not Row(command=NoteOn(voice_id="x")).is_empty() def test_row_with_transpose_is_not_empty(self) -> None: assert not Row(transpose=0).is_empty() @@ -32,13 +32,13 @@ def test_row_with_volume_is_not_empty(self) -> None: class TestRowReferencesSample: def test_default_row_references_no_sample(self) -> None: - assert not Row().references_sample("x") + assert not Row().references_voice("x") def test_row_references_its_instrument_sample(self) -> None: - assert _row_with_instrument().references_sample("x") + assert _row_with_instrument().references_voice("x") def test_row_does_not_reference_a_different_sample(self) -> None: - assert not _row_with_instrument().references_sample("y") + assert not _row_with_instrument().references_voice("y") class TestPatternIsEmpty: diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 3dca432b6..39aec8671 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -8,10 +8,10 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.data import Metadata from sampletones_core.project.container import ProjectContainer -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from sampletones_shared.constants.project import ( PROJECT_DOCUMENT_NAME, @@ -52,7 +52,7 @@ def _populated_project( first = Sample(name="lead", reconstruction=reconstruction_factory()) second_reconstruction = first.reconstruction if shared else reconstruction_factory() second = Sample(name="bass", reconstruction=second_reconstruction) - project.samples.extend([first, second]) + project.voices.extend([first, second]) song = project.song channel = song[ChannelName.PULSE1] @@ -60,10 +60,7 @@ def _populated_project( pattern.name = "intro" pattern.rows[0] = Row( transpose=0, - command=Instrument( - sample_id=first.id, - channel_name=ChannelName.PULSE1, - ), + command=NoteOn(voice_id=first.id), volume=15, ) @@ -90,8 +87,8 @@ def test_full_round_trip( assert loaded.metadata == project.metadata assert loaded.info.title == project.info.title assert loaded.settings.tempo == 128 - assert [sample.id for sample in loaded.samples] == [sample.id for sample in project.samples] - assert [sample.name for sample in loaded.samples] == ["lead", "bass"] + assert [sample.id for sample in loaded.voices] == [sample.id for sample in project.voices] + assert [sample.name for sample in loaded.voices] == ["lead", "bass"] loaded_song = loaded.song assert loaded_song.order == project.song.order @@ -104,7 +101,7 @@ def test_full_round_trip( row = first_pattern.rows[0] assert row.transpose == 0 assert row.command is not None - assert row.command.sample_id == loaded.samples[0].id + assert row.command.voice_id == loaded.voices[0].id def test_references_resolve_after_load( self, @@ -120,7 +117,7 @@ def test_references_resolve_after_load( channel = loaded_song[ChannelName.PULSE1] index_at_0 = loaded_song.order[0].get(ChannelName.PULSE1) row = channel.pattern(index_at_0).rows[0] - assert loaded.sample(row.command.sample_id) is loaded.samples[0] + assert loaded.voice(row.command.voice_id) is loaded.voices[0] index_at_2 = loaded_song.order[2].get(ChannelName.PULSE1) assert channel.pattern(index_at_0) is channel.pattern(index_at_2) @@ -182,7 +179,7 @@ def test_round_trip_without_instruments(self, tmp_path: Path) -> None: ProjectContainer.save(project, path) loaded = ProjectContainer.load(path) - assert len(loaded.samples) == 0 + assert len(loaded.voices) == 0 assert set(loaded.song.channels) == set(ChannelName.items()) with zipfile.ZipFile(path, "r") as archive: @@ -310,7 +307,7 @@ def test_incompatible_embedded_reconstruction_version_rejected( ) -> None: """A project carrying a reconstruction from another build is refused as it opens.""" project = _populated_project(reconstruction_factory) - project.samples[0].reconstruction = project.samples[0].reconstruction.model_copy( + project.voices[0].reconstruction = project.voices[0].reconstruction.model_copy( update={"metadata": Metadata(reconstruction_data_version="0.0")}, ) path = tmp_path / "demo.stp" diff --git a/tests/unit/sampletones_core/project/test_models.py b/tests/unit/sampletones_core/project/test_models.py index 40038932a..a52c004d4 100644 --- a/tests/unit/sampletones_core/project/test_models.py +++ b/tests/unit/sampletones_core/project/test_models.py @@ -3,42 +3,40 @@ import pytest from pydantic import ValidationError -from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase -def _instrument() -> Instrument: - return Instrument( - sample_id="abc123", - channel_name=ChannelName.TRIANGLE, - ) +def _note_on() -> NoteOn: + return NoteOn(voice_id="abc123") -class TestInstrument: +class TestNoteOn: def test_is_frozen(self) -> None: - instrument = _instrument() + note_on = _note_on() with pytest.raises(ValidationError): - instrument.sample_id = "other" # type: ignore[misc] + note_on.voice_id = "other" # type: ignore[misc] def test_value_equality_and_hash(self) -> None: - first = _instrument() - second = _instrument() + first = _note_on() + second = _note_on() assert first == second assert hash(first) == hash(second) - def test_distinct_slices_differ(self) -> None: - triangle = Instrument(sample_id="abc", channel_name=ChannelName.TRIANGLE) - noise = Instrument(sample_id="abc", channel_name=ChannelName.NOISE) - assert triangle != noise + def test_distinct_voices_differ(self) -> None: + assert NoteOn(voice_id="abc") != NoteOn(voice_id="def") + + def test_names_the_voice_alone(self) -> None: + with pytest.raises(ValidationError): + NoteOn.model_validate({"voice_id": "abc", "channel_name": "triangle"}) def test_round_trip(self) -> None: - instrument = _instrument() - restored = Instrument.model_validate(instrument.model_dump()) - assert restored == instrument + note_on = _note_on() + restored = NoteOn.model_validate(note_on.model_dump()) + assert restored == note_on class TestRowDefaults: @@ -66,7 +64,7 @@ def label(self) -> str: test_cases = ( TestCase(expected=Row()), TestCase(expected=Row(transpose=0, volume=15)), - TestCase(expected=Row(transpose=12, command=_instrument(), volume=8)), + TestCase(expected=Row(transpose=12, command=_note_on(), volume=8)), TestCase(expected=Row(command=NoteOff())), ) diff --git a/tests/unit/sampletones_core/project/test_serialization.py b/tests/unit/sampletones_core/project/test_serialization.py index b82424a05..a00e34718 100644 --- a/tests/unit/sampletones_core/project/test_serialization.py +++ b/tests/unit/sampletones_core/project/test_serialization.py @@ -2,11 +2,11 @@ from pydantic import ValidationError from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn from sampletones_shared.constants.project import ( MAX_ROWS_PER_PATTERN, MIN_ROWS_PER_PATTERN, @@ -18,10 +18,7 @@ def _pattern_with_instrument() -> Pattern: pattern.rows[0] = Row( transpose=0, volume=15, - instrument=Instrument( - sample_id="abc123", - channel_name=ChannelName.PULSE1, - ), + instrument=NoteOn(voice_id="abc123"), ) return pattern diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index a8e005947..8438326ba 100644 --- a/tests/unit/sampletones_core/project/test_song.py +++ b/tests/unit/sampletones_core/project/test_song.py @@ -2,9 +2,9 @@ from pydantic import ValidationError from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn from sampletones_shared.constants.project import ( DEFAULT_ROWS_PER_PATTERN, MAX_ROWS_PER_PATTERN, @@ -18,10 +18,10 @@ def _song(rows_per_pattern: int = _ROWS) -> Song: return Song.empty(rows_per_pattern) -def _place_instrument(song: Song, channel: ChannelName, sample_id: str, row_index: int = 0) -> None: +def _place_instrument(song: Song, channel: ChannelName, voice_id: str, row_index: int = 0) -> None: pattern = song.pattern(channel, 0) assert pattern is not None - pattern.rows[row_index] = Row(command=Instrument(sample_id=sample_id, channel_name=channel)) + pattern.rows[row_index] = Row(command=NoteOn(voice_id=voice_id)) class TestSongEmpty: @@ -355,17 +355,17 @@ def test_remove_nonexistent_pattern_raises(self) -> None: class TestSongReferencesSample: def test_false_when_no_row_references_any_sample(self) -> None: - assert _song().references_sample("abc") is False + assert _song().references_voice("abc") is False def test_true_when_a_row_references_the_sample(self) -> None: song = _song() _place_instrument(song, ChannelName.PULSE1, "abc") - assert song.references_sample("abc") is True + assert song.references_voice("abc") is True - def test_false_for_a_different_sample_id(self) -> None: + def test_false_for_a_different_voice_id(self) -> None: song = _song() _place_instrument(song, ChannelName.PULSE1, "abc") - assert song.references_sample("xyz") is False + assert song.references_voice("xyz") is False class TestSongClearSampleReferences: @@ -374,7 +374,7 @@ def test_clears_only_rows_referencing_the_target(self) -> None: _place_instrument(song, ChannelName.PULSE1, "abc", row_index=0) _place_instrument(song, ChannelName.PULSE1, "keep", row_index=1) - song.clear_sample_references("abc") + song.clear_voice_references("abc") pattern = song.pattern(ChannelName.PULSE1, 0) assert pattern is not None @@ -386,15 +386,15 @@ def test_clears_references_across_all_channels(self) -> None: _place_instrument(song, ChannelName.PULSE1, "abc") _place_instrument(song, ChannelName.TRIANGLE, "abc") - song.clear_sample_references("abc") + song.clear_voice_references("abc") - assert song.references_sample("abc") is False + assert song.references_voice("abc") is False def test_leaves_rows_untouched_when_sample_absent(self) -> None: song = _song() _place_instrument(song, ChannelName.PULSE1, "abc") - song.clear_sample_references("missing") + song.clear_voice_references("missing") pattern = song.pattern(ChannelName.PULSE1, 0) assert pattern is not None diff --git a/tests/unit/sampletones_core/project/test_structure.py b/tests/unit/sampletones_core/project/test_structure.py index 6066a6b28..630634b29 100644 --- a/tests/unit/sampletones_core/project/test_structure.py +++ b/tests/unit/sampletones_core/project/test_structure.py @@ -3,13 +3,13 @@ from unittest.mock import Mock from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from tests.suite.scenario import BaseTestScenario, ScenarioStep @@ -53,22 +53,22 @@ class TestProject: def test_create(self) -> None: project = Project.create(title="Demo") assert project.info.title == "Demo" - assert len(project.samples) == 0 + assert len(project.voices) == 0 assert set(project.song.channels) == set(ChannelName.items()) def test_instrument_resolution(self) -> None: project = Project.create() sample = _sample("lead") - project.samples.append(sample) - assert project.sample(sample.id) is sample - assert project.sample("missing") is None + project.voices.append(sample) + assert project.voice(sample.id) is sample + assert project.voice("missing") is None @dataclass class SampleContext: project: Project sample: Sample - instrument: Instrument + instrument: NoteOn resolved: Dict[str, Sample] = field(default_factory=dict) @@ -80,22 +80,22 @@ def _build_instrument_context(self) -> SampleContext: project = Project.create() first = _sample("first") second = _sample("second") - project.samples.extend([first, second]) - instrument = Instrument(sample_id=first.id, channel_name=ChannelName.PULSE1) + project.voices.extend([first, second]) + instrument = NoteOn(voice_id=first.id) return SampleContext(project=project, sample=first, instrument=instrument) def test_instrument_survives_instrument_reorder(self) -> None: def check_before(context: SampleContext) -> None: - assert context.project.samples.index(context.sample) == 0 - context.resolved["before"] = context.project.sample(context.instrument.sample_id) + assert context.project.voices.index(context.sample) == 0 + context.resolved["before"] = context.project.voice(context.instrument.voice_id) def reorder(context: SampleContext) -> None: - moved = context.project.samples.pop(0) - context.project.samples.append(moved) - assert context.project.samples.index(context.sample) == 1 + moved = context.project.voices.pop(0) + context.project.voices.append(moved) + assert context.project.voices.index(context.sample) == 1 def check_after(context: SampleContext) -> None: - resolved = context.project.sample(context.instrument.sample_id) + resolved = context.project.voice(context.instrument.voice_id) assert resolved is context.sample assert resolved is context.resolved["before"] diff --git a/tests/unit/sampletones_core/project/test_tuning.py b/tests/unit/sampletones_core/project/test_tuning.py index 9d494d790..1e422331d 100644 --- a/tests/unit/sampletones_core/project/test_tuning.py +++ b/tests/unit/sampletones_core/project/test_tuning.py @@ -2,9 +2,9 @@ import pytest -from sampletones_core.project.instruments.sample import Sample from sampletones_core.project.project import Project from sampletones_core.project.tuning import UNTUNED_PROJECT, tuning_from_project +from sampletones_core.project.voices.sample import Sample from sampletones_shared.music import Tuning from tests.suite.performance import ( make_pulse_reconstruction, @@ -31,11 +31,11 @@ class TestTheTuningAProjectSounds: def test_a_projects_sample_states_the_tuning(self) -> None: project = sampled_project() - assert tuning_from_project(project) == project.samples[0].reconstruction.config.tuning + assert tuning_from_project(project) == project.voices[0].reconstruction.config.tuning def test_samples_agreeing_state_the_tuning_they_agree_on(self) -> None: project = sampled_project() - project.samples.append( + project.voices.append( Sample( name="second", reconstruction=make_triangle_reconstruction(pitch=45, count=2), @@ -50,7 +50,7 @@ def test_a_project_holding_no_samples_sounds_the_default_tuning(self) -> None: def test_samples_that_disagree_are_refused(self) -> None: """One timer table sounds one tuning, so a project holding two of them names both.""" project = sampled_project() - project.samples.append( + project.voices.append( Sample( name="baroque", reconstruction=retuned_reconstruction( diff --git a/tests/unit/sampletones_core/project/instruments/__init__.py b/tests/unit/sampletones_core/project/voices/__init__.py similarity index 100% rename from tests/unit/sampletones_core/project/instruments/__init__.py rename to tests/unit/sampletones_core/project/voices/__init__.py diff --git a/tests/unit/sampletones_core/project/instruments/test_sample.py b/tests/unit/sampletones_core/project/voices/test_sample.py similarity index 78% rename from tests/unit/sampletones_core/project/instruments/test_sample.py rename to tests/unit/sampletones_core/project/voices/test_sample.py index 3d5cab3bb..5c45e46bd 100644 --- a/tests/unit/sampletones_core/project/instruments/test_sample.py +++ b/tests/unit/sampletones_core/project/voices/test_sample.py @@ -1,6 +1,7 @@ from unittest.mock import Mock -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.sample import Sample class TestSampleClone: @@ -9,10 +10,10 @@ def test_clone_gets_a_fresh_id(self) -> None: assert sample.clone().id != sample.id def test_clone_carries_name_and_loop(self) -> None: - sample = Sample(name="lead", reconstruction=Mock(), loop=True) + sample = Sample(name="lead", reconstruction=Mock(), loop_point=WHOLE_LOOP_POINT) clone = sample.clone() assert clone.name == "lead" - assert clone.loop is True + assert clone.loop_point == WHOLE_LOOP_POINT def test_clone_deep_copies_the_reconstruction(self) -> None: reconstruction = Mock() diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py index d1470478c..bdf8b284a 100644 --- a/tests/unit/sampletones_core/utils/test_display.py +++ b/tests/unit/sampletones_core/utils/test_display.py @@ -5,17 +5,17 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.project import Project -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample from sampletones_core.utils.display import ( NOTE_BLANK, NOTE_OFF, display_command, display_id, - display_sample, - display_sample_label, display_transpose, + display_voice, + display_voice_label, display_volume, ) @@ -23,7 +23,7 @@ def _project_with_samples(count: int) -> Tuple[Project, List[Sample]]: project = Project.create() samples = [Sample(name=f"i{index}", reconstruction=Mock()) for index in range(count)] - project.samples.extend(samples) + project.voices.extend(samples) return project, samples @@ -31,16 +31,16 @@ class TestDisplaySamples: def test_present_shows_index(self) -> None: project, samples = _project_with_samples(3) assert ( - display_sample( - samples=project.samples, - sample_id=samples[0].id, + display_voice( + voices=project.voices, + voice_id=samples[0].id, ) == "00" ) assert ( - display_sample( - samples=project.samples, - sample_id=samples[2].id, + display_voice( + voices=project.voices, + voice_id=samples[2].id, ) == "02" ) @@ -48,16 +48,16 @@ def test_present_shows_index(self) -> None: def test_missing_and_none_are_placeholder(self) -> None: project, _ = _project_with_samples(1) assert ( - display_sample( - samples=project.samples, - sample_id="missing", + display_voice( + voices=project.voices, + voice_id="missing", ) == ".." ) assert ( - display_sample( - samples=project.samples, - sample_id=None, + display_voice( + voices=project.voices, + voice_id=None, ) == ".." ) @@ -65,11 +65,11 @@ def test_missing_and_none_are_placeholder(self) -> None: def test_index_follows_reorder(self) -> None: project, samples = _project_with_samples(3) first = samples[0] - project.samples.append(project.samples.pop(0)) + project.voices.append(project.voices.pop(0)) assert ( - display_sample( - samples=project.samples, - sample_id=first.id, + display_voice( + voices=project.voices, + voice_id=first.id, ) == "02" ) @@ -86,22 +86,19 @@ def test_none_is_placeholder(self) -> None: class TestDisplaySampleLabel: def test_combines_hex_index_and_name(self) -> None: - assert display_sample_label(0, "Bass") == "00: Bass" + assert display_voice_label(0, "Bass") == "00: Bass" def test_index_is_hexadecimal(self) -> None: - assert display_sample_label(26, "Lead") == "1A: Lead" + assert display_voice_label(26, "Lead") == "1A: Lead" class TestDisplayCommand: def test_resolves_referenced_instrument(self) -> None: project, samples = _project_with_samples(2) - instrument = Instrument( - sample_id=samples[1].id, - channel_name=ChannelName.PULSE1, - ) + instrument = NoteOn(voice_id=samples[1].id) assert ( display_command( - samples=project.samples, + voices=project.voices, command=instrument, ) == "01" @@ -111,7 +108,7 @@ def test_none_is_placeholder(self) -> None: project, _ = _project_with_samples(1) assert ( display_command( - samples=project.samples, + voices=project.voices, command=None, ) == ".." @@ -121,7 +118,7 @@ def test_note_off_renders_dashes(self) -> None: project, _ = _project_with_samples(1) assert ( display_command( - samples=project.samples, + voices=project.voices, command=NoteOff(), ) == NOTE_OFF diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py index 1c20db496..5eef3099c 100644 --- a/tests/unit/sampletones_player/compression/test_seeds.py +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -39,5 +39,5 @@ def test_a_project_holding_no_sample_offers_nothing(self) -> None: make_pulse_reconstruction(count=SOUNDING_TICKS), rows_per_pattern=ROWS_PER_PATTERN, ) - project.samples.clear() + project.voices.clear() assert phrases_from_project(project, TUNING) == () From 91b166c07d028dccc78df4fbdf055daa4beba8e8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 03:19:54 +0200 Subject: [PATCH 073/142] Unified: how a channel reads a voice into one reading --- .../playback/synthesizer/synthesizer.py | 30 ++---- src/sampletones_core/exporters/slices.py | 38 ++++---- .../formats/bitphase/builder.py | 60 ++++++------ .../formats/famitracker/builder.py | 16 ++-- src/sampletones_core/performance/__init__.py | 4 +- src/sampletones_core/performance/song.py | 21 ++--- src/sampletones_core/performance/ticks.py | 30 ++---- src/sampletones_core/performance/voice.py | 92 +++++++++++++------ src/sampletones_player/compression/seeds.py | 8 +- .../sampletones_core/exporters/test_slices.py | 12 +-- .../performance/test_ticks.py | 84 +++++++++++------ .../performance/test_voice.py | 10 +- 12 files changed, 222 insertions(+), 183 deletions(-) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 22a9511cd..a924b6326 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -1,5 +1,5 @@ from dataclasses import replace -from typing import Callable, FrozenSet, List, Optional, Tuple +from typing import Callable, FrozenSet, Optional, Tuple import numpy as np @@ -7,8 +7,7 @@ from sampletones_core.audio import clip_audio_inplace, silence from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.instructions import InstructionUnion -from sampletones_core.performance import SampleVoice, apply_row, resolve_row, sound_tick +from sampletones_core.performance import VoiceReading, apply_row, resolve_row, sound_tick from sampletones_core.project import Project from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition @@ -216,26 +215,20 @@ def _synthesize_ticks( channel_name: ChannelName, frames: RowFrames, ) -> np.ndarray: - sample = project.voice(voice_id) - if sample is None: + voice = project.voice(voice_id) + reading = VoiceReading.read(voice, channel_name) if voice is not None else None + if reading is None: return silence(frames.total) - instructions = sample.reconstruction.instructions[channel_name] - if not instructions: - return silence(frames.total) - - voice = SampleVoice.read(sample.reconstruction, channel_name) output = silence(frames.total) silence_frame = silence(frames.longest) for tick, frame_length in enumerate(frames.lengths): frame = self._synthesize_tick( state, - instructions, + reading, silence_frame[:frame_length], - sample.loops, frame_length, - voice, ) output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame @@ -244,18 +237,11 @@ def _synthesize_ticks( def _synthesize_tick( self, state: ChannelState, - instructions: List[InstructionUnion], + reading: VoiceReading, silence_frame: np.ndarray, - loop: bool, frame_length: int, - voice: SampleVoice, ) -> np.ndarray: - instruction = sound_tick( - state.performance, - instructions, - loop=loop, - voice=voice, - ) + instruction = sound_tick(state.performance, reading) if instruction is None: return silence_frame diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 00f9797ae..5bbeed96e 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -12,7 +12,7 @@ @dataclass(frozen=True) class InstrumentSlot: - """Where a sample's channel slice landed in the instrument table.""" + """Where a voice's channel slice landed in the instrument table.""" index: int initial_pitch: int @@ -22,30 +22,30 @@ class InstrumentSlot: @dataclass(frozen=True) -class SampleSlice: - """One channel slice of a project sample, numbered for the instrument table. +class VoiceSlice: + """One channel slice of a project voice, numbered for the instrument table. Attributes: index: Position the slice takes in the exported instrument table. - sample: The sample whose reconstruction the slice came from. + voice: The voice the slice came from. channel: The NES channel the slice covers. features: The per-dimension envelopes describing the slice. """ index: int - sample: Sample + voice: Sample channel: ChannelName features: Features @property def instrument_name(self) -> str: """The exported instrument's name, naming both its sample and its channel.""" - return instrument_slice_name(self.sample.name, self.channel) + return instrument_slice_name(self.voice.name, self.channel) @property def key(self) -> Tuple[str, ChannelName]: """The identity a pattern row references the slice by.""" - return (self.sample.id, self.channel) + return (self.voice.id, self.channel) @property def slot(self) -> InstrumentSlot: @@ -56,31 +56,31 @@ def slot(self) -> InstrumentSlot: ) -def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: - """Walks every channel slice of every sample in instrument-table order. +def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: + """Walks every channel slice of every voice in instrument-table order. - A sample contributes one slice per channel that plays, so it yields one to four. Slices - are numbered in sample order, then channel order, which fixes the instrument numbering - every tracker format builds on. Each sample's features are exported once, so a caller - reads a reconstruction's envelopes at a single cost. + A voice contributes one slice per channel that plays, so a sample yields one to four. Slices + are numbered in voice order, then channel order, which fixes the instrument numbering every + tracker format builds on. Each voice's features are exported once, so a caller reads a + reconstruction's envelopes at a single cost. Args: - project: The project whose samples are exported. + project: The project whose voices are exported. Yields: - SampleSlice: Each slice alongside the index it takes in the instrument table. + VoiceSlice: Each slice alongside the index it takes in the instrument table. """ index = 0 - for sample in project.voices: - features_by_channel = sample.reconstruction.export() + for voice in project.voices: + features_by_channel = voice.reconstruction.export() for channel in ChannelName.items(): features = features_by_channel[channel] if not features.has_frames: continue - yield SampleSlice( + yield VoiceSlice( index=index, - sample=sample, + voice=voice, channel=channel, features=features, ) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 59080d470..152c882f9 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -4,7 +4,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import SILENT_VOLUME -from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.exporters.slices import iterate_voice_slices from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.formats.bitphase.envelopes import ( ChannelEnvelopes, @@ -81,7 +81,7 @@ @dataclass(frozen=True) -class Voice: +class SliceVoice: """One built instrument together with the table and the note that triggers it. Attributes: @@ -101,10 +101,10 @@ class Voice: ticks: int -VoiceTable = Dict[Tuple[str, ChannelName], Voice] +SliceVoiceTable = Dict[Tuple[str, ChannelName], SliceVoice] -def _build_voice( +def _build_slice_voice( index: int, name: str, channel: ChannelName, @@ -112,7 +112,7 @@ def _build_voice( envelopes: ChannelEnvelopes, *, maximum_table_id: int, -) -> Voice: +) -> SliceVoice: """Numbers one channel slice and packages it as an instrument-and-table pair. Instruments and tables are numbered alike, so a pattern cell names the same position @@ -131,7 +131,7 @@ def _build_voice( if table_id > maximum_table_id: raise ValueError(f"Document holds room for {maximum_table_id + 1} slice tables") - return Voice( + return SliceVoice( number=number, instrument=BitphaseInstrument( id=format_instrument_id(number), @@ -163,7 +163,7 @@ def _note_cell(channel_generator: ChannelName, pitch: int) -> NoteCell: return note_index_to_note_cell(pitch_to_note_index(pitch)) -def _trigger_row(voice: Voice, note: NoteCell, volume: int) -> BitphaseRow: +def _trigger_row(voice: SliceVoice, note: NoteCell, volume: int) -> BitphaseRow: return BitphaseRow( note=note, instrument=voice.number, @@ -210,7 +210,7 @@ def _build_song( ) -def _preview_length(voices: Sequence[Voice]) -> int: +def _preview_length(voices: Sequence[SliceVoice]) -> int: """Sizes the preview pattern so a full line of it covers the longest instrument.""" rows = math.ceil(max((voice.ticks for voice in voices), default=0) / PREVIEW_SPEED) return max( @@ -219,7 +219,7 @@ def _preview_length(voices: Sequence[Voice]) -> int: ) -def _preview_order(voices: Sequence[Voice], length: int) -> Tuple[int, ...]: +def _preview_order(voices: Sequence[SliceVoice], length: int) -> Tuple[int, ...]: """Spaces the trigger far enough apart for the longest instrument to play through. Every order position past the first plays a resting pattern, so an instrument that @@ -231,7 +231,7 @@ def _preview_order(voices: Sequence[Voice], length: int) -> Tuple[int, ...]: def _preview_patterns( - voices: Sequence[Voice], + voices: Sequence[SliceVoice], length: int, positions: int, ) -> Tuple[BitphasePattern, ...]: @@ -271,7 +271,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: ValueError: If the reconstruction holds more slices than Bitphase has room for. """ voices = [ - _build_voice( + _build_slice_voice( index, instrument.name, instrument.channel, @@ -328,31 +328,31 @@ def _build_voice_table( project: Project, *, maximum_table_id: int, -) -> Tuple[List[Voice], VoiceTable]: - voices: List[Voice] = [] - by_reference: VoiceTable = {} +) -> Tuple[List[SliceVoice], SliceVoiceTable]: + voices: List[SliceVoice] = [] + by_reference: SliceVoiceTable = {} - for sample_slice in iterate_sample_slices(project): + for voice_slice in iterate_voice_slices(project): envelopes = features_to_envelopes( - sample_slice.features, - sample_slice.channel, - loop=sample_slice.sample.loops, + voice_slice.features, + voice_slice.channel, + loop=voice_slice.voice.loops, ) - voice = _build_voice( - sample_slice.index, - sample_slice.instrument_name, - sample_slice.channel, - sample_slice.features.initial_pitch, + voice = _build_slice_voice( + voice_slice.index, + voice_slice.instrument_name, + voice_slice.channel, + voice_slice.features.initial_pitch, envelopes, maximum_table_id=maximum_table_id, ) voices.append(voice) - by_reference[sample_slice.key] = voice + by_reference[voice_slice.key] = voice return voices, by_reference -def _resolve_voice(reference: NoteOn, channel: ChannelName, voices: VoiceTable) -> Voice: +def _resolve_slice_voice(reference: NoteOn, channel: ChannelName, voices: SliceVoiceTable) -> SliceVoice: voice = voices.get((reference.voice_id, channel)) if voice is None: raise ValueError(f"Row references voice '{reference.voice_id}' on channel '{channel}' with no instrument") @@ -380,7 +380,7 @@ def _volume_column(volume: Optional[int]) -> int: def _row_cell( row: Row, channel_generator: ChannelName, - voices: VoiceTable, + voices: SliceVoiceTable, ) -> BitphaseRow: """Converts one tracker line to the Bitphase row that plays it. @@ -397,7 +397,7 @@ def _row_cell( volume=volume, ) case NoteOn() as reference: - voice = _resolve_voice(reference, channel_generator, voices) + voice = _resolve_slice_voice(reference, channel_generator, voices) pitch = voice.initial_pitch + (row.transpose or 0) cell = _trigger_row( voice, @@ -414,7 +414,7 @@ def _channel_rows( rows: Sequence[Row], length: int, channel: ChannelName, - voices: VoiceTable, + voices: SliceVoiceTable, ) -> List[BitphaseRow]: cells = [_row_cell(row, channel, voices) for row in rows[:length]] cells.extend(BitphaseRow() for _ in range(length - len(cells))) @@ -490,7 +490,7 @@ def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]: def _document_tables( - voices: Sequence[Voice], + voices: Sequence[SliceVoice], groove_table: Optional[BitphaseTable], ) -> Tuple[BitphaseTable, ...]: """Gathers the tables a document holds: one per slice, and the groove where it takes one.""" @@ -503,7 +503,7 @@ def _document_tables( def _project_patterns( project: Project, - voices: VoiceTable, + voices: SliceVoiceTable, groove_table: Optional[BitphaseTable], ) -> Tuple[BitphasePattern, ...]: """Flattens the song's per-channel arrangement into whole-pattern order positions. diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index d8d5d196e..fafdb9e39 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -5,7 +5,7 @@ from sampletones_core.exporters.slices import ( InstrumentSlot, InstrumentTable, - iterate_sample_slices, + iterate_voice_slices, ) from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.module import ( @@ -108,19 +108,19 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst instruments: List[Instrument2A03] = [] slots: InstrumentTable = {} - for sample_slice in iterate_sample_slices(project): - if sample_slice.index >= MAX_INSTRUMENTS: + for voice_slice in iterate_voice_slices(project): + if voice_slice.index >= MAX_INSTRUMENTS: raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments") instruments.append( build_instrument( - sample_slice.index, - sample_slice.instrument_name, - sample_slice.features, - loop=sample_slice.sample.loops, + voice_slice.index, + voice_slice.instrument_name, + voice_slice.features, + loop=voice_slice.voice.loops, ) ) - slots[sample_slice.key] = sample_slice.slot + slots[voice_slice.key] = voice_slice.slot return instruments, slots diff --git a/src/sampletones_core/performance/__init__.py b/src/sampletones_core/performance/__init__.py index b2072e2ae..4c9f8dedf 100644 --- a/src/sampletones_core/performance/__init__.py +++ b/src/sampletones_core/performance/__init__.py @@ -9,12 +9,12 @@ from .song import song_instructions from .state import ChannelPerformance from .ticks import sound_tick -from .voice import SampleVoice +from .voice import VoiceReading __all__ = [ "SILENT_WALK_REPORTER", "ChannelPerformance", - "SampleVoice", + "VoiceReading", "WalkProgress", "WalkReporter", "announce", diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index 43456f0ce..bb44a7305 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -11,7 +11,7 @@ from sampletones_core.performance.rows import apply_row, resolve_row from sampletones_core.performance.state import ChannelPerformance from sampletones_core.performance.ticks import sound_tick -from sampletones_core.performance.voice import SampleVoice +from sampletones_core.performance.voice import VoiceReading from sampletones_core.project.project import Project from sampletones_core.project.song_position import SongPosition from sampletones_core.project.voices.sample import Sample @@ -75,7 +75,7 @@ def song_instructions( def _channel_ticks( - sample: Optional[Sample], + voice: Optional[Sample], channel_name: ChannelName, performance: ChannelPerformance, ticks: int, @@ -87,7 +87,7 @@ def _channel_ticks( around them play on. Args: - sample: The sample the channel is sounding, or ``None`` while it rests. + voice: The voice the channel is sounding, or ``None`` while it rests. channel_name: The channel being sounded. performance: What the channel carries; its tick index moves on per sounded tick. ticks: The engine ticks the row lasts. @@ -96,22 +96,13 @@ def _channel_ticks( List[InstructionUnion]: One instruction per tick of the row. """ resting: InstructionUnion = CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction() - if sample is None: + reading = VoiceReading.read(voice, channel_name) if voice is not None else None + if reading is None: return [resting] * ticks - instructions = sample.reconstruction.instructions[channel_name] - if not instructions: - return [resting] * ticks - - voice = SampleVoice.read(sample.reconstruction, channel_name) sounded: List[InstructionUnion] = [] for _ in range(ticks): - instruction = sound_tick( - performance, - instructions, - loop=sample.loops, - voice=voice, - ) + instruction = sound_tick(performance, reading) sounded.append(resting if instruction is None else instruction) return sounded diff --git a/src/sampletones_core/performance/ticks.py b/src/sampletones_core/performance/ticks.py index 44732540b..c4a6ced30 100644 --- a/src/sampletones_core/performance/ticks.py +++ b/src/sampletones_core/performance/ticks.py @@ -1,47 +1,37 @@ -from typing import Optional, Sequence +from typing import Optional from sampletones_core.instructions import InstructionUnion from sampletones_core.performance.modifiers import apply_modifiers from sampletones_core.performance.state import ChannelPerformance -from sampletones_core.performance.voice import SampleVoice +from sampletones_core.performance.voice import VoiceReading def sound_tick( performance: ChannelPerformance, - instructions: Sequence[InstructionUnion], - *, - loop: bool, - voice: SampleVoice, + reading: VoiceReading, ) -> Optional[InstructionUnion]: """The instruction a channel sounds this tick, and the step onto the next one. - A looping sample wraps around its instructions, so it sustains for as long as rows keep it - sounding; a one-shot plays each of its instructions once and falls silent past the last. - Either way the channel moves on a tick, so a sample that has run out keeps counting and a - row starting a note lands it back at the beginning. + The channel moves on a tick whatever the reading answers, so a voice that has run out keeps + counting and a row starting a note lands it back at the beginning. Args: performance: What the channel carries; its tick index moves on. - instructions: The sounding sample's stream for this channel, holding at least one frame. - loop: Whether the sample repeats its instructions. - voice: The reading that fills in the dimensions the instrument leaves to the channel. + reading: How this channel reads the sounding voice. Returns: - Optional[InstructionUnion]: The instruction to sound, or ``None`` where the sample has + Optional[InstructionUnion]: The instruction to sound, or ``None`` where the voice has played out and the channel rests. """ index = performance.tick_index performance.tick_index += 1 - if loop: - instruction = instructions[index % len(instructions)] - elif index < len(instructions): - instruction = instructions[index] - else: + instruction = reading.at(index) + if instruction is None: return None return apply_modifiers( - voice.sound(instruction, performance.feature_values), + reading.sound(instruction, performance.feature_values), performance.transpose, performance.volume, ) diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index cb5dfde8d..20c473940 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -1,55 +1,95 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, Tuple +from typing import Dict, Optional, Sequence, Tuple from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, ExporterTypeUnion from sampletones_core.instructions import InstructionUnion -from sampletones_core.reconstructions import Reconstruction +from sampletones_core.project.voices.sample import Sample @dataclass(frozen=True) -class SampleVoice: - """How one channel reads a sample's frames. +class VoiceReading: + """How one channel reads a voice's frames. - A sample carries a frame per tick stating every dimension the channel reads, and the - reconstruction names which of those dimensions the instrument itself wrote. The rest are the - channel's own: the instrument leaves an empty envelope for them and the channel sounds them at - the value it holds, which is what clearing an envelope in the instruments panel means once the - sample is played in a song. + A voice carries a frame per tick stating every dimension the channel reads, and names which of + those dimensions it writes itself. The rest are the channel's own: the voice leaves an empty + envelope for them and the channel sounds them at the value it holds, which is what clearing an + envelope in the instruments panel means once the voice is played in a song. + + Reading a voice on a channel answers all a channel needs of it — the frames, the reference its + arpeggio is measured against, the dimensions it leaves behind, and where it repeats from — so + the song walk and the sequencer's renderer read one voice the same way. Attributes: exporter: The reading that turns this channel's frames into envelope values and back. - initial_pitch: Reference pitch the arpeggio values are measured against. - held_features: The dimensions the instrument leaves to the channel. + instructions: The frames the channel plays, one per tick. + reference: The pitch the arpeggio values are measured against. + held_features: The dimensions the voice leaves to the channel. + loop_point: The tick the frames repeat from, or ``None`` where they play once. """ exporter: ExporterTypeUnion - initial_pitch: int + instructions: Sequence[InstructionUnion] + reference: int held_features: Tuple[FeatureKey, ...] + loop_point: Optional[int] @classmethod def read( cls, - reconstruction: Reconstruction, + voice: Sample, channel_name: ChannelName, - ) -> SampleVoice: - """The voice one channel of ``reconstruction`` is played through. + ) -> Optional[VoiceReading]: + """The reading one channel plays ``voice`` through. Args: - reconstruction: The sample's reconstruction. - channel_name: The channel being sounded. + voice: The voice being sounded. + channel_name: The channel sounding it. Returns: - SampleVoice: The reading of that channel's frames. + Optional[VoiceReading]: The reading of that channel's frames, or ``None`` where the + voice describes no frame there and the channel rests. """ + reconstruction = voice.reconstruction + instructions = reconstruction.instructions[channel_name] + if not instructions: + return None + return cls( exporter=CHANNEL_TO_EXPORTER_MAP[channel_name], - initial_pitch=reconstruction.initial_pitches[channel_name], + instructions=instructions, + reference=reconstruction.initial_pitches[channel_name], held_features=reconstruction.held_features[channel_name], + loop_point=voice.loop_point, ) + def at(self, tick_index: int) -> Optional[InstructionUnion]: + """The frame standing at ``tick_index`` of a sounding note. + + A voice repeating from a loop point plays its opening once and then circles the frames from + that point on, so it sustains for as long as rows keep it sounding; one playing its frames + once falls silent past the last. A point beyond the frames this channel holds circles its + final frame, which is the value the channel would hold anyway. + + Args: + tick_index: How many ticks of the voice the channel has played. + + Returns: + Optional[InstructionUnion]: The frame to sound, or ``None`` where the voice has played + out and the channel rests. + """ + if tick_index < len(self.instructions): + return self.instructions[tick_index] + + if self.loop_point is None: + return None + + point = min(self.loop_point, len(self.instructions) - 1) + cycle = len(self.instructions) - point + return self.instructions[point + (tick_index - point) % cycle] + def sound( self, instruction: InstructionUnion, @@ -58,20 +98,20 @@ def sound( """The frame the channel sounds, once the dimensions it governs are filled in. ``feature_values`` is the channel's own, and this is where it moves: the dimensions the - frame states and the instrument writes are handed over to it, and every dimension the - frame plays is then read back out of it. So an instrument that writes a dimension sets - what the channel holds, and one that leaves it empty sounds at what the channel holds. + frame states and the voice writes are handed over to it, and every dimension the frame + plays is then read back out of it. So a voice that writes a dimension sets what the channel + holds, and one that leaves it empty sounds at what the channel holds. Args: - instruction: The frame as the sample holds it. - feature_values: The values the channel holds, updated with what the instrument writes. + instruction: The frame as the voice holds it. + feature_values: The values the channel holds, updated with what the voice writes. Returns: InstructionUnion: The frame to sound, before the pattern's transpose and volume. """ stated = self.exporter.feature_values( instruction, # type: ignore[arg-type] - self.initial_pitch, + self.reference, ) for feature_key, value in stated.items(): if feature_key not in self.held_features: @@ -79,6 +119,6 @@ def sound( sounded: InstructionUnion = self.exporter.instruction_from_values( feature_values, - self.initial_pitch, + self.reference, ) return sounded diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py index 184fed3d0..e088cb141 100644 --- a/src/sampletones_player/compression/seeds.py +++ b/src/sampletones_player/compression/seeds.py @@ -1,7 +1,7 @@ from typing import List, Tuple from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP -from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.exporters.slices import iterate_voice_slices from sampletones_core.project.project import Project from sampletones_core.timers.utils import get_timer_table from sampletones_player.compression.dictionary.phrase import Phrase @@ -33,9 +33,9 @@ def phrases_from_project( timer_table = get_timer_table(tuning) pitches = PitchTable.from_tuning(tuning) phrases: List[Phrase] = [] - for sample_slice in iterate_sample_slices(project): - channel = sample_slice.channel - played = {channel: CHANNEL_TO_EXPORTER_MAP[channel].from_features(sample_slice.features)} + for voice_slice in iterate_voice_slices(project): + channel = voice_slice.channel + played = {channel: CHANNEL_TO_EXPORTER_MAP[channel].from_features(voice_slice.features)} planes = channel_planes( channel, channel_registers(channel, played, timer_table), diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index 52effc096..e1952a5bb 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -3,7 +3,7 @@ import numpy as np from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.exporters.slices import iterate_voice_slices from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.voices.sample import Sample @@ -35,9 +35,9 @@ class TestSampleSlices: def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None: project = _project([_sample("lead", [ChannelName.PULSE1, ChannelName.NOISE])]) - slices = list(iterate_sample_slices(project)) + slices = list(iterate_voice_slices(project)) - assert [sample_slice.channel for sample_slice in slices] == [ + assert [voice_slice.channel for voice_slice in slices] == [ ChannelName.PULSE1, ChannelName.NOISE, ] @@ -53,9 +53,9 @@ def test_a_channel_standing_by_takes_no_place_in_the_table(self) -> None: ) project = _project([sample]) - slices = list(iterate_sample_slices(project)) + slices = list(iterate_voice_slices(project)) - assert [(sample_slice.index, sample_slice.channel) for sample_slice in slices] == [ + assert [(voice_slice.index, voice_slice.channel) for voice_slice in slices] == [ (0, ChannelName.PULSE2), ] @@ -67,6 +67,6 @@ def test_slices_are_numbered_across_the_samples_in_order(self) -> None: ] ) - indices: List[int] = [sample_slice.index for sample_slice in iterate_sample_slices(project)] + indices: List[int] = [voice_slice.index for voice_slice in iterate_voice_slices(project)] assert indices == [0, 1, 2] diff --git a/tests/unit/sampletones_core/performance/test_ticks.py b/tests/unit/sampletones_core/performance/test_ticks.py index 3a6ee2c28..d3c42eec3 100644 --- a/tests/unit/sampletones_core/performance/test_ticks.py +++ b/tests/unit/sampletones_core/performance/test_ticks.py @@ -1,103 +1,131 @@ from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import Optional, Tuple import pytest from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.instructions import InstructionUnion, PulseInstruction -from sampletones_core.performance import ChannelPerformance, SampleVoice, sound_tick +from sampletones_core.performance import ChannelPerformance, VoiceReading, sound_tick +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.sample import Sample from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.performance import make_pulse_reconstruction ENVELOPE_TICKS: int = 3 SOUNDING_PITCH: int = 60 +TAIL_LOOP_POINT: int = ENVELOPE_TICKS - 1 -def _voice() -> Tuple[SampleVoice, List[InstructionUnion]]: +def _reading(loop_point: Optional[int]) -> VoiceReading: """A pulse voice over a three-tick envelope, read the way a channel reads it.""" - reconstruction = make_pulse_reconstruction(pitch=SOUNDING_PITCH, count=ENVELOPE_TICKS) - return ( - SampleVoice.read(reconstruction, ChannelName.PULSE1), - reconstruction.instructions[ChannelName.PULSE1], + voice = Sample( + name="lead", + reconstruction=make_pulse_reconstruction(pitch=SOUNDING_PITCH, count=ENVELOPE_TICKS), + loop_point=loop_point, ) + reading = VoiceReading.read(voice, ChannelName.PULSE1) + assert reading is not None + return reading class TestSoundTick(BaseTestSuite): - """Which of a sample's instructions a channel reaches, and where it runs out.""" + """Which of a voice's instructions a channel reaches, and where it runs out.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): expected: bool tick_index: int - loop: bool + loop_point: Optional[int] test_cases: Tuple["TestSoundTick.TestCase", ...] = ( - TestCase(label="a one-shot within its envelope", tick_index=0, loop=False, expected=True), + TestCase(label="a one-shot within its envelope", tick_index=0, loop_point=None, expected=True), TestCase( label="a one-shot on its final tick", tick_index=ENVELOPE_TICKS - 1, - loop=False, + loop_point=None, expected=True, ), TestCase( label="a one-shot past its envelope", tick_index=ENVELOPE_TICKS, - loop=False, + loop_point=None, expected=False, ), TestCase( - label="a looping sample past its envelope", + label="a looping voice past its envelope", tick_index=ENVELOPE_TICKS, - loop=True, + loop_point=WHOLE_LOOP_POINT, expected=True, ), TestCase( - label="a looping sample several passes on", + label="a looping voice several passes on", tick_index=ENVELOPE_TICKS * 4 + 1, - loop=True, + loop_point=WHOLE_LOOP_POINT, + expected=True, + ), + TestCase( + label="a voice circling its tail", + tick_index=ENVELOPE_TICKS * 4, + loop_point=TAIL_LOOP_POINT, expected=True, ), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_whether_the_channel_still_sounds(self, test_case: TestCase) -> None: - voice, instructions = _voice() + reading = _reading(test_case.loop_point) performance = ChannelPerformance(tick_index=test_case.tick_index) - instruction = sound_tick(performance, instructions, loop=test_case.loop, voice=voice) + instruction = sound_tick(performance, reading) assert (instruction is not None) is test_case.expected @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_channel_moves_on_whether_or_not_it_sounds(self, test_case: TestCase) -> None: - """A sample that has played out keeps counting, so the tick index states the song's time.""" - voice, instructions = _voice() + """A voice that has played out keeps counting, so the tick index states the song's time.""" + reading = _reading(test_case.loop_point) performance = ChannelPerformance(tick_index=test_case.tick_index) - sound_tick(performance, instructions, loop=test_case.loop, voice=voice) + sound_tick(performance, reading) assert performance.tick_index == test_case.tick_index + 1 - def test_a_looping_sample_wraps_onto_the_instruction_the_pass_reaches(self) -> None: - voice, instructions = _voice() + def test_a_looping_voice_wraps_onto_the_instruction_the_pass_reaches(self) -> None: + reading = _reading(WHOLE_LOOP_POINT) performance = ChannelPerformance() - sounded = [sound_tick(performance, instructions, loop=True, voice=voice) for _ in range(ENVELOPE_TICKS * 2)] + sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS * 2)] assert sounded[:ENVELOPE_TICKS] == sounded[ENVELOPE_TICKS:] - def test_the_row_bends_the_instruction_the_sample_holds(self) -> None: + def test_a_loop_point_leaves_the_opening_behind(self) -> None: + """A voice repeating from a point plays its opening once, then circles the frames past it.""" + reading = _reading(TAIL_LOOP_POINT) + performance = ChannelPerformance() + + sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS + 2)] + + assert sounded[:ENVELOPE_TICKS] == [reading.at(index) for index in range(ENVELOPE_TICKS)] + assert sounded[ENVELOPE_TICKS:] == [sounded[TAIL_LOOP_POINT]] * 2 + + def test_a_loop_point_past_the_frames_circles_the_last_one(self) -> None: + reading = _reading(ENVELOPE_TICKS * 2) + performance = ChannelPerformance(tick_index=ENVELOPE_TICKS * 3) + + assert sound_tick(performance, reading) == _reading(None).at(ENVELOPE_TICKS - 1) + + def test_the_row_bends_the_instruction_the_voice_holds(self) -> None: """The transpose and volume a row reached are applied to what the channel sounds.""" - voice, instructions = _voice() + reading = _reading(None) transpose = 7 volume = MAX_VOLUME // 3 performance = ChannelPerformance(transpose=transpose, volume=volume) - instruction = sound_tick(performance, instructions, loop=False, voice=voice) + instruction = sound_tick(performance, reading) - held: Optional[InstructionUnion] = instructions[0] + held: Optional[InstructionUnion] = reading.instructions[0] assert isinstance(instruction, PulseInstruction) assert isinstance(held, PulseInstruction) assert instruction.pitch == held.pitch + transpose diff --git a/tests/unit/sampletones_core/performance/test_voice.py b/tests/unit/sampletones_core/performance/test_voice.py index 651687e0b..059852aef 100644 --- a/tests/unit/sampletones_core/performance/test_voice.py +++ b/tests/unit/sampletones_core/performance/test_voice.py @@ -15,7 +15,8 @@ PulseInstruction, TriangleInstruction, ) -from sampletones_core.performance import SampleVoice +from sampletones_core.performance import VoiceReading +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -65,8 +66,11 @@ def _voice( channel_name: ChannelName, instructions: Sequence[InstructionUnion], held_features: Iterable[FeatureKey], -) -> SampleVoice: - return SampleVoice.read(_reconstruction(channel_name, instructions, held_features), channel_name) +) -> VoiceReading: + voice = Sample(name="lead", reconstruction=_reconstruction(channel_name, instructions, held_features)) + reading = VoiceReading.read(voice, channel_name) + assert reading is not None + return reading def _channel_values() -> Dict[FeatureKey, int]: From 518a2b60af333e7dead635c484dc9152f7422a94 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 03:52:17 +0200 Subject: [PATCH 074/142] Added: the shape, a voice written by hand rather than converted --- src/sampletones_application/application.py | 11 +- .../logic/history/action.py | 2 + .../logic/history/fingerprint.py | 19 +- .../logic/project/controller.py | 71 ++++++- .../logic/sequencer/samples.py | 4 +- .../logic/sequencer/tracker/tracker.py | 8 +- .../logic/shared/project_source.py | 3 +- src/sampletones_config/lang/en.yaml | 2 + src/sampletones_core/exporters/slices.py | 3 +- src/sampletones_core/features/__init__.py | 2 + src/sampletones_core/features/spec.py | 37 +++- src/sampletones_core/performance/song.py | 4 +- src/sampletones_core/performance/voice.py | 26 ++- src/sampletones_core/project/container.py | 64 +++--- src/sampletones_core/project/document.py | 4 +- src/sampletones_core/project/project.py | 16 +- src/sampletones_core/project/tuning.py | 5 +- .../project/voices/__init__.py | 11 +- .../project/voices/envelopes.py | 83 ++++++++ src/sampletones_core/project/voices/record.py | 12 +- src/sampletones_core/project/voices/shape.py | 164 ++++++++++++++++ src/sampletones_core/project/voices/voice.py | 41 ++++ src/sampletones_core/utils/display.py | 6 +- tests/suite/performance.py | 16 +- .../logic/project/test_controller.py | 76 +++++++- .../test_application_retune.py | 22 ++- .../test_application_sample_rebind.py | 14 +- .../performance/test_shape_walk.py | 116 +++++++++++ .../project/test_container.py | 75 +++++++ .../project/voices/test_shape.py | 183 ++++++++++++++++++ 30 files changed, 1012 insertions(+), 88 deletions(-) create mode 100644 src/sampletones_core/project/voices/envelopes.py create mode 100644 src/sampletones_core/project/voices/shape.py create mode 100644 src/sampletones_core/project/voices/voice.py create mode 100644 tests/unit/sampletones_core/performance/test_shape_walk.py create mode 100644 tests/unit/sampletones_core/project/voices/test_shape.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 95346c644..7f41390fe 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -158,6 +158,7 @@ from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.stage import ExportStage from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode from sampletones_core.types.feature import FeatureValue @@ -1025,7 +1026,7 @@ def _navigate_to_reconstructions(self) -> None: def _edit_project_sample(self, voice_id: str) -> None: sample = self.project_manager.current.voice(voice_id) - if sample is None: + if not isinstance(sample, Sample): logger.warning(f"Cannot edit unknown project sample: {voice_id}") return @@ -1050,7 +1051,7 @@ def _rebind_replaced_sample( reconstruction: The reconstruction the sample is about to hold. """ sample = self.project_manager.current.voice(voice_id) - if sample is None or sample.reconstruction is not self.reconstruction_manager.reconstruction: + if not isinstance(sample, Sample) or sample.reconstruction is not self.reconstruction_manager.reconstruction: return self.reconstruction_manager.apply_edited(reconstruction) @@ -1120,7 +1121,7 @@ def _retune_samples_for_rate(self, nes_frequency: int) -> None: """ targets = [ (sample.id, sample.reconstruction) - for sample in self.project_manager.current.voices + for sample in samples(self.project_manager.current.voices) if sample.reconstruction.config.nes_frequency != nes_frequency ] if not targets: @@ -1161,7 +1162,7 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: """ project = self.project_manager.current sample = project.voices.get(retuned.voice_id) - if sample is None: + if not isinstance(sample, Sample): return nes_frequency = retuned.reconstruction.config.nes_frequency @@ -1319,7 +1320,7 @@ def _owning_project_sample(self) -> Optional[Sample]: if reconstruction is None: return None - for sample in self.project_manager.current.voices: + for sample in samples(self.project_manager.current.voices): if sample.reconstruction is reconstruction: return sample diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index 6a6074744..d19ef7af7 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -32,6 +32,8 @@ class HistoryAction(AbstractElement): MOVE_SAMPLE = "move_sample" DUPLICATE_SAMPLE = "duplicate_sample" SET_SAMPLE_LOOP = "set_sample_loop" + ADD_SHAPE = "add_shape" + EDIT_SHAPE = "edit_shape" SET_TEMPO = "set_tempo" SET_SPEED = "set_speed" SET_NES_FREQUENCY = "set_nes_frequency" diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index 52b69966d..7327017b5 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -2,6 +2,9 @@ from typing import Callable, Dict, Iterable, List, Tuple from sampletones_core.project import Project +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction ReconstructionHash = Callable[[Reconstruction], str] @@ -25,11 +28,15 @@ def fingerprint_project( project.settings.model_dump_json(), project.song.model_dump_json(), ] - for sample in project.voices: - parts.append(sample.id) - parts.append(sample.name) - parts.append(str(sample.loop_point)) - parts.append(reconstruction_hash(sample.reconstruction)) + for voice in project.voices: + parts.append(voice.id) + parts.append(voice.name) + parts.append(str(voice.loop_point)) + match voice: + case Sample(): + parts.append(reconstruction_hash(voice.reconstruction)) + case Shape(): + parts.append(voice.model_dump_json()) combined = "|".join(parts) return hashlib.sha256(combined.encode("utf-8")).hexdigest() @@ -62,5 +69,5 @@ def hash(self, reconstruction: Reconstruction) -> str: return cached[1] def prune(self, projects: Iterable[Project]) -> None: - live = {id(sample.reconstruction) for project in projects for sample in project.voices} + live = {id(sample.reconstruction) for project in projects for sample in samples(project.voices)} self._hashes = {key: value for key, value in self._hashes.items() if key in live} diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 3e84f348f..606515bef 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -1,14 +1,16 @@ from contextlib import contextmanager from pathlib import Path -from typing import Iterator, Optional +from typing import Iterator, Optional, Tuple -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE from sampletones_core.exports.request import ProjectExport from sampletones_core.project import Project from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp @@ -200,6 +202,63 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: self._announce(self.on_voices_changed) return sample + def add_shape(self, name: str) -> Shape: + """Appends a hand-written voice, resting at the roots a channel added by hand sounds on. + + A shape opens with no envelope, so every dimension is the channel's until one is written; + the voice list holds it from this moment and the tracker can name it. + """ + shape = Shape(name=name) + self.project.voices.append(shape) + self._touch() + self._announce(self.on_voices_changed) + return shape + + def set_shape_envelope( + self, + voice_id: str, + feature_key: FeatureKey, + items: Tuple[int, ...], + ) -> None: + """Writes one dimension of a shape's envelopes, emptying it to leave it to the channel. + + Raises: + TypeError: If ``voice_id`` names a voice that writes no envelopes of its own. + """ + shape = self._shape(voice_id) + shape.envelopes = shape.envelopes.with_envelope(feature_key, items) + shape.invalidate() + self._touch() + self._announce(self.on_voices_changed) + self._announce(self.on_song_changed) + + def set_shape_root( + self, + voice_id: str, + *, + pitch: int, + period: int, + ) -> None: + """Moves the roots a shape's arpeggio is measured against, on the tonal channels and on noise. + + Raises: + TypeError: If ``voice_id`` names a voice that states no root of its own. + """ + shape = self._shape(voice_id) + shape.root_pitch = pitch + shape.root_period = period + shape.invalidate() + self._touch() + self._announce(self.on_voices_changed) + self._announce(self.on_song_changed) + + def _shape(self, voice_id: str) -> Shape: + voice = self.project.voices[voice_id] + if not isinstance(voice, Shape): + raise TypeError(f"Voice '{voice_id}' is no shape") + + return voice + def replace_sample_reconstruction(self, voice_id: str, reconstruction: Reconstruction) -> None: """Substitutes a sample's reconstruction, detaching its local source-audio origin. @@ -208,7 +267,11 @@ def replace_sample_reconstruction(self, voice_id: str, reconstruction: Reconstru reconstruction, the project stays a self-contained, shareable artifact. """ reconstruction.detach_source() - self.project.voices[voice_id].reconstruction = reconstruction + voice = self.project.voices[voice_id] + if not isinstance(voice, Sample): + raise TypeError(f"Voice '{voice_id}' carries no reconstruction to substitute") + + voice.reconstruction = reconstruction self._touch() self._announce(self.on_voices_changed) self._announce(self.on_song_changed) @@ -235,7 +298,7 @@ def remove_voice(self, voice_id: str) -> None: self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def duplicate_voice(self, voice_id: str) -> Sample: + def duplicate_voice(self, voice_id: str) -> VoiceUnion: """Appends an independent copy of a voice (same name and loop point). The copy is appended, so existing voices keep their positions; it keeps the diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py index bfc8715fd..c11cebf7a 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -93,7 +93,7 @@ def build_sample_footprint(self, voice_id: str) -> Optional[SampleFootprintViewM pool holds no such sample. """ sample = self._controller.project.voices.get(voice_id) - if sample is None: + if not isinstance(sample, Sample): return None return SampleFootprintViewModel.from_footprints( @@ -167,7 +167,7 @@ def _play_sample( priority: PlaybackPriority, ) -> None: sample = self._controller.project.voices.get(voice_id) - if sample is None: + if not isinstance(sample, Sample): return try: diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 23e497273..079dc27a0 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -16,7 +16,7 @@ from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels from sampletones_core.utils.display import ( display_command, display_id, @@ -527,9 +527,9 @@ def _create_frame_pattern(self, channel: ChannelName) -> Optional[int]: ) return pattern_index - def _used_generators(self, sample: Sample) -> List[ChannelName]: - """The channels a sample's reconstruction provides instructions for.""" - return [channel for channel in ChannelName.items() if sample.reconstruction.get_channel_instructions(channel)] + def _used_generators(self, voice: VoiceUnion) -> List[ChannelName]: + """The channels a voice sounds on.""" + return list(voice_channels(voice)) def _subcolumn_generators(self, row_index: int) -> List[ChannelName]: """Channels a sample-column transpose/volume edit writes to. diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py index 588695d17..a2ca8c7be 100644 --- a/src/sampletones_application/logic/shared/project_source.py +++ b/src/sampletones_application/logic/shared/project_source.py @@ -3,6 +3,7 @@ from typing import Dict, Protocol, Self from sampletones_core.project import Project +from sampletones_core.project.voices.voice import samples def snapshot_project(project: Project) -> Project: @@ -16,7 +17,7 @@ def snapshot_project(project: Project) -> Project: snapshot. """ shared_reconstructions: Dict[int, object] = { - id(sample.reconstruction): sample.reconstruction for sample in project.voices + id(sample.reconstruction): sample.reconstruction for sample in samples(project.voices) } return copy.deepcopy(project, shared_reconstructions) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6e82b68e9..07c2ed7ce 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -636,6 +636,8 @@ sequencer.history.label.rename_sample: "Rename sample" sequencer.history.label.move_sample: "Move sample" sequencer.history.label.duplicate_sample: "Duplicate sample" sequencer.history.label.set_sample_loop: "Toggle sample loop" +sequencer.history.label.add_shape: "Add shape" +sequencer.history.label.edit_shape: "Edit shape" sequencer.history.label.loop_on: "on" sequencer.history.label.loop_off: "off" sequencer.history.label.set_tempo: "Set tempo" diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 5bbeed96e..4948cfe47 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -8,6 +8,7 @@ from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.project.project import Project from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import samples @dataclass(frozen=True) @@ -71,7 +72,7 @@ def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: VoiceSlice: Each slice alongside the index it takes in the instrument table. """ index = 0 - for voice in project.voices: + for voice in samples(project.voices): features_by_channel = voice.reconstruction.export() for channel in ChannelName.items(): features = features_by_channel[channel] diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index d7dea6c0b..4f25f3da3 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -6,6 +6,7 @@ RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH, FeatureRange, + channel_reference, feature_range, resting_held_features, resting_reference, @@ -21,6 +22,7 @@ "RESTING_REFERENCE_PERIOD", "RESTING_REFERENCE_PITCH", "FeatureRange", + "channel_reference", "feature_range", "resting_held_features", "resting_reference", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index ef01a8e9b..4466b4068 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -73,6 +73,33 @@ class FeatureRange: } +def channel_reference( + channel_name: ChannelName, + *, + pitch: int, + period: int, +) -> int: + """Which of a pitch-and-period pair a channel measures its arpeggio against. + + An arpeggio envelope is a semitone offset on the tonal channels and a period offset on noise, + so a reference is stated as both and the channel picks the one it reads. Every voice states + its reference this way, which is what lets one voice be started on any channel. + + Args: + channel_name: The channel reading the reference. + pitch: The reference a tonal channel measures against. + period: The reference the noise channel measures against. + + Returns: + int: The reference this channel reads. + """ + match CHANNEL_GENERATOR_KIND[channel_name]: + case GeneratorName.NOISE: + return period + case _: + return pitch + + def resting_reference(channel_name: ChannelName) -> int: """The reference an arpeggio envelope is measured against while a channel describes no frame. @@ -86,11 +113,11 @@ def resting_reference(channel_name: ChannelName) -> int: Returns: int: The pitch a tonal channel rests at, or the period the noise channel rests at. """ - match CHANNEL_GENERATOR_KIND[channel_name]: - case GeneratorName.NOISE: - return RESTING_REFERENCE_PERIOD - case _: - return RESTING_REFERENCE_PITCH + return channel_reference( + channel_name, + pitch=RESTING_REFERENCE_PITCH, + period=RESTING_REFERENCE_PERIOD, + ) def resting_held_features( diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index bb44a7305..c92f96d70 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -14,7 +14,7 @@ from sampletones_core.performance.voice import VoiceReading from sampletones_core.project.project import Project from sampletones_core.project.song_position import SongPosition -from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.timing.song import SongTiming @@ -75,7 +75,7 @@ def song_instructions( def _channel_ticks( - voice: Optional[Sample], + voice: Optional[VoiceUnion], channel_name: ChannelName, performance: ChannelPerformance, ticks: int, diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index 20c473940..492d25378 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -7,6 +7,8 @@ from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, ExporterTypeUnion from sampletones_core.instructions import InstructionUnion from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion @dataclass(frozen=True) @@ -39,11 +41,16 @@ class VoiceReading: @classmethod def read( cls, - voice: Sample, + voice: VoiceUnion, channel_name: ChannelName, ) -> Optional[VoiceReading]: """The reading one channel plays ``voice`` through. + A sample answers with the frames its reconstruction found for this channel and the + reference they were measured against; a shape answers with the frames its envelopes make + of this channel and the root it states. Both kinds therefore reach a channel as one + reading. + Args: voice: The voice being sounded. channel_name: The channel sounding it. @@ -52,16 +59,25 @@ def read( Optional[VoiceReading]: The reading of that channel's frames, or ``None`` where the voice describes no frame there and the channel rests. """ - reconstruction = voice.reconstruction - instructions = reconstruction.instructions[channel_name] + match voice: + case Sample(): + reconstruction = voice.reconstruction + instructions: Sequence[InstructionUnion] = reconstruction.instructions[channel_name] + reference = reconstruction.initial_pitches[channel_name] + held_features = reconstruction.held_features[channel_name] + case Shape(): + instructions = voice.instructions(channel_name) + reference = voice.reference(channel_name) + held_features = voice.held_features(channel_name) + if not instructions: return None return cls( exporter=CHANNEL_TO_EXPORTER_MAP[channel_name], instructions=instructions, - reference=reconstruction.initial_pitches[channel_name], - held_features=reconstruction.held_features[channel_name], + reference=reference, + held_features=held_features, loop_point=voice.loop_point, ) diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index 2195e5eb8..434a74ce7 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -8,8 +8,10 @@ from sampletones_core.compatibility.upgrade import upgrade_json from sampletones_core.project.document import ProjectDocument from sampletones_core.project.project import Project -from sampletones_core.project.voices.record import SampleRecord +from sampletones_core.project.voices.record import SampleRecord, VoiceRecord from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION @@ -115,15 +117,7 @@ def _build_document(project: Project) -> ProjectDocument: metadata=project.metadata, info=project.info, settings=project.settings, - voices=[ - SampleRecord( - id=voice.id, - name=voice.name, - reconstruction_id=voice.reconstruction.id, - loop_point=voice.loop_point, - ) - for voice in project.voices - ], + voices=[ProjectContainer._voice_record(voice) for voice in project.voices], song=project.song, ) @@ -132,10 +126,9 @@ def _build_project( document: ProjectDocument, reconstructions: Dict[str, Reconstruction], ) -> Project: - voices: IdentifiedCollection[Sample] = IdentifiedCollection() + voices: IdentifiedCollection[VoiceUnion] = IdentifiedCollection() for record in document.voices: - reconstruction = reconstructions[record.reconstruction_id] - voices.append(ProjectContainer._restore_sample(record, reconstruction)) + voices.append(ProjectContainer._restore_voice(record, reconstructions)) return Project( metadata=document.metadata, @@ -146,20 +139,47 @@ def _build_project( ) @staticmethod - def _restore_sample(record: SampleRecord, reconstruction: Reconstruction) -> Sample: - sample = Sample( - name=record.name, - reconstruction=reconstruction, - loop_point=record.loop_point, - ) - sample.id = record.id - return sample + def _voice_record(voice: VoiceUnion) -> VoiceRecord: + """The record a voice is written as: a reference for a sample, the shape itself for a shape.""" + match voice: + case Sample(): + return SampleRecord( + id=voice.id, + name=voice.name, + reconstruction_id=voice.reconstruction.id, + loop_point=voice.loop_point, + ) + case Shape(): + return voice + + @staticmethod + def _restore_voice( + record: VoiceRecord, + reconstructions: Dict[str, Reconstruction], + ) -> VoiceUnion: + """The voice a record describes, resolving a sample's reconstruction from the archive. + + Raises: + KeyError: If a sample record names a reconstruction the archive holds none of. + """ + match record: + case SampleRecord(): + sample = Sample( + name=record.name, + reconstruction=reconstructions[record.reconstruction_id], + loop_point=record.loop_point, + ) + sample.id = record.id + return sample + case Shape(): + return record @staticmethod def _unique_reconstructions(project: Project) -> Dict[str, Reconstruction]: reconstructions: Dict[str, Reconstruction] = {} for voice in project.voices: - reconstructions[voice.reconstruction.id] = voice.reconstruction + if isinstance(voice, Sample): + reconstructions[voice.reconstruction.id] = voice.reconstruction return reconstructions diff --git a/src/sampletones_core/project/document.py b/src/sampletones_core/project/document.py index 31d9a9525..d13c00b9b 100644 --- a/src/sampletones_core/project/document.py +++ b/src/sampletones_core/project/document.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field from sampletones_core.data import Metadata -from sampletones_core.project.voices.record import SampleRecord +from sampletones_core.project.voices.record import VoiceRecord from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from .info import ProjectInfo @@ -30,5 +30,5 @@ class ProjectDocument(BaseModel): metadata: Metadata info: ProjectInfo settings: ProjectSettings - voices: List[SampleRecord] + voices: List[VoiceRecord] song: Song diff --git a/src/sampletones_core/project/project.py b/src/sampletones_core/project/project.py index 1434c05c7..a277691d5 100644 --- a/src/sampletones_core/project/project.py +++ b/src/sampletones_core/project/project.py @@ -3,7 +3,7 @@ from typing import Optional from sampletones_core.data import Metadata -from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.structures import IdentifiedCollection from sampletones_shared.constants.project import ( DEFAULT_PROJECT_AUTHOR, @@ -20,10 +20,10 @@ class Project: """The top-level container for everything a user composes. - Owns the voices (each a sample embedding its own reconstruction) and the song - arrangement. References inside the song point at voices by their stable - ``id``; the :class:`IdentifiedCollection` resolves those ids in O(1) while - also exposing reorder-safe positions for the UI. + Owns the voices — samples embedding their own reconstruction, and shapes carrying + their own envelopes — and the song arrangement. References inside the song point at + voices by their stable ``id``; the :class:`IdentifiedCollection` resolves those ids + in O(1) while also exposing reorder-safe positions for the UI. """ def __init__( @@ -31,13 +31,13 @@ def __init__( metadata: Metadata, info: ProjectInfo, settings: ProjectSettings, - voices: IdentifiedCollection[Sample], + voices: IdentifiedCollection[VoiceUnion], song: Song, ) -> None: self.metadata: Metadata = metadata self.info: ProjectInfo = info self.settings: ProjectSettings = settings - self.voices: IdentifiedCollection[Sample] = voices + self.voices: IdentifiedCollection[VoiceUnion] = voices self.song: Song = song @classmethod @@ -66,7 +66,7 @@ def create( song=Song.empty(rows_per_pattern), ) - def voice(self, voice_id: str) -> Optional[Sample]: + def voice(self, voice_id: str) -> Optional[VoiceUnion]: return self.voices.get(voice_id) def __repr__(self) -> str: diff --git a/src/sampletones_core/project/tuning.py b/src/sampletones_core/project/tuning.py index fb0bd0464..f961e4723 100644 --- a/src/sampletones_core/project/tuning.py +++ b/src/sampletones_core/project/tuning.py @@ -1,6 +1,7 @@ from typing import Final, Set from sampletones_core.project.project import Project +from sampletones_core.project.voices.voice import samples from sampletones_shared.music import Tuning UNTUNED_PROJECT: Final[Tuning] = Tuning() @@ -19,7 +20,7 @@ def tuning_from_project(project: Project) -> Tuning: a project holding none takes the tuning a reconstruction is built against by default. Args: - project: The project whose voices state the tuning. + project: The project whose samples state the tuning. Returns: Tuning: The tuning every sample of the project was reconstructed against. @@ -28,7 +29,7 @@ def tuning_from_project(project: Project) -> Tuning: ValueError: If the samples were reconstructed against tunings that differ, which one timer table sounds only one of. """ - tunings: Set[Tuning] = {voice.reconstruction.config.tuning for voice in project.voices} + tunings: Set[Tuning] = {sample.reconstruction.config.tuning for sample in samples(project.voices)} if not tunings: return UNTUNED_PROJECT diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py index bce31c41c..058958c04 100644 --- a/src/sampletones_core/project/voices/__init__.py +++ b/src/sampletones_core/project/voices/__init__.py @@ -1,8 +1,11 @@ +from .envelopes import ShapeEnvelopes from .loop import WHOLE_LOOP_POINT from .note_off import NoteOff from .note_on import NoteOn -from .record import SampleRecord +from .record import SampleRecord, VoiceRecord from .sample import Sample +from .shape import Shape +from .voice import VoiceUnion, samples, voice_channels __all__ = [ "WHOLE_LOOP_POINT", @@ -10,4 +13,10 @@ "NoteOn", "Sample", "SampleRecord", + "Shape", + "ShapeEnvelopes", + "VoiceRecord", + "VoiceUnion", + "samples", + "voice_channels", ] diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py new file mode 100644 index 000000000..2058b0133 --- /dev/null +++ b/src/sampletones_core/project/voices/envelopes.py @@ -0,0 +1,83 @@ +from typing import Annotated, Dict, Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import ( + ARPEGGIO_MAX, + ARPEGGIO_MIN, + MAX_DUTY_CYCLE, + MAX_VOLUME, + SILENT_VOLUME, +) + +VolumeItem = Annotated[int, Field(ge=SILENT_VOLUME, le=MAX_VOLUME)] +ArpeggioItem = Annotated[int, Field(ge=ARPEGGIO_MIN, le=ARPEGGIO_MAX)] +DutyCycleItem = Annotated[int, Field(ge=0, le=MAX_DUTY_CYCLE)] + + +class ShapeEnvelopes(BaseModel): + """The per-tick envelopes a shape writes, in the terms every channel reads them in. + + Each dimension carries the widest range the four channels offer, and a channel takes what it + reads: an arpeggio item is a semitone offset on the tonal channels and a period offset on + noise, and a duty-cycle item selects a pulse waveform or the noise channel's short mode. An + empty envelope leaves that dimension to the channel, which keeps the value it already holds — + the same record a reconstruction's held dimensions carry. + + Attributes: + volume: Output level per tick. + arpeggio: Offset from the shape's root per tick. + duty_cycle: Pulse waveform, or noise mode, per tick. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + volume: Tuple[VolumeItem, ...] = () + arpeggio: Tuple[ArpeggioItem, ...] = () + duty_cycle: Tuple[DutyCycleItem, ...] = () + + @property + def envelope_map(self) -> Dict[FeatureKey, Tuple[int, ...]]: + return { + FeatureKey.VOLUME: self.volume, + FeatureKey.ARPEGGIO: self.arpeggio, + FeatureKey.DUTY_CYCLE: self.duty_cycle, + } + + def envelope(self, feature_key: FeatureKey) -> Tuple[int, ...]: + """The items one dimension carries, empty where the shape leaves it to the channel. + + Args: + feature_key: The dimension read. + + Returns: + Tuple[int, ...]: That dimension's items. + + Raises: + KeyError: If ``feature_key`` names a dimension a shape does not write. + """ + return self.envelope_map[feature_key] + + def with_envelope(self, feature_key: FeatureKey, items: Tuple[int, ...]) -> "ShapeEnvelopes": + """The envelopes with one dimension replaced. + + Args: + feature_key: The dimension written. + items: What that dimension now carries; empty leaves it to the channel. + + Returns: + ShapeEnvelopes: The envelopes carrying ``items`` for ``feature_key``. + + Raises: + KeyError: If ``feature_key`` names a dimension a shape does not write. + """ + if feature_key not in self.envelope_map: + raise KeyError(feature_key) + + return self.model_copy(update={feature_key.value: items}) + + @property + def frame_count(self) -> int: + """The ticks the envelopes describe, taken from the longest dimension.""" + return max((len(items) for items in self.envelope_map.values()), default=0) diff --git a/src/sampletones_core/project/voices/record.py b/src/sampletones_core/project/voices/record.py index dc5c7bf21..83158938e 100644 --- a/src/sampletones_core/project/voices/record.py +++ b/src/sampletones_core/project/voices/record.py @@ -1,7 +1,9 @@ -from typing import Literal, Optional +from typing import Annotated, Literal, Optional, Union from pydantic import BaseModel, Field +from sampletones_core.project.voices.shape import Shape + class SampleRecord(BaseModel): """The on-disk form of a sample: its identity plus a reference to the @@ -19,3 +21,11 @@ class SampleRecord(BaseModel): ge=0, description="Tick the sample's instructions repeat from, or None where it plays once.", ) + + +VoiceRecord = Annotated[Union[SampleRecord, Shape], Field(discriminator="kind")] +"""The on-disk form of one voice, told apart by its ``kind``. + +A sample is written as a reference to the reconstruction stored beside the document, while a shape +carries only what it states and is written whole. +""" diff --git a/src/sampletones_core/project/voices/shape.py b/src/sampletones_core/project/voices/shape.py new file mode 100644 index 000000000..18cd86959 --- /dev/null +++ b/src/sampletones_core/project/voices/shape.py @@ -0,0 +1,164 @@ +from functools import cached_property +from typing import Dict, List, Literal, Optional, Self, Tuple +from uuid import uuid4 + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import ( + MAX_PERIOD, + MAX_PITCH, + MIN_PITCH, +) +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.features import ( + CHANNEL_GENERATOR_KIND, + RESTING_REFERENCE_PERIOD, + RESTING_REFERENCE_PITCH, + channel_reference, + supported_features, + supports, +) +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.voices.envelopes import ShapeEnvelopes + + +def _new_shape_id() -> str: + return uuid4().hex + + +class Shape(BaseModel): + """A hand-written voice: envelopes with no recording behind them, playable on any channel. + + Where a sample carries the frames a conversion found for each channel, a shape carries one set + of envelopes and every channel reads what it can of them — the dimensions its generator offers, + measured against the root the shape states. That is the FamiTracker instrument model, so a + shape reaches a tracker as one instrument and sounds here as the frames each channel makes of + it. + + A shape carries no payload beyond what it states, so a project stores it whole rather than + beside itself: this is both the voice a song plays and the record a ``project.json`` holds. + + Attributes: + id: Stable id the tracker rows reference. + name: The name the voice list shows. + envelopes: The per-tick values every channel reads. + root_pitch: The note a tonal channel measures the arpeggio against. + root_period: The period the noise channel measures the arpeggio against. + loop_point: The tick the envelopes repeat from, or ``None`` where they play once. + """ + + model_config = ConfigDict(extra="forbid") + + kind: Literal["shape"] = "shape" + id: str = Field(default_factory=_new_shape_id, description="Stable shape id.") + name: str = Field(..., description="Shape name.") + envelopes: ShapeEnvelopes = Field(default_factory=ShapeEnvelopes) + root_pitch: int = Field( + default=RESTING_REFERENCE_PITCH, + ge=MIN_PITCH, + le=MAX_PITCH, + description="Note a tonal channel measures the arpeggio envelope against.", + ) + root_period: int = Field( + default=RESTING_REFERENCE_PERIOD, + ge=0, + le=MAX_PERIOD, + description="Period the noise channel measures the arpeggio envelope against.", + ) + loop_point: Optional[int] = Field( + default=None, + ge=0, + description="Tick the envelopes repeat from, or None where they play once.", + ) + + @property + def loops(self) -> bool: + """Whether the shape repeats its envelopes rather than playing them once.""" + return self.loop_point is not None + + def reference(self, channel_name: ChannelName) -> int: + """The value this channel measures the arpeggio envelope against.""" + return channel_reference( + channel_name, + pitch=self.root_pitch, + period=self.root_period, + ) + + def held_features(self, channel_name: ChannelName) -> Tuple[FeatureKey, ...]: + """The dimensions this channel governs: those it offers and the shape leaves empty.""" + kind = CHANNEL_GENERATOR_KIND[channel_name] + return tuple( + feature_key + for feature_key in supported_features(kind) + if not self.envelopes.envelope_map.get(feature_key, ()) + ) + + def features(self, channel_name: ChannelName) -> Features: + """The envelopes as this channel reads them, measured against the shape's root. + + A channel takes the dimensions its generator offers and leaves the rest absent, which is + what makes one set of envelopes serve every channel. + + Args: + channel_name: The channel reading the shape. + + Returns: + Features: The per-dimension envelopes for that channel. + """ + kind = CHANNEL_GENERATOR_KIND[channel_name] + return Features( + initial_pitch=self.reference(channel_name), + volume=_items(self.envelopes.volume), + arpeggio=_items(self.envelopes.arpeggio), + pitch=None, + hi_pitch=None, + duty_cycle=(_items(self.envelopes.duty_cycle) if supports(kind, FeatureKey.DUTY_CYCLE) else None), + ) + + @cached_property + def _instructions(self) -> Dict[ChannelName, List[InstructionUnion]]: + return { + channel_name: list(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(self.features(channel_name))) + for channel_name in ChannelName.items() + } + + def instructions(self, channel_name: ChannelName) -> List[InstructionUnion]: + """The frames this channel plays, one per tick of the envelopes. + + Args: + channel_name: The channel sounding the shape. + + Returns: + List[InstructionUnion]: The frames, empty where the shape writes no envelope. + """ + return self._instructions[channel_name] + + def invalidate(self) -> None: + """Drops the memoized frames so they are made afresh from the envelopes they describe.""" + self.__dict__.pop("_instructions", None) + + def clone(self) -> Self: + """Return an independent copy with a fresh id, carrying the name, root and envelopes.""" + return type(self)( + name=self.name, + envelopes=self.envelopes, + root_pitch=self.root_pitch, + root_period=self.root_period, + loop_point=self.loop_point, + ) + + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other: object) -> bool: + return isinstance(other, Shape) and self.id == other.id + + def __repr__(self) -> str: + return f"Shape(id={self.id!r}, name={self.name!r})" + + +def _items(envelope: Tuple[int, ...]) -> np.ndarray: + return np.array(envelope, dtype=np.int8) diff --git a/src/sampletones_core/project/voices/voice.py b/src/sampletones_core/project/voices/voice.py new file mode 100644 index 000000000..a0396a7eb --- /dev/null +++ b/src/sampletones_core/project/voices/voice.py @@ -0,0 +1,41 @@ +from typing import Iterable, Iterator, Tuple, Union + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape + +VoiceUnion = Union[Sample, Shape] + + +def samples(voices: Iterable[VoiceUnion]) -> Iterator[Sample]: + """The samples among a project's voices — those a reconstruction stands behind. + + Work that reads recorded audio — retuning, rendering a waveform, opening a document in the + Reconstructions tab — concerns those alone, so it walks them rather than every voice. + + Args: + voices: The project's voices. + + Yields: + Sample: Each voice a reconstruction stands behind. + """ + return (voice for voice in voices if isinstance(voice, Sample)) + + +def voice_channels(voice: VoiceUnion) -> Tuple[ChannelName, ...]: + """The channels a voice sounds on. + + A sample sounds on the channels its reconstruction found frames for; a shape sounds wherever + its envelopes make a frame, which is every channel once it writes one. + + Args: + voice: The voice being placed. + + Returns: + Tuple[ChannelName, ...]: The channels it sounds on, in channel order. + """ + match voice: + case Sample(): + return voice.reconstruction.playing_channels + case Shape(): + return tuple(channel for channel in ChannelName.items() if voice.instructions(channel)) diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index ed1b0672b..878cf94e8 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -2,7 +2,7 @@ from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.structures import IdentifiedCollection from sampletones_shared.constants.symbols import MINUS, PLUS @@ -34,7 +34,7 @@ def display_id(value: Optional[int]) -> str: def display_voice( *, - voices: IdentifiedCollection[Sample], + voices: IdentifiedCollection[VoiceUnion], voice_id: Optional[str] = None, ) -> str: """ @@ -52,7 +52,7 @@ def display_voice_label(position: int, name: str) -> str: def display_command( - voices: IdentifiedCollection[Sample], + voices: IdentifiedCollection[VoiceUnion], command: Optional[Union[NoteOn, NoteOff]], ) -> str: """Render a row's note-column command: a voice's list position, ``--`` for note-off, or ``..``.""" diff --git a/tests/suite/performance.py b/tests/suite/performance.py index e6de088e6..52a7ebb28 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -17,6 +17,8 @@ from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from tests.suite.stems import single_entry_stems_data @@ -128,12 +130,24 @@ def project_with_sample( return project, sample +def project_with_shape( + shape: Shape, + *, + rows_per_pattern: int, + settings: Optional[ProjectSettings] = None, +) -> Project: + """A one-shape project, so a case can place a hand-written voice on any channel it likes.""" + project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings) + project.voices.append(shape) + return project + + def place_instrument( project: Project, *, channel_name: ChannelName, row_index: int, - sample: Sample, + sample: VoiceUnion, transpose: Optional[int] = None, volume: Optional[int] = None, pattern_index: int = 0, diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 13cfb84e7..d2651f05f 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -1,16 +1,19 @@ from pathlib import Path from typing import Callable, List +from unittest.mock import Mock import numpy as np import pytest from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.shape import Shape from sampletones_core.reconstructions import Reconstruction @@ -500,6 +503,77 @@ def test_set_sample_loop_toggles_loop_flag( assert controller.project.voice(sample.id).loop_point == WHOLE_LOOP_POINT +class TestShapes: + def test_add_shape_appends_a_voice_resting_where_a_hand_added_channel_rests(self) -> None: + controller = _controller() + + shape = controller.add_shape("lead") + + assert controller.project.voice(shape.id) is shape + assert shape.root_pitch == RESTING_REFERENCE_PITCH + assert shape.root_period == RESTING_REFERENCE_PERIOD + assert shape.envelopes.frame_count == 0 + + def test_writing_an_envelope_reaches_the_frames_the_shape_sounds(self) -> None: + controller = _controller() + shape = controller.add_shape("lead") + + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 10)) + + assert shape.envelopes.volume == (15, 10) + assert len(shape.instructions(ChannelName.PULSE1)) == 2 + + def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None: + controller = _controller() + shape = controller.add_shape("lead") + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) + + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, ()) + + assert FeatureKey.VOLUME in shape.held_features(ChannelName.PULSE1) + + def test_moving_the_roots_reaches_the_frames(self) -> None: + controller = _controller() + shape = controller.add_shape("lead") + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) + + controller.set_shape_root(shape.id, pitch=48, period=3) + + assert shape.reference(ChannelName.PULSE1) == 48 + assert shape.reference(ChannelName.NOISE) == 3 + first = shape.instructions(ChannelName.PULSE1)[0] + assert isinstance(first, PulseInstruction) + assert first.pitch == 48 + + def test_a_sample_takes_no_shape_edit( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + controller = _controller() + sample = controller.add_sample(reconstruction_factory(), name="bass") + + with pytest.raises(TypeError): + controller.set_shape_envelope(sample.id, FeatureKey.VOLUME, (15,)) + + def test_a_shape_takes_no_reconstruction(self) -> None: + controller = _controller() + shape = controller.add_shape("lead") + + with pytest.raises(TypeError): + controller.replace_sample_reconstruction(shape.id, Mock()) + + def test_a_shape_duplicates_into_a_voice_of_its_own(self) -> None: + controller = _controller() + shape = controller.add_shape("lead") + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) + + clone = controller.duplicate_voice(shape.id) + + assert clone.id != shape.id + assert isinstance(clone, Shape) + assert clone.envelopes == shape.envelopes + + class TestPatternManagement: def test_add_pattern_returns_int_index(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index 7f6a86d04..e5f8fa29a 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -4,6 +4,12 @@ from sampletones_application.application import Application from sampletones_application.services.result import ServiceCancelled from sampletones_application.services.retune import RetunedSample +from sampletones_core.project.voices.sample import Sample + + +def _sample_double() -> Sample: + """A real project sample over a stand-in reconstruction, since the routing tells the kinds apart.""" + return Sample(name="lead", reconstruction=MagicMock()) def _retuned(voice_id: str, rate: int) -> RetunedSample: @@ -14,7 +20,7 @@ def _retuned(voice_id: str, rate: int) -> RetunedSample: def _app( current_rate: int, - sample: Optional[MagicMock], + sample: Optional[Sample], open_reconstruction: Optional[MagicMock] = None, ) -> Application: app = Application.__new__(Application) @@ -32,7 +38,7 @@ def _app( class TestApplyRetunedSample: def test_swaps_the_reconstruction_when_the_rate_matches(self) -> None: - app = _app(current_rate=60, sample=MagicMock()) + app = _app(current_rate=60, sample=_sample_double()) retuned = _retuned("lead", 60) app._apply_retuned_sample(retuned) @@ -40,7 +46,7 @@ def test_swaps_the_reconstruction_when_the_rate_matches(self) -> None: app.project_controller.replace_sample_reconstruction.assert_called_once_with("lead", retuned.reconstruction) def test_discards_a_stale_result_from_a_superseded_rate(self) -> None: - app = _app(current_rate=30, sample=MagicMock()) + app = _app(current_rate=30, sample=_sample_double()) retuned = _retuned("lead", 60) app._apply_retuned_sample(retuned) @@ -57,7 +63,7 @@ def test_ignores_a_removed_sample(self) -> None: def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None: open_reconstruction = MagicMock() - sample = MagicMock() + sample = _sample_double() sample.reconstruction = open_reconstruction app = _app(current_rate=60, sample=sample, open_reconstruction=open_reconstruction) retuned = _retuned("lead", 60) @@ -68,7 +74,7 @@ def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None: app._reconstructions_tab.update_reconstruction.assert_called_once() def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: - sample = MagicMock() + sample = _sample_double() sample.reconstruction = MagicMock() app = _app(current_rate=60, sample=sample, open_reconstruction=MagicMock()) retuned = _retuned("lead", 60) @@ -79,15 +85,15 @@ def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: app._reconstructions_tab.update_reconstruction.assert_not_called() -def _sample(voice_id: str, rate: int) -> MagicMock: - sample = MagicMock() +def _sample(voice_id: str, rate: int) -> Sample: + sample = _sample_double() sample.id = voice_id sample.reconstruction.config.nes_frequency = rate return sample def _app_for_rate( - samples: List[MagicMock], + samples: List[Sample], open_reconstruction: Optional[MagicMock], running: bool = False, ) -> Application: diff --git a/tests/unit/sampletones_application/test_application_sample_rebind.py b/tests/unit/sampletones_application/test_application_sample_rebind.py index b3c236d44..c57350919 100644 --- a/tests/unit/sampletones_application/test_application_sample_rebind.py +++ b/tests/unit/sampletones_application/test_application_sample_rebind.py @@ -2,10 +2,16 @@ from unittest.mock import MagicMock from sampletones_application.application import Application +from sampletones_core.project.voices.sample import Sample + + +def _sample_double() -> Sample: + """A real project sample over a stand-in reconstruction, since the routing tells the kinds apart.""" + return Sample(name="lead", reconstruction=MagicMock()) def _app( - sample: Optional[MagicMock], + sample: Optional[Sample], open_reconstruction: Optional[MagicMock], ) -> Application: app = Application.__new__(Application) @@ -20,7 +26,7 @@ def _app( class TestRebindReplacedSample: def test_rebinds_the_editor_showing_the_replaced_sample(self) -> None: outgoing = MagicMock() - sample = MagicMock() + sample = _sample_double() sample.reconstruction = outgoing app = _app(sample=sample, open_reconstruction=outgoing) incoming = MagicMock() @@ -31,7 +37,7 @@ def test_rebinds_the_editor_showing_the_replaced_sample(self) -> None: app._reconstructions_tab.update_reconstruction.assert_called_once() def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: - sample = MagicMock() + sample = _sample_double() sample.reconstruction = MagicMock() app = _app(sample=sample, open_reconstruction=MagicMock()) @@ -41,7 +47,7 @@ def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: app._reconstructions_tab.update_reconstruction.assert_not_called() def test_leaves_the_editor_alone_when_no_document_is_open(self) -> None: - sample = MagicMock() + sample = _sample_double() sample.reconstruction = MagicMock() app = _app(sample=sample, open_reconstruction=None) diff --git a/tests/unit/sampletones_core/performance/test_shape_walk.py b/tests/unit/sampletones_core/performance/test_shape_walk.py new file mode 100644 index 000000000..9d1851c8e --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_shape_walk.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.instructions import PulseInstruction +from sampletones_core.performance import song_instructions +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.shape import Shape +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import place_instrument, project_with_shape + +ROWS_PER_PATTERN: int = 4 +VOLUME: Tuple[int, ...] = (15, 10) +ARPEGGIO: Tuple[int, ...] = (0, 5) + + +def _shape(loop: bool = False) -> Shape: + return Shape( + name="lead", + envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), + loop_point=WHOLE_LOOP_POINT if loop else None, + ) + + +def _resting(channel_name: ChannelName) -> object: + return CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction() + + +class TestAShapeSoundsOnEveryChannel(BaseTestSuite): + """A hand-written voice is placed on any channel, and the walk sounds the frames it makes there.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + + test_cases = ( + TestCase(label=ChannelName.PULSE1.value, channel_name=ChannelName.PULSE1), + TestCase(label=ChannelName.PULSE2.value, channel_name=ChannelName.PULSE2), + TestCase(label=ChannelName.TRIANGLE.value, channel_name=ChannelName.TRIANGLE), + TestCase(label=ChannelName.NOISE.value, channel_name=ChannelName.NOISE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_walk_sounds_the_shape_where_it_was_placed(self, test_case: TestCase) -> None: + shape = _shape() + project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument( + project, + channel_name=test_case.channel_name, + row_index=0, + sample=shape, + ) + + streams = song_instructions(project) + + assert streams[test_case.channel_name][: len(VOLUME)] == shape.instructions(test_case.channel_name) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_channels_it_was_not_placed_on_rest(self, test_case: TestCase) -> None: + shape = _shape() + project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument( + project, + channel_name=test_case.channel_name, + row_index=0, + sample=shape, + ) + + streams = song_instructions(project) + + for channel_name in ChannelName.items(): + if channel_name is test_case.channel_name: + continue + + assert set(streams[channel_name]) == {_resting(channel_name)} + + +class TestAShapeInASong: + def test_a_one_shot_falls_silent_past_its_envelopes(self) -> None: + shape = _shape() + project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=shape) + + stream = song_instructions(project)[ChannelName.PULSE1] + + assert stream[len(VOLUME) :] == [_resting(ChannelName.PULSE1)] * (len(stream) - len(VOLUME)) + + def test_a_looping_shape_keeps_sounding(self) -> None: + shape = _shape(loop=True) + project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=shape) + + stream = song_instructions(project)[ChannelName.PULSE1] + + assert _resting(ChannelName.PULSE1) not in stream + + def test_a_rows_transpose_bends_the_shape_off_its_root(self) -> None: + shape = _shape() + project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=shape, + transpose=7, + ) + + first = song_instructions(project)[ChannelName.PULSE1][0] + + assert isinstance(first, PulseInstruction) + assert first.pitch == shape.root_pitch + ARPEGGIO[0] + 7 diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 39aec8671..ee9252714 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -10,8 +10,11 @@ from sampletones_core.project.container import ProjectContainer from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from sampletones_shared.constants.project import ( PROJECT_DOCUMENT_NAME, @@ -122,6 +125,78 @@ def test_references_resolve_after_load( assert channel.pattern(index_at_0) is channel.pattern(index_at_2) +class TestShapesRoundTrip: + """A shape carries no payload beside itself, so a project holds it whole in its document.""" + + def test_a_shape_survives_a_round_trip(self, tmp_path: Path) -> None: + project = Project.create(title="Demo") + shape = Shape( + name="lead", + envelopes=ShapeEnvelopes(volume=(15, 12), arpeggio=(0, 7), duty_cycle=(2,)), + root_pitch=55, + root_period=3, + loop_point=WHOLE_LOOP_POINT, + ) + project.voices.append(shape) + path = tmp_path / "demo.stp" + + ProjectContainer.save(project, path) + loaded = ProjectContainer.load(path) + + assert loaded.voices[0] == shape + restored = loaded.voice(shape.id) + assert isinstance(restored, Shape) + assert restored.envelopes == shape.envelopes + assert restored.root_pitch == shape.root_pitch + assert restored.root_period == shape.root_period + assert restored.loop_point == shape.loop_point + + def test_a_shape_leaves_no_reconstruction_in_the_archive(self, tmp_path: Path) -> None: + project = Project.create(title="Demo") + project.voices.append(Shape(name="lead")) + path = tmp_path / "demo.stp" + + ProjectContainer.save(project, path) + + with zipfile.ZipFile(path) as archive: + assert [name for name in archive.namelist() if name.startswith(RECONSTRUCTIONS_DIRECTORY)] == [] + + def test_both_kinds_share_one_pool_in_their_written_order( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + project = Project.create(title="Demo") + sample = Sample(name="bass", reconstruction=reconstruction_factory()) + shape = Shape(name="lead") + project.voices.extend([sample, shape]) + path = tmp_path / "demo.stp" + + ProjectContainer.save(project, path) + loaded = ProjectContainer.load(path) + + assert [voice.id for voice in loaded.voices] == [sample.id, shape.id] + assert isinstance(loaded.voices[0], Sample) + assert isinstance(loaded.voices[1], Shape) + + def test_a_row_naming_a_shape_still_names_it_after_a_round_trip( + self, + tmp_path: Path, + ) -> None: + project = Project.create(title="Demo") + shape = Shape(name="lead", envelopes=ShapeEnvelopes(volume=(15,))) + project.voices.append(shape) + project.song[ChannelName.PULSE1].patterns[0].rows[0] = Row(command=NoteOn(voice_id=shape.id)) + path = tmp_path / "demo.stp" + + ProjectContainer.save(project, path) + loaded = ProjectContainer.load(path) + + row = loaded.song[ChannelName.PULSE1].patterns[0].rows[0] + assert row.command is not None + assert loaded.voice(row.command.voice_id) is loaded.voices[0] + + class TestArchiveLayout: def test_unique_reconstructions_are_deduplicated( self, diff --git a/tests/unit/sampletones_core/project/voices/test_shape.py b/tests/unit/sampletones_core/project/voices/test_shape.py new file mode 100644 index 000000000..fef759dc2 --- /dev/null +++ b/tests/unit/sampletones_core/project/voices/test_shape.py @@ -0,0 +1,183 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import ( + RESTING_REFERENCE_PERIOD, + RESTING_REFERENCE_PITCH, + supported_features, + supports, +) +from sampletones_core.features.spec import CHANNEL_GENERATOR_KIND +from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.shape import Shape +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +VOLUME: Tuple[int, ...] = (15, 12, 9, 6) +ARPEGGIO: Tuple[int, ...] = (0, 0, 12, 12) +DUTY_CYCLE: Tuple[int, ...] = (2,) + + +def _shape(**overrides: object) -> Shape: + fields: dict = { + "name": "lead", + "envelopes": ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), + } + fields.update(overrides) + return Shape(**fields) + + +class TestShapeIdentity: + def test_each_shape_gets_its_own_id(self) -> None: + assert _shape().id != _shape().id + + def test_clone_gets_a_fresh_id_and_carries_the_rest(self) -> None: + shape = _shape(root_pitch=48, root_period=3, loop_point=WHOLE_LOOP_POINT) + clone = shape.clone() + + assert clone.id != shape.id + assert clone.name == shape.name + assert clone.envelopes == shape.envelopes + assert clone.root_pitch == shape.root_pitch + assert clone.root_period == shape.root_period + assert clone.loop_point == shape.loop_point + + +class TestShapeRoots: + def test_a_shape_rests_where_a_channel_added_by_hand_rests(self) -> None: + shape = Shape(name="lead") + + assert shape.root_pitch == RESTING_REFERENCE_PITCH + assert shape.root_period == RESTING_REFERENCE_PERIOD + + def test_the_tonal_channels_read_the_pitch_and_noise_reads_the_period(self) -> None: + shape = _shape(root_pitch=55, root_period=3) + + assert shape.reference(ChannelName.PULSE1) == 55 + assert shape.reference(ChannelName.PULSE2) == 55 + assert shape.reference(ChannelName.TRIANGLE) == 55 + assert shape.reference(ChannelName.NOISE) == 3 + + +class TestShapeFeatures(BaseTestSuite): + """One set of envelopes, read on every channel in the dimensions that channel offers.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + + test_cases = ( + TestCase(label=ChannelName.PULSE1.value, channel_name=ChannelName.PULSE1), + TestCase(label=ChannelName.PULSE2.value, channel_name=ChannelName.PULSE2), + TestCase(label=ChannelName.TRIANGLE.value, channel_name=ChannelName.TRIANGLE), + TestCase(label=ChannelName.NOISE.value, channel_name=ChannelName.NOISE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_channel_reads_the_dimensions_it_offers(self, test_case: TestCase) -> None: + features = _shape().features(test_case.channel_name) + kind = CHANNEL_GENERATOR_KIND[test_case.channel_name] + + assert set(features.keys()) >= set(supported_features(kind)) + assert (features.duty_cycle is not None) is supports(kind, FeatureKey.DUTY_CYCLE) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_arpeggio_is_measured_against_the_channels_root(self, test_case: TestCase) -> None: + shape = _shape() + + assert shape.features(test_case.channel_name).initial_pitch == shape.reference(test_case.channel_name) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_channel_sounds_one_frame_per_tick(self, test_case: TestCase) -> None: + shape = _shape() + + assert len(shape.instructions(test_case.channel_name)) == shape.envelopes.frame_count + + +class TestShapeInstructions: + def test_a_pulse_frame_carries_the_volume_duty_and_root(self) -> None: + first = _shape().instructions(ChannelName.PULSE1)[0] + + assert first == PulseInstruction( + on=True, + pitch=RESTING_REFERENCE_PITCH, + volume=VOLUME[0], + duty_cycle=DUTY_CYCLE[0], + ) + + def test_the_arpeggio_moves_the_frame_off_the_root(self) -> None: + instructions = _shape().instructions(ChannelName.PULSE1) + third = instructions[2] + + assert isinstance(third, PulseInstruction) + assert third.pitch == RESTING_REFERENCE_PITCH + ARPEGGIO[2] + + def test_a_triangle_frame_sounds_at_the_root(self) -> None: + first = _shape().instructions(ChannelName.TRIANGLE)[0] + + assert first == TriangleInstruction(on=True, pitch=RESTING_REFERENCE_PITCH) + + def test_a_noise_frame_takes_the_period_root_and_the_short_mode(self) -> None: + first = _shape().instructions(ChannelName.NOISE)[0] + + assert first == NoiseInstruction( + on=True, + period=RESTING_REFERENCE_PERIOD, + volume=VOLUME[0], + short=True, + ) + + def test_a_shape_writing_nothing_sounds_on_no_channel(self) -> None: + shape = Shape(name="empty") + + assert all(not shape.instructions(channel_name) for channel_name in ChannelName.items()) + + def test_an_edit_reaches_the_frames(self) -> None: + shape = _shape() + before = shape.instructions(ChannelName.PULSE1) + + shape.envelopes = shape.envelopes.with_envelope(FeatureKey.ARPEGGIO, (7,)) + shape.invalidate() + + after = shape.instructions(ChannelName.PULSE1) + assert after != before + assert isinstance(after[0], PulseInstruction) + assert after[0].pitch == RESTING_REFERENCE_PITCH + 7 + + +class TestHeldDimensions: + def test_an_empty_envelope_is_left_to_the_channel(self) -> None: + shape = Shape(name="lead", envelopes=ShapeEnvelopes(arpeggio=ARPEGGIO)) + + held = shape.held_features(ChannelName.PULSE1) + + assert FeatureKey.VOLUME in held + assert FeatureKey.DUTY_CYCLE in held + assert FeatureKey.ARPEGGIO not in held + + def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None: + shape = Shape(name="lead", envelopes=ShapeEnvelopes(arpeggio=ARPEGGIO)) + + assert FeatureKey.DUTY_CYCLE not in shape.held_features(ChannelName.TRIANGLE) + + +class TestEnvelopeBounds: + def test_a_volume_past_the_range_is_refused(self) -> None: + with pytest.raises(ValidationError): + ShapeEnvelopes(volume=(MAX_VOLUME + 1,)) + + def test_a_dimension_a_shape_writes_none_of_is_refused(self) -> None: + with pytest.raises(KeyError): + ShapeEnvelopes().with_envelope(FeatureKey.PITCH, (1,)) + + def test_the_frame_count_is_the_longest_dimension(self) -> None: + envelopes = ShapeEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE) + + assert envelopes.frame_count == len(VOLUME) From d2e188223c16b0a80044ca8fcf078c47e913cde7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 04:36:49 +0200 Subject: [PATCH 075/142] Carried: a shape into the tracker formats and the console --- .../logic/reconstruction/instruments.py | 4 +- .../logic/reconstruction/reconstruction.py | 2 +- .../logic/sequencer/samples.py | 2 +- src/sampletones_core/exporters/slices.py | 129 ++++++++++++----- .../exports/implementation/famitracker.py | 2 +- src/sampletones_core/exports/request.py | 7 +- .../formats/bitphase/builder.py | 8 +- .../formats/bitphase/envelopes.py | 17 +-- .../formats/bitphase/preset.py | 2 +- .../formats/famitracker/builder.py | 39 +++--- .../formats/famitracker/footprint.py | 15 +- .../formats/famitracker/sequences/features.py | 27 ++-- src/sampletones_core/project/voices/shape.py | 47 ++++++- src/sampletones_player/builder.py | 2 +- tests/integration/nsf/test_backend.py | 2 +- .../services/test_export.py | 2 +- tests/suite/player.py | 3 +- .../logic/reconstruction/test_instruments.py | 4 +- .../logic/sequencer/test_samples.py | 2 +- .../services/export/test_service.py | 2 +- .../sampletones_core/exporters/test_slices.py | 131 ++++++++++++++---- .../sampletones_core/exports/test_bitphase.py | 2 +- .../exports/test_famitracker.py | 2 +- .../formats/bitphase/conftest.py | 5 +- .../formats/bitphase/test_envelopes.py | 39 +++--- .../formats/bitphase/test_preset.py | 3 +- .../formats/bitphase/test_shape_document.py | 75 ++++++++++ .../famitracker/sequences/test_features.py | 31 +++-- .../formats/famitracker/test_footprint.py | 37 ++--- .../formats/famitracker/test_fti.py | 7 +- .../formats/famitracker/test_shape_module.py | 128 +++++++++++++++++ .../sampletones_player/test_shape_song.py | 46 ++++++ 32 files changed, 638 insertions(+), 186 deletions(-) create mode 100644 tests/unit/sampletones_core/formats/bitphase/test_shape_document.py create mode 100644 tests/unit/sampletones_core/formats/famitracker/test_shape_module.py create mode 100644 tests/unit/sampletones_player/test_shape_song.py diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 54481a355..398cd3bfa 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -86,14 +86,14 @@ def _build_footprint( ) -> SampleFootprintViewModel: """Measures each playing channel's instrument as the size its own export writes. - A reconstruction has no loop flag of its own — that belongs to a sample placed in a + A reconstruction has no loop point of its own — that belongs to a voice placed in a project — so each instrument is measured playing its envelopes once, matching what **Export instrument...** produces. A channel standing by is written nowhere, so it is measured nowhere and the sample's total names what the export costs. """ return SampleFootprintViewModel.from_footprints( { - channel_name: features_footprint(features, loop=False) + channel_name: features_footprint(features, loop_point=None) for channel_name, features in channels.items() if features.has_frames } diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 054ff344a..76f5fe25f 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -548,7 +548,7 @@ def _instrument_export( name=name, channel=channel_name, features=feature, - loop=False, + loop_point=None, nes_frequency=self._nes_frequency(), tuning=self._tuning(), ) diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py index c11cebf7a..ebae12aeb 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -97,7 +97,7 @@ def build_sample_footprint(self, voice_id: str) -> Optional[SampleFootprintViewM return None return SampleFootprintViewModel.from_footprints( - reconstruction_footprints(sample.reconstruction, loop=sample.loops) + reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) ) def sample_name(self, voice_id: str) -> str: diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 4948cfe47..21758b573 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -1,19 +1,20 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, Iterator, Tuple +from typing import Dict, Iterator, Optional, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.project.project import Project from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.voice import samples +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels @dataclass(frozen=True) class InstrumentSlot: - """Where a voice's channel slice landed in the instrument table.""" + """Where a row naming a voice on one channel lands: the instrument it plays and its reference.""" index: int initial_pitch: int @@ -24,23 +25,21 @@ class InstrumentSlot: @dataclass(frozen=True) class VoiceSlice: - """One channel slice of a project voice, numbered for the instrument table. + """What one channel of one voice plays, in the envelope terms every backend reads. Attributes: - index: Position the slice takes in the exported instrument table. voice: The voice the slice came from. channel: The NES channel the slice covers. features: The per-dimension envelopes describing the slice. """ - index: int - voice: Sample + voice: VoiceUnion channel: ChannelName features: Features @property def instrument_name(self) -> str: - """The exported instrument's name, naming both its sample and its channel.""" + """The exported instrument's name, naming both its voice and its channel.""" return instrument_slice_name(self.voice.name, self.channel) @property @@ -48,41 +47,101 @@ def key(self) -> Tuple[str, ChannelName]: """The identity a pattern row references the slice by.""" return (self.voice.id, self.channel) - @property - def slot(self) -> InstrumentSlot: - """The table position and reference pitch a pattern row resolves through.""" - return InstrumentSlot( - index=self.index, - initial_pitch=self.features.initial_pitch, - ) + +@dataclass(frozen=True) +class InstrumentEntry: + """One instrument an export writes, and the channels whose rows reach it. + + A sample's channels each carry frames of their own, so each becomes an instrument answering + for that channel alone. A shape carries one set of envelopes every channel reads, so it + becomes one instrument answering for every channel it sounds on, each against its own root — + which is the instrument model FamiTracker itself uses. + + Attributes: + index: Position the instrument takes in the exported table. + voice_id: The voice a row names to reach it. + name: The name the tracker lists it by. + features: The envelopes written into it. + loop_point: The tick its envelopes repeat from, or ``None`` where they play once. + slots: Per channel it answers for, the table position and the reference that channel reads. + """ + + index: int + voice_id: str + name: str + features: Features + loop_point: Optional[int] + slots: Dict[ChannelName, InstrumentSlot] def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: - """Walks every channel slice of every voice in instrument-table order. + """Walks what every channel of every voice plays, in voice order then channel order. + + A voice contributes one slice per channel it sounds on, so a sample yields one to four and a + shape yields one per channel its envelopes make a frame for. Each voice is read once, so a + caller reads a reconstruction's envelopes at a single cost. + + Args: + project: The project whose voices are exported. - A voice contributes one slice per channel that plays, so a sample yields one to four. Slices - are numbered in voice order, then channel order, which fixes the instrument numbering every - tracker format builds on. Each voice's features are exported once, so a caller reads a - reconstruction's envelopes at a single cost. + Yields: + VoiceSlice: What each channel of each voice plays. + """ + for voice in project.voices: + match voice: + case Sample(): + features_by_channel = voice.reconstruction.export() + for channel in ChannelName.items(): + features = features_by_channel[channel] + if features.has_frames: + yield VoiceSlice(voice=voice, channel=channel, features=features) + case Shape(): + for channel in voice_channels(voice): + yield VoiceSlice(voice=voice, channel=channel, features=voice.features(channel)) + + +def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]: + """Walks the instruments an export writes, numbered in voice order then channel order. Args: project: The project whose voices are exported. Yields: - VoiceSlice: Each slice alongside the index it takes in the instrument table. + InstrumentEntry: Each instrument alongside the channels whose rows reach it. """ index = 0 - for voice in samples(project.voices): - features_by_channel = voice.reconstruction.export() - for channel in ChannelName.items(): - features = features_by_channel[channel] - if not features.has_frames: - continue - - yield VoiceSlice( - index=index, - voice=voice, - channel=channel, - features=features, - ) - index += 1 + for voice in project.voices: + match voice: + case Sample(): + features_by_channel = voice.reconstruction.export() + for channel in ChannelName.items(): + features = features_by_channel[channel] + if not features.has_frames: + continue + + yield InstrumentEntry( + index=index, + voice_id=voice.id, + name=instrument_slice_name(voice.name, channel), + features=features, + loop_point=voice.loop_point, + slots={channel: InstrumentSlot(index=index, initial_pitch=features.initial_pitch)}, + ) + index += 1 + case Shape(): + channels = voice_channels(voice) + if not channels: + continue + + yield InstrumentEntry( + index=index, + voice_id=voice.id, + name=voice.name, + features=voice.instrument_features(), + loop_point=voice.loop_point, + slots={ + channel: InstrumentSlot(index=index, initial_pitch=voice.reference(channel)) + for channel in channels + }, + ) + index += 1 diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index 205922c8e..59b74914a 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -60,7 +60,7 @@ def write_instrument( STANDALONE_INSTRUMENT_INDEX, request.name, request.features, - loop=request.loop, + loop_point=request.loop_point, ) write_fti(destination, instrument) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) diff --git a/src/sampletones_core/exports/request.py b/src/sampletones_core/exports/request.py index 2aaa8153f..df7f11aa2 100644 --- a/src/sampletones_core/exports/request.py +++ b/src/sampletones_core/exports/request.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Tuple +from typing import Optional, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features @@ -15,7 +15,8 @@ class InstrumentExport: name: Name the written instrument carries. channel: The NES channel the slice was reconstructed for. features: The per-dimension envelopes describing the slice. - loop: Whether the instrument repeats its envelopes while its note is held. + loop_point: The tick the instrument repeats from while its note is held, or ``None`` + where it plays its envelopes once. nes_frequency: Rate in Hz the envelopes advance at, one item per tick. tuning: Where concert pitch sat for the reconstruction the slice came from. """ @@ -23,7 +24,7 @@ class InstrumentExport: name: str channel: ChannelName features: Features - loop: bool + loop_point: Optional[int] nes_frequency: int tuning: Tuning diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 152c882f9..e039e7cf6 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -279,7 +279,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: features_to_envelopes( instrument.features, instrument.channel, - loop=instrument.loop, + loop_point=instrument.loop_point, ), maximum_table_id=MAX_TABLE_ID, ) @@ -332,14 +332,14 @@ def _build_voice_table( voices: List[SliceVoice] = [] by_reference: SliceVoiceTable = {} - for voice_slice in iterate_voice_slices(project): + for index, voice_slice in enumerate(iterate_voice_slices(project)): envelopes = features_to_envelopes( voice_slice.features, voice_slice.channel, - loop=voice_slice.voice.loops, + loop_point=voice_slice.voice.loop_point, ) voice = _build_slice_voice( - voice_slice.index, + index, voice_slice.instrument_name, voice_slice.channel, voice_slice.features.initial_pitch, diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index c163e958b..d6c9ebd4b 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -91,17 +91,17 @@ def features_to_envelopes( features: Features, channel: ChannelName, *, - loop: bool, + loop_point: Optional[int], ) -> ChannelEnvelopes: """Converts one channel slice's envelopes into Bitphase instrument and table rows. Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's waveform field, and the arpeggio becomes the table contour that moves the note. A slice that leaves its volume to the channel takes a full level for every frame it - describes, so the channel governs how loud it sounds. A looping slice returns to its - first row so it sustains for as long as the note is held; a one-shot returns to its - last row, resting on the level its volume envelope ends with — silence where the - slice writes its own, the channel's level where it holds one. + describes, so the channel governs how loud it sounds. A slice with a loop point returns to + that row so it sustains for as long as the note is held; a one-shot returns to its last row, + resting on the level its volume envelope ends with — silence where the slice writes its own, + the channel's level where it holds one. A slice describing no frame comes back as the one silent row that is the smallest instrument Bitphase plays. @@ -109,7 +109,8 @@ def features_to_envelopes( Args: features: The per-dimension envelopes describing the slice. channel: The NES channel the slice was reconstructed for. - loop: Whether the instrument repeats its envelopes while its note is held. + loop_point: The row the instrument repeats from while its note is held, or ``None`` + where it plays its rows once. Returns: ChannelEnvelopes: The rows, contour, and loop point describing the slice. @@ -119,7 +120,7 @@ def features_to_envelopes( FeatureKey.ARPEGGIO: features.arpeggio, FeatureKey.DUTY_CYCLE: features.duty_cycle, } - items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop) + items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop_point is not None) frames = max(len(values) for values in items.values()) if not frames: @@ -146,5 +147,5 @@ def features_to_envelopes( return ChannelEnvelopes( rows=rows, table_rows=table_rows, - loop=LOOP_FROM_START if loop else len(rows) - 1, + loop=(min(loop_point, len(rows) - 1) if loop_point is not None else len(rows) - 1), ) diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index 0479bc2b3..3c86a399c 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -64,7 +64,7 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: envelopes = features_to_envelopes( request.features, request.channel, - loop=request.loop, + loop_point=request.loop_point, ) offsets = _tone_offsets( request.channel, diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index fafdb9e39..54cd5ebcb 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -5,7 +5,7 @@ from sampletones_core.exporters.slices import ( InstrumentSlot, InstrumentTable, - iterate_voice_slices, + iterate_instrument_entries, ) from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.module import ( @@ -66,18 +66,19 @@ def build_instrument( name: str, features: Features, *, - loop: bool, + loop_point: Optional[int], ) -> Instrument2A03: - """Builds one FamiTracker instrument from the envelopes of a channel slice. + """Builds one FamiTracker instrument from a set of envelopes. - The slice's envelopes become the instrument's five 2A03 sequences, so an instrument reaching a + The envelopes become the instrument's five 2A03 sequences, so an instrument reaching a ``.fti`` file on its own and one taking a slot in a module are built the same way. Args: index: The slot the instrument is numbered under. name: The name FamiTracker lists the instrument by. features: The per-dimension envelopes the sequences are read from. - loop: Whether every populated sequence loops from its first item, sustaining a held note. + loop_point: The item every populated sequence repeats from, sustaining a held note, or + ``None`` where the instrument plays its envelopes once. Returns: The instrument the envelopes describe. @@ -88,7 +89,7 @@ def build_instrument( pitch=features.pitch, hi_pitch=features.hi_pitch, duty_cycle=features.duty_cycle, - loop=loop, + loop_point=loop_point, ) return Instrument2A03( @@ -99,28 +100,32 @@ def build_instrument( def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], InstrumentTable]: - """Builds one FamiTracker instrument per channel slice of every sample. + """Builds the module's instruments and the table a pattern row resolves through. - Each sample contributes one instrument for every channel its reconstruction - covers, so a sample yields one to four instruments. Instruments are numbered in - sample order, then channel order. + A sample contributes one instrument for every channel its reconstruction covers, so it yields + one to four; a shape contributes one instrument every channel it sounds on reaches, each + against that channel's own root. Instruments are numbered in voice order, then channel order. + + Raises: + ValueError: If the project holds more instruments than FamiTracker has room for. """ instruments: List[Instrument2A03] = [] slots: InstrumentTable = {} - for voice_slice in iterate_voice_slices(project): - if voice_slice.index >= MAX_INSTRUMENTS: + for entry in iterate_instrument_entries(project): + if entry.index >= MAX_INSTRUMENTS: raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments") instruments.append( build_instrument( - voice_slice.index, - voice_slice.instrument_name, - voice_slice.features, - loop=voice_slice.voice.loops, + entry.index, + entry.name, + entry.features, + loop_point=entry.loop_point, ) ) - slots[voice_slice.key] = voice_slice.slot + for channel, slot in entry.slots.items(): + slots[(entry.voice_id, channel)] = slot return instruments, slots diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index 40f6a9a64..e3e4f4faa 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Iterable +from typing import Dict, Iterable, Optional from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features @@ -69,7 +69,7 @@ def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint: def features_footprint( features: Features, *, - loop: bool, + loop_point: Optional[int], ) -> InstrumentFootprint: """Measures the instrument a channel slice's envelopes export to. @@ -79,7 +79,8 @@ def features_footprint( Args: features: The per-dimension envelopes describing the slice. - loop: Whether the instrument loops while its note is held, which decides the shared length. + loop_point: The tick the instrument repeats from, which decides the shared length, or + ``None`` where it plays its envelopes once. Returns: InstrumentFootprint: The footprint of the instrument those envelopes describe. @@ -90,7 +91,7 @@ def features_footprint( pitch=features.pitch, hi_pitch=features.hi_pitch, duty_cycle=features.duty_cycle, - loop=loop, + loop_point=loop_point, ) return sequences_footprint(sequences.values()) @@ -98,7 +99,7 @@ def features_footprint( def reconstruction_footprints( reconstruction: Reconstruction, *, - loop: bool, + loop_point: Optional[int], ) -> Dict[ChannelName, InstrumentFootprint]: """Measures one instrument per channel a reconstruction plays. @@ -108,13 +109,13 @@ def reconstruction_footprints( Args: reconstruction: The reconstruction whose channels are measured. - loop: Whether the sample carrying it loops while its note is held. + loop_point: The tick the sample carrying it repeats from, or ``None`` where it plays once. Returns: Dict[ChannelName, InstrumentFootprint]: The footprint of each playing channel's instrument. """ return { - channel_name: features_footprint(features, loop=loop) + channel_name: features_footprint(features, loop_point=loop_point) for channel_name, features in reconstruction.export().items() if features.has_frames } diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index 24e2057f8..683655268 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -20,7 +20,7 @@ def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: def _sequence_items( arrays: Dict[SequenceKind, Optional[np.ndarray]], - loop: bool, + loops: bool, ) -> Dict[SequenceKind, Tuple[int, ...]]: """Reads the dimensions as the item tuples an instrument stores. @@ -31,8 +31,8 @@ def _sequence_items( instrument on their own. """ items_by_kind = {kind: _to_items(array) for kind, array in arrays.items()} - if loop: - return equalize_lengths(items_by_kind, loop, limit=MAX_SEQUENCE_ITEMS) + if loops: + return equalize_lengths(items_by_kind, loops, limit=MAX_SEQUENCE_ITEMS) return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS) @@ -44,16 +44,17 @@ def features_to_instrument_sequences( pitch: Optional[np.ndarray], hi_pitch: Optional[np.ndarray], duty_cycle: Optional[np.ndarray], - loop: bool, + loop_point: Optional[int], ) -> Dict[SequenceKind, InstrumentSequence]: """Builds the five 2A03 sequences from per-dimension envelope arrays. Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as ``None`` or as an empty envelope becomes a disabled sequence the instrument stores nothing for. Item counts stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer - reconstruction exports its opening frames and the shortening is logged. When ``loop`` - is set, every populated sequence loops from its first item so the instrument sustains - on a held note, and the populated dimensions share one length to repeat in step. + reconstruction exports its opening frames and the shortening is logged. A ``loop_point`` + sets every populated sequence to repeat from that item so the instrument sustains on a held + note, and the populated dimensions share one length to repeat in step; a point beyond a + sequence's own items repeats its final item, which is the value it would hold anyway. """ arrays: Dict[SequenceKind, Optional[np.ndarray]] = { SequenceKind.VOLUME: volume, @@ -63,15 +64,21 @@ def features_to_instrument_sequences( SequenceKind.DUTY: duty_cycle, } - items_by_kind = _sequence_items(arrays, loop) + items_by_kind = _sequence_items(arrays, loop_point is not None) sequences: Dict[SequenceKind, InstrumentSequence] = {} for kind, items in items_by_kind.items(): - loop_point = LOOP_FROM_START if loop and items else NO_LOOP_POINT sequences[kind] = InstrumentSequence( kind=kind, items=items, - loop_point=loop_point, + loop_point=_loop_item(loop_point, len(items)), ) return sequences + + +def _loop_item(loop_point: Optional[int], length: int) -> int: + if loop_point is None or not length: + return NO_LOOP_POINT + + return max(LOOP_FROM_START, min(loop_point, length - 1)) diff --git a/src/sampletones_core/project/voices/shape.py b/src/sampletones_core/project/voices/shape.py index 18cd86959..cd2cdc4b4 100644 --- a/src/sampletones_core/project/voices/shape.py +++ b/src/sampletones_core/project/voices/shape.py @@ -109,13 +109,33 @@ def features(self, channel_name: ChannelName) -> Features: Features: The per-dimension envelopes for that channel. """ kind = CHANNEL_GENERATOR_KIND[channel_name] + length = self.envelopes.frame_count return Features( initial_pitch=self.reference(channel_name), - volume=_items(self.envelopes.volume), - arpeggio=_items(self.envelopes.arpeggio), + volume=_items(self.envelopes.volume, length), + arpeggio=_items(self.envelopes.arpeggio, length), pitch=None, hi_pitch=None, - duty_cycle=(_items(self.envelopes.duty_cycle) if supports(kind, FeatureKey.DUTY_CYCLE) else None), + duty_cycle=(_items(self.envelopes.duty_cycle, length) if supports(kind, FeatureKey.DUTY_CYCLE) else None), + ) + + def instrument_features(self) -> Features: + """The envelopes as a tracker instrument holds them: every dimension the shape writes. + + A tracker instrument is one set of sequences whatever channel plays it, and each channel + reads what it can of them — which is why a shape reaches a tracker as a single instrument. + + Returns: + Features: The envelopes, measured against the shape's tonal root. + """ + length = self.envelopes.frame_count + return Features( + initial_pitch=self.root_pitch, + volume=_items(self.envelopes.volume, length), + arpeggio=_items(self.envelopes.arpeggio, length), + pitch=None, + hi_pitch=None, + duty_cycle=_items(self.envelopes.duty_cycle, length), ) @cached_property @@ -160,5 +180,22 @@ def __repr__(self) -> str: return f"Shape(id={self.id!r}, name={self.name!r})" -def _items(envelope: Tuple[int, ...]) -> np.ndarray: - return np.array(envelope, dtype=np.int8) +def _items(envelope: Tuple[int, ...], length: int) -> np.ndarray: + """One dimension brought to the length the shape's longest runs, holding its final value. + + A tracker advances each sequence on a counter of its own, so a dimension shorter than the rest + would circle at its own pace once the shape repeats. Running every written dimension the same + length keeps a tracker sounding the shape the way the engine here plays it, where a dimension + holds its final value for as long as the note lasts. + + Args: + envelope: The items the dimension states, empty where the channel governs it. + length: The ticks the shape's longest dimension runs. + + Returns: + np.ndarray: The dimension's items, empty where the channel governs it. + """ + if not envelope: + return np.array([], dtype=np.int8) + + return np.array(envelope + (envelope[-1],) * (length - len(envelope)), dtype=np.int8) diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 2d323a344..5e099eac4 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -136,7 +136,7 @@ def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optio Returns: Optional[int]: The tick to return to, or ``None`` where the song stops at its end. """ - if instruments and all(instrument.loop for instrument in instruments): + if instruments and all(instrument.loop_point is not None for instrument in instruments): return SONG_START return None diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 6f1a4dbc4..1d002dcbe 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -71,7 +71,7 @@ def sample_request(sample: Sample) -> SampleExport: name=instrument_slice_name(sample.name, channel), channel=channel, features=features, - loop=sample.loops, + loop_point=sample.loop_point, nes_frequency=config.nes_frequency, tuning=config.tuning, ) diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index d962c030a..afb4620d8 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -61,7 +61,7 @@ def instrument_export(name: str, features: Features) -> InstrumentExport: name=name, channel=ChannelName.PULSE1, features=features, - loop=False, + loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/suite/player.py b/tests/suite/player.py index ae53b48f9..1ca99a7b9 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -10,6 +10,7 @@ from sampletones_core.exporters import Features from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule @@ -255,7 +256,7 @@ def player_instrument( name=name, channel=channel, features=features, - loop=loop, + loop_point=WHOLE_LOOP_POINT if loop else None, nes_frequency=nes_frequency, tuning=tuning, ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 4df890955..c267a2847 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -146,7 +146,7 @@ def test_the_size_is_the_one_a_one_shot_export_writes( footprint = received[0].footprint assert footprint is not None expected = total_footprint( - features_footprint(features, loop=False) + features_footprint(features, loop_point=None) for features in feature_data.channels.values() if features.has_frames ) @@ -175,7 +175,7 @@ def test_an_envelope_edit_is_measured_as_it_arrives( edited[FeatureKey.VOLUME] = volume footprint = received[0].footprint assert footprint is not None - assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited, loop=False).total_bytes + assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited, loop_point=None).total_bytes def test_a_bar_edit_is_measured_as_it_arrives( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index 149023ffe..b691a33d7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -191,7 +191,7 @@ def test_it_measures_the_sample_under_its_own_loop_flag( footprint = logic.build_sample_footprint(sample.id) assert footprint == SampleFootprintViewModel.from_footprints( - reconstruction_footprints(sample.reconstruction, loop=True) + reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT) ) def test_a_looping_sample_costs_less_than_a_one_shot( diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index a84073924..4fc6f63f3 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -127,7 +127,7 @@ def build_instrument(name: str = "Lead") -> InstrumentExport: hi_pitch=None, duty_cycle=None, ), - loop=False, + loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index e1952a5bb..a80c7093c 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -3,18 +3,24 @@ import numpy as np from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters.slices import iterate_voice_slices +from sampletones_core.exporters.slices import ( + iterate_instrument_entries, + iterate_voice_slices, +) from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.envelopes import ShapeEnvelopes from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.structures import IdentifiedCollection from tests.suite.sequencer import sample_reconstruction -def _project(samples: Sequence[Sample]) -> Project: - collection: IdentifiedCollection[Sample] = IdentifiedCollection() - for sample in samples: - collection.append(sample) +def _project(voices: Sequence[VoiceUnion]) -> Project: + collection: IdentifiedCollection[VoiceUnion] = IdentifiedCollection() + for voice in voices: + collection.append(voice) project = Project.create(title="Slices", author="Tester", settings=ProjectSettings()) project.voices = collection @@ -25,12 +31,22 @@ def _sample(name: str, channels: Sequence[ChannelName]) -> Sample: return Sample(name=name, reconstruction=sample_reconstruction(list(channels))) -class TestSampleSlices: - """The walk numbers the instruments a module writes, so it visits the channels that play. +def _shape(name: str) -> Shape: + return Shape(name=name, envelopes=ShapeEnvelopes(volume=(15, 10), arpeggio=(0, 5))) - A sample carries every channel whatever it sounds, and one standing by is written nowhere, - so it takes no place in the instrument table and shifts no index behind it. - """ + +def _stand_by(sample: Sample, channel: ChannelName) -> None: + sample.reconstruction.update_channel_data( + channel, + [], + np.zeros(0, dtype=np.float32), + sample.reconstruction.initial_pitches[channel], + (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + + +class TestVoiceSlices: + """What every channel of every voice plays, which is what a per-channel backend reads.""" def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None: project = _project([_sample("lead", [ChannelName.PULSE1, ChannelName.NOISE])]) @@ -42,31 +58,98 @@ def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None: ChannelName.NOISE, ] - def test_a_channel_standing_by_takes_no_place_in_the_table(self) -> None: + def test_a_channel_standing_by_takes_no_slice(self) -> None: sample = _sample("lead", [ChannelName.PULSE1, ChannelName.PULSE2]) - sample.reconstruction.update_channel_data( - ChannelName.PULSE1, - [], - np.zeros(0, dtype=np.float32), - sample.reconstruction.initial_pitches[ChannelName.PULSE1], - (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), - ) + _stand_by(sample, ChannelName.PULSE1) project = _project([sample]) slices = list(iterate_voice_slices(project)) - assert [(voice_slice.index, voice_slice.channel) for voice_slice in slices] == [ - (0, ChannelName.PULSE2), - ] + assert [voice_slice.channel for voice_slice in slices] == [ChannelName.PULSE2] + + def test_a_shape_contributes_a_slice_for_every_channel_it_sounds_on(self) -> None: + project = _project([_shape("lead")]) + + slices = list(iterate_voice_slices(project)) + + assert [voice_slice.channel for voice_slice in slices] == ChannelName.items() + + def test_each_of_a_shapes_slices_is_measured_against_that_channels_root(self) -> None: + shape = _shape("lead") + project = _project([shape]) + + for voice_slice in iterate_voice_slices(project): + assert voice_slice.features.initial_pitch == shape.reference(voice_slice.channel) + + def test_a_shape_writing_nothing_contributes_no_slice(self) -> None: + project = _project([Shape(name="empty")]) + + assert list(iterate_voice_slices(project)) == [] + + +class TestInstrumentEntries: + """The instruments an export writes, and the channels whose rows reach each one.""" + + def test_a_sample_yields_one_instrument_per_playing_channel(self) -> None: + project = _project([_sample("lead", [ChannelName.PULSE1, ChannelName.NOISE])]) + + entries = list(iterate_instrument_entries(project)) - def test_slices_are_numbered_across_the_samples_in_order(self) -> None: + assert [entry.index for entry in entries] == [0, 1] + assert [list(entry.slots) for entry in entries] == [[ChannelName.PULSE1], [ChannelName.NOISE]] + + def test_a_shape_yields_one_instrument_every_channel_reaches(self) -> None: + project = _project([_shape("lead")]) + + entries = list(iterate_instrument_entries(project)) + + assert len(entries) == 1 + assert list(entries[0].slots) == ChannelName.items() + assert {slot.index for slot in entries[0].slots.values()} == {0} + + def test_a_shapes_slots_each_carry_that_channels_root(self) -> None: + shape = _shape("lead") + project = _project([shape]) + + entry = next(iter(iterate_instrument_entries(project))) + + for channel, slot in entry.slots.items(): + assert slot.initial_pitch == shape.reference(channel) + + def test_a_shape_is_named_by_itself_and_a_sample_slice_by_its_channel(self) -> None: + project = _project([_shape("lead"), _sample("pad", [ChannelName.TRIANGLE])]) + + entries = list(iterate_instrument_entries(project)) + + assert entries[0].name == "lead" + assert entries[1].name == "pad (triangle)" + + def test_instruments_are_numbered_across_the_voices_in_order(self) -> None: project = _project( [ _sample("lead", [ChannelName.PULSE1]), + _shape("hand"), _sample("pad", [ChannelName.TRIANGLE, ChannelName.NOISE]), ] ) - indices: List[int] = [voice_slice.index for voice_slice in iterate_voice_slices(project)] + indices: List[int] = [entry.index for entry in iterate_instrument_entries(project)] + + assert indices == [0, 1, 2, 3] + + def test_a_channel_standing_by_shifts_no_index_behind_it(self) -> None: + sample = _sample("lead", [ChannelName.PULSE1, ChannelName.PULSE2]) + _stand_by(sample, ChannelName.PULSE1) + project = _project([sample]) + + entries = list(iterate_instrument_entries(project)) + + assert [(entry.index, list(entry.slots)) for entry in entries] == [(0, [ChannelName.PULSE2])] + + def test_a_shape_writing_nothing_takes_no_place_in_the_table(self) -> None: + project = _project([Shape(name="empty"), _sample("pad", [ChannelName.TRIANGLE])]) + + entries = list(iterate_instrument_entries(project)) - assert indices == [0, 1, 2] + assert [entry.index for entry in entries] == [0] + assert entries[0].name == "pad (triangle)" diff --git a/tests/unit/sampletones_core/exports/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py index 8a07af857..a949a60cc 100644 --- a/tests/unit/sampletones_core/exports/test_bitphase.py +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -52,7 +52,7 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: name=name, channel=ChannelName.PULSE1, features=build_features(frames), - loop=False, + loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index c7c3e82bd..722a259c6 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -44,7 +44,7 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: name=name, channel=ChannelName.PULSE1, features=build_features(frames), - loop=False, + loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index 314d7fc4a..f1178979c 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -5,6 +5,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 @@ -35,13 +36,13 @@ def build_instrument( features: Features, *, channel: ChannelName = ChannelName.PULSE1, - loop: bool = False, + loop_point: Optional[int] = None, ) -> InstrumentExport: return InstrumentExport( name=name, channel=channel, features=features, - loop=loop, + loop_point=loop_point, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index e8fc2c373..b568283d0 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -18,6 +18,7 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from .conftest import build_features @@ -46,7 +47,7 @@ def test_each_volume_item_becomes_one_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE @@ -54,7 +55,7 @@ def test_the_contour_becomes_the_table(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert list(envelopes.table_rows) == PITCH_CONTOUR @@ -67,7 +68,7 @@ def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidt envelopes = features_to_envelopes( build_features([15], duty_cycle=[case.duty_cycle]), case.channel, - loop=False, + loop_point=None, ) assert envelopes.rows[0].pulse_width == case.pulse_width @@ -75,7 +76,7 @@ def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.TRIANGLE, - loop=False, + loop_point=None, ) assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} @@ -84,7 +85,7 @@ def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: envelopes = features_to_envelopes( build_features([15] * len(steps), arpeggio=steps), ChannelName.NOISE, - loop=False, + loop_point=None, ) assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] @@ -99,7 +100,7 @@ def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), ChannelName.PULSE1, - loop=loop, + loop_point=WHOLE_LOOP_POINT if loop else None, ) assert len(envelopes.rows) == len(envelopes.table_rows) @@ -107,7 +108,7 @@ def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), ChannelName.PULSE1, - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert len(envelopes.rows) == 2 @@ -115,7 +116,7 @@ def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] @@ -123,7 +124,7 @@ def test_a_slice_without_a_contour_holds_its_note(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=[]), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) @@ -133,7 +134,7 @@ def test_a_looping_slice_returns_to_its_first_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.PULSE1, - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert envelopes.loop == LOOP_FROM_START @@ -141,7 +142,7 @@ def test_a_one_shot_rests_on_its_last_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert envelopes.loop == len(envelopes.rows) - 1 @@ -152,7 +153,7 @@ def test_a_one_shot_rests_in_silence(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME @@ -161,7 +162,7 @@ def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=loop, + loop_point=WHOLE_LOOP_POINT if loop else None, ) assert envelopes.loop < len(envelopes.rows) assert envelopes.loop < len(envelopes.table_rows) @@ -176,7 +177,7 @@ def test_it_holds_a_full_row_per_frame(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) @@ -184,7 +185,7 @@ def test_its_contour_still_moves_the_note(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert list(envelopes.table_rows) == PITCH_CONTOUR @@ -193,7 +194,7 @@ def test_its_duty_envelope_still_reaches_the_rows(self) -> None: envelopes = features_to_envelopes( build_features([], duty_cycle=duty_cycles), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert [row.pulse_width for row in envelopes.rows] == duty_cycles @@ -201,7 +202,7 @@ def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=False, + loop_point=None, ) assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE @@ -209,7 +210,7 @@ def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert len(envelopes.rows) == len(PITCH_CONTOUR) assert envelopes.loop == LOOP_FROM_START @@ -222,7 +223,7 @@ class TestAnEmptySlice: @pytest.fixture(name="envelopes") def envelopes_fixture(self) -> ChannelEnvelopes: - return features_to_envelopes(build_features([]), ChannelName.PULSE1, loop=False) + return features_to_envelopes(build_features([]), ChannelName.PULSE1, loop_point=None) def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 1e3345a16..a9d5e683e 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -19,6 +19,7 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument @@ -55,7 +56,7 @@ def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset def test_a_looping_slice_returns_to_its_first_row(self) -> None: preset = instrument_to_preset( - build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop=True), + build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop_point=WHOLE_LOOP_POINT), ) assert preset.loop == LOOP_FROM_START diff --git a/tests/unit/sampletones_core/formats/bitphase/test_shape_document.py b/tests/unit/sampletones_core/formats/bitphase/test_shape_document.py new file mode 100644 index 000000000..ed24e2fb7 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_shape_document.py @@ -0,0 +1,75 @@ +from typing import Final, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.shape import Shape + +ROWS_PER_PATTERN: Final[int] = 4 +VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) +ARPEGGIO: Final[Tuple[int, ...]] = (0, 4, 7) + + +def _project(*channels: ChannelName, loop_point: int | None = None) -> Tuple[Project, Shape]: + shape = Shape( + name="Lead", + envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), + loop_point=loop_point, + ) + project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) + project.voices.append(shape) + for channel in channels: + pattern = project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN) + pattern.rows[0] = Row(command=NoteOn(voice_id=shape.id)) + project.song.set_order_entry(0, channel, 0) + + return project, shape + + +class TestAShapeReachesTheDocument: + def test_each_channel_it_sounds_on_takes_an_instrument_of_its_own(self) -> None: + """Bitphase bakes registers per tick, so a channel's rows carry that channel's reading.""" + project, _ = _project(ChannelName.PULSE1, ChannelName.NOISE) + + document = project_to_bitphase(project) + + assert len(document.instruments) == len(ChannelName.items()) + + def test_every_instrument_runs_the_ticks_its_envelopes_describe(self) -> None: + project, _ = _project(ChannelName.PULSE1) + + document = project_to_bitphase(project) + + assert all(len(instrument.rows) == len(VOLUME) for instrument in document.instruments) + + def test_a_looping_shape_returns_to_its_loop_point(self) -> None: + project, _ = _project(ChannelName.PULSE1, loop_point=1) + + document = project_to_bitphase(project) + + assert all(instrument.loop == 1 for instrument in document.instruments) + + def test_a_one_shot_rests_on_its_final_row(self) -> None: + project, _ = _project(ChannelName.PULSE1) + + document = project_to_bitphase(project) + + assert all(instrument.loop == len(instrument.rows) - 1 for instrument in document.instruments) + + def test_the_table_carries_the_shapes_contour(self) -> None: + project, _ = _project(ChannelName.PULSE1) + + document = project_to_bitphase(project) + + assert any(tuple(table.rows) == ARPEGGIO for table in document.tables) + + def test_the_document_is_written_without_a_loop_past_its_rows(self) -> None: + project, _ = _project(ChannelName.PULSE1, loop_point=LOOP_FROM_START) + + document = project_to_bitphase(project) + + assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments) diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 9f625a53a..cf68e992d 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -9,6 +9,7 @@ NO_LOOP_POINT, SequenceKind, ) +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT class TestFeaturesToInstrumentSequences: @@ -19,7 +20,7 @@ def test_all_five_kinds_present(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert set(sequences) == set(SequenceKind) @@ -30,7 +31,7 @@ def test_populated_dimension_is_enabled_with_items(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) volume = sequences[SequenceKind.VOLUME] assert volume.enabled is True @@ -43,7 +44,7 @@ def test_missing_dimension_is_disabled(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert sequences[SequenceKind.PITCH].enabled is False assert sequences[SequenceKind.PITCH].items == () @@ -56,7 +57,7 @@ def test_items_are_python_ints(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert all(isinstance(item, int) for item in sequences[SequenceKind.VOLUME].items) assert all(isinstance(item, int) for item in sequences[SequenceKind.ARPEGGIO].items) @@ -68,7 +69,7 @@ def test_loop_sets_loop_point_on_populated_sequences(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START @@ -80,7 +81,7 @@ def test_loop_leaves_empty_sequences_unlooped(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert sequences[SequenceKind.PITCH].loop_point == NO_LOOP_POINT @@ -91,7 +92,7 @@ def test_no_loop_leaves_loop_point_disabled(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT @@ -104,7 +105,7 @@ def test_loop_drops_the_trailing_note_off_volume_item(self) -> None: pitch=None, hi_pitch=None, duty_cycle=np.array([1, 1, 2]), - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) @@ -118,7 +119,7 @@ def test_one_shot_carries_each_dimension_as_written(self) -> None: pitch=None, hi_pitch=None, duty_cycle=np.array([1]), - loop=False, + loop_point=None, ) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) @@ -131,7 +132,7 @@ def test_a_loop_brings_every_populated_dimension_to_one_length(self) -> None: pitch=np.array([0, 1]), hi_pitch=None, duty_cycle=np.array([1, 1, 2]), - loop=True, + loop_point=WHOLE_LOOP_POINT, ) lengths = {len(sequence.items) for sequence in sequences.values() if sequence.enabled} assert lengths == {2} @@ -143,7 +144,7 @@ def test_disabled_dimensions_stay_empty(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert sequences[SequenceKind.ARPEGGIO].items == () assert sequences[SequenceKind.PITCH].items == () @@ -156,7 +157,7 @@ def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) zeroed = features_to_instrument_sequences( volume=np.array([15, 0]), @@ -164,7 +165,7 @@ def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert cleared[SequenceKind.ARPEGGIO].enabled is False assert zeroed[SequenceKind.ARPEGGIO].enabled is True @@ -177,7 +178,7 @@ def test_all_dimensions_empty_stays_empty(self) -> None: pitch=None, hi_pitch=None, duty_cycle=None, - loop=True, + loop_point=WHOLE_LOOP_POINT, ) assert all(not sequence.enabled for sequence in sequences.values()) @@ -190,7 +191,7 @@ def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> Non pitch=None, hi_pitch=None, duty_cycle=None, - loop=False, + loop_point=None, ) assert all(len(sequence.items) <= MAX_SEQUENCE_ITEMS for sequence in sequences.values()) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index cd61c61b0..47c4e9355 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -26,6 +26,7 @@ MAX_SEQUENCE_ITEMS, SequenceKind, ) +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -55,37 +56,37 @@ class TestFeaturesFootprint(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): features: Features - loop: bool + loop_point: Optional[int] expected: InstrumentFootprint test_cases = ( TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), - loop=False, + loop_point=None, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_one_shot", ), TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), - loop=True, + loop_point=WHOLE_LOOP_POINT, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), label="pulse_loop", ), TestCase( features=build_features([15, 0], [0], [0]), - loop=False, + loop_point=None, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16), label="dimensions_of_differing_lengths", ), TestCase( features=build_features([15, 12, 0], [0, 1], None), - loop=False, + loop_point=None, expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13), label="triangle", ), TestCase( features=build_features([], [], None), - loop=False, + loop_point=None, expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0), label="silent", ), @@ -95,7 +96,7 @@ class TestCase(BaseRegularTestCase): [0] * OVER_LONG_LENGTH, None, ), - loop=False, + loop_point=None, expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), label="capped_at_the_sequence_limit", ), @@ -105,7 +106,7 @@ class TestCase(BaseRegularTestCase): [0] * MAX_SEQUENCE_ITEMS, [0] * MAX_SEQUENCE_ITEMS, ), - loop=False, + loop_point=None, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768), label="largest_instrument_famitracker_holds", ), @@ -116,17 +117,17 @@ def test_both_regions_are_measured_from_the_populated_sequences( self, test_case: TestCase, ) -> None: - assert features_footprint(test_case.features, loop=test_case.loop) == test_case.expected + assert features_footprint(test_case.features, loop_point=test_case.loop_point) == test_case.expected @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None: """Both entry points measure one export, so a slice reads the same either way.""" - instrument = build_instrument(0, test_case.label, test_case.features, loop=test_case.loop) + instrument = build_instrument(0, test_case.label, test_case.features, loop_point=test_case.loop_point) assert instrument_footprint(instrument) == test_case.expected @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_total_sums_both_regions(self, test_case: TestCase) -> None: - footprint = features_footprint(test_case.features, loop=test_case.loop) + footprint = features_footprint(test_case.features, loop_point=test_case.loop_point) assert footprint.total_bytes == test_case.expected.instrument_bytes + test_case.expected.sequence_bytes @@ -161,13 +162,13 @@ class TestReconstructionFootprints: def test_one_entry_per_playing_channel(self) -> None: """The sample holds every channel; the two that play are the two an export writes.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loops) + footprints = reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) assert set(footprints) == {ChannelName.PULSE1, ChannelName.TRIANGLE} def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None: """Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loops) + footprints = reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) pulse = footprints[ChannelName.PULSE1] triangle = footprints[ChannelName.TRIANGLE] assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES @@ -176,8 +177,8 @@ def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: sample = pulse_sample("lead", pitch=60) features = sample.reconstruction.export() for loop in (False, True): - assert reconstruction_footprints(sample.reconstruction, loop=loop) == { - channel_name: features_footprint(feature, loop=loop) + assert reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT if loop else None) == { + channel_name: features_footprint(feature, loop_point=WHOLE_LOOP_POINT if loop else None) for channel_name, feature in features.items() if feature.has_frames } @@ -185,7 +186,9 @@ def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: def test_looping_costs_the_shortest_dimensions_length(self) -> None: """A looping instrument shares the shortest dimension's length, so it stores fewer items.""" sample = pulse_sample("lead", pitch=60) - one_shot = total_footprint(reconstruction_footprints(sample.reconstruction, loop=False).values()) - looping = total_footprint(reconstruction_footprints(sample.reconstruction, loop=True).values()) + one_shot = total_footprint(reconstruction_footprints(sample.reconstruction, loop_point=None).values()) + looping = total_footprint( + reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT).values() + ) assert one_shot.instrument_bytes == looping.instrument_bytes assert looping.sequence_bytes < one_shot.sequence_bytes diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index 8503bd4bc..865e73ee1 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -10,6 +10,7 @@ from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, ) +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT GOLDEN_INSTRUMENT_NAME = "Test Instrument" GOLDEN_VOLUME = np.array([15, 12, 8, 0]) @@ -34,7 +35,7 @@ def build_instrument( pitch: Optional[np.ndarray] = None, hi_pitch: Optional[np.ndarray] = None, duty_cycle: Optional[np.ndarray] = None, - loop: bool = False, + loop_point: Optional[int] = None, index: int = 0, ) -> Instrument2A03: sequences = features_to_instrument_sequences( @@ -43,7 +44,7 @@ def build_instrument( pitch=pitch, hi_pitch=hi_pitch, duty_cycle=duty_cycle, - loop=loop, + loop_point=loop_point, ) return Instrument2A03(index=index, name=name, sequences=sequences) @@ -174,7 +175,7 @@ def test_missing_sequences_are_disabled(self, tmp_path: Path) -> None: def test_loop_flag_sets_loop_point(self, tmp_path: Path) -> None: path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Pad", volume=np.array([15, 10, 5]), loop=True)) + write_fti(path, build_instrument("Pad", volume=np.array([15, 10, 5]), loop_point=WHOLE_LOOP_POINT)) parsed = parse_fti(path.read_bytes()) assert parsed.sequences[0].loop_point == 0 diff --git a/tests/unit/sampletones_core/formats/famitracker/test_shape_module.py b/tests/unit/sampletones_core/formats/famitracker/test_shape_module.py new file mode 100644 index 000000000..f2d22b547 --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_shape_module.py @@ -0,0 +1,128 @@ +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.builder import build_instrument_table, project_to_module +from sampletones_core.formats.famitracker.model.pattern import PatternData +from sampletones_core.formats.famitracker.notes import period_to_note_cell, pitch_to_note_cell +from sampletones_core.formats.famitracker.specification.channels import CHANNEL_TO_ID +from sampletones_core.formats.famitracker.specification.sequences import ( + LOOP_FROM_START, + NO_LOOP_POINT, + SequenceKind, +) +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.shape import Shape + +ROWS_PER_PATTERN: Final[int] = 4 +VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) +ARPEGGIO: Final[Tuple[int, ...]] = (0, 4, 7) +DUTY_CYCLE: Final[Tuple[int, ...]] = (2,) +TAIL_LOOP_POINT: Final[int] = 1 +TRANSPOSE: Final[int] = 5 + + +def _shape(loop_point: int | None = None) -> Shape: + return Shape( + name="Lead", + envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), + loop_point=loop_point, + ) + + +def _project(shape: Shape, *channels: ChannelName, transpose: int = 0) -> Project: + project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) + project.voices.append(shape) + for channel in channels: + pattern = project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN) + pattern.rows[0] = Row(command=NoteOn(voice_id=shape.id), transpose=transpose) + project.song.set_order_entry(0, channel, 0) + + return project + + +def _rows(patterns: List[PatternData], channel: ChannelName) -> List[object]: + return [row for pattern in patterns if pattern.channel == CHANNEL_TO_ID[channel] for row in pattern.rows] + + +class TestAShapeReachesTheModule: + def test_a_shape_used_on_several_channels_is_written_once(self) -> None: + shape = _shape() + project = _project(shape, ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.NOISE) + + instruments, slots = build_instrument_table(project) + + assert len(instruments) == 1 + assert instruments[0].name == "Lead" + assert {slots[(shape.id, channel)].index for channel in ChannelName.items()} == {0} + + def test_the_instrument_carries_every_dimension_the_shape_writes(self) -> None: + instruments, _ = build_instrument_table(_project(_shape(), ChannelName.PULSE1)) + + sequences = instruments[0].sequences + assert sequences[SequenceKind.VOLUME].items == VOLUME + assert sequences[SequenceKind.ARPEGGIO].items == ARPEGGIO + assert sequences[SequenceKind.DUTY].items == DUTY_CYCLE * len(VOLUME) + + def test_a_one_shot_leaves_every_loop_point_unset(self) -> None: + instruments, _ = build_instrument_table(_project(_shape(), ChannelName.PULSE1)) + + assert all(sequence.loop_point == NO_LOOP_POINT for sequence in instruments[0].sequences.values()) + + def test_a_loop_point_reaches_every_populated_sequence(self) -> None: + instruments, _ = build_instrument_table(_project(_shape(TAIL_LOOP_POINT), ChannelName.PULSE1)) + + populated = [sequence for sequence in instruments[0].sequences.values() if sequence.items] + assert [sequence.loop_point for sequence in populated] == [TAIL_LOOP_POINT] * len(populated) + + def test_a_shorter_dimension_runs_the_length_of_the_longest(self) -> None: + """A tracker advances each sequence on its own counter, so they must share a length.""" + shape = Shape( + name="Lead", + envelopes=ShapeEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE), + loop_point=TAIL_LOOP_POINT, + ) + instruments, _ = build_instrument_table(_project(shape, ChannelName.PULSE1)) + + duty = instruments[0].sequences[SequenceKind.DUTY] + assert duty.items == DUTY_CYCLE * len(VOLUME) + assert duty.loop_point == TAIL_LOOP_POINT + + def test_a_looping_shape_still_repeats_from_the_start(self) -> None: + instruments, _ = build_instrument_table(_project(_shape(LOOP_FROM_START), ChannelName.PULSE1)) + + populated = [sequence for sequence in instruments[0].sequences.values() if sequence.items] + assert all(sequence.loop_point == LOOP_FROM_START for sequence in populated) + + +class TestTheRowsNameTheShapesRoot: + @pytest.mark.parametrize( + "channel", + [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE], + ) + def test_a_tonal_row_states_the_root_moved_by_its_transpose(self, channel: ChannelName) -> None: + shape = _shape() + module = project_to_module(_project(shape, channel, transpose=TRANSPOSE)) + + cell = pitch_to_note_cell(shape.root_pitch + TRANSPOSE) + row = _rows(list(module.track.patterns), channel)[0] + assert (row.note, row.octave) == (cell.note, cell.octave) + + def test_a_noise_row_states_the_period_root_moved_by_its_transpose(self) -> None: + shape = _shape() + module = project_to_module(_project(shape, ChannelName.NOISE, transpose=TRANSPOSE)) + + cell = period_to_note_cell(shape.root_period + TRANSPOSE) + row = _rows(list(module.track.patterns), ChannelName.NOISE)[0] + assert (row.note, row.octave) == (cell.note, cell.octave) + + def test_every_channel_names_the_one_instrument(self) -> None: + shape = _shape() + module = project_to_module(_project(shape, *ChannelName.items())) + + for channel in ChannelName.items(): + assert _rows(list(module.track.patterns), channel)[0].instrument == 0 diff --git a/tests/unit/sampletones_player/test_shape_song.py b/tests/unit/sampletones_player/test_shape_song.py new file mode 100644 index 000000000..e696ce419 --- /dev/null +++ b/tests/unit/sampletones_player/test_shape_song.py @@ -0,0 +1,46 @@ +from typing import Final, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.shape import Shape +from sampletones_player.builder import song_from_project + +ROWS_PER_PATTERN: Final[int] = 4 +VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) +VOLUME_NIBBLE: Final[int] = 0x0F + + +def _project() -> Project: + shape = Shape( + name="Lead", + envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=(0, 4, 7), duty_cycle=(1,)), + ) + project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) + project.voices.append(shape) + pattern = project.song[ChannelName.PULSE1].ensure_pattern(0, ROWS_PER_PATTERN) + pattern.rows[0] = Row(command=NoteOn(voice_id=shape.id)) + project.song.set_order_entry(0, ChannelName.PULSE1, 0) + return project + + +class TestAShapeReachesTheConsole: + """The player reads the same walk the sequencer plays, so a shape needs nothing of its own.""" + + def test_a_project_holding_a_shape_compiles(self) -> None: + song = song_from_project(_project(), loop_tick=None) + + assert song.planes.ticks > 0 + + def test_the_compiled_song_sounds_the_shape_on_the_channel_it_was_placed_on(self) -> None: + song = song_from_project(_project(), loop_tick=None) + + levels = [registers.control & VOLUME_NIBBLE for registers in song.streams.pulse1[: len(VOLUME)]] + assert levels == list(VOLUME) + + def test_the_channels_it_was_not_placed_on_stay_silent(self) -> None: + song = song_from_project(_project(), loop_tick=None) + + assert {registers.control & VOLUME_NIBBLE for registers in song.streams.pulse2} == {0} From 76c2abd58bfee4f00d092a8b65d52bf2fe902176 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 05:36:29 +0200 Subject: [PATCH 076/142] Turned: the samples list into the voices panel, with shapes on it --- .../categories/elements/sequencer.py | 8 +- .../categories/hierarchy.py | 1 + .../coordinators/tabs/sequencer.py | 117 +++++----- .../layout/glyphs/glyphs.py | 2 + .../layout/glyphs/header.py | 2 +- .../layout/glyphs/voice.py | 6 + .../layout/tabs/sequencer/tables/cells.py | 4 +- .../tabs/sequencer/tables/instrument.py | 11 - .../layout/tabs/sequencer/tables/voice.py | 12 ++ .../logic/project/controller.py | 4 +- .../logic/sequencer/history_detail.py | 15 +- .../logic/sequencer/{samples.py => voices.py} | 134 ++++++++---- src/sampletones_application/tags/sequencer.py | 30 +-- .../ui/panels/sequencer/tracker.py | 16 +- .../sequencer/{samples.py => voices.py} | 200 ++++++++++++------ .../view_model/sequencer/kind.py | 20 ++ .../view_model/sequencer/samples.py | 35 --- .../view_model/sequencer/voices.py | 49 +++++ .../view_model/shared/footprint.py | 28 ++- src/sampletones_config/lang/en.yaml | 30 +-- src/sampletones_config/layout/glyphs.yaml | 6 +- .../layout/tabs/sequencer/table_cells.yaml | 3 +- .../{instruments_row.yaml => voices_row.yaml} | 4 +- .../coordinators/tabs/test_sequencer.py | 45 ++-- .../logic/project/test_controller.py | 8 +- .../logic/sequencer/test_history_detail.py | 4 +- .../{test_samples.py => test_voices.py} | 98 +++++++-- .../panels/sequencer/test_panel_tab_gate.py | 10 +- .../ui/panels/sequencer/test_samples_keys.py | 105 --------- .../sequencer/test_tracker_context_menu.py | 16 +- .../ui/panels/sequencer/test_voices_keys.py | 105 +++++++++ ...st_samples_menu.py => test_voices_menu.py} | 50 ++--- ..._selection.py => test_voices_selection.py} | 18 +- .../{test_samples.py => test_voices.py} | 6 +- 34 files changed, 735 insertions(+), 467 deletions(-) create mode 100644 src/sampletones_application/layout/glyphs/voice.py delete mode 100644 src/sampletones_application/layout/tabs/sequencer/tables/instrument.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/tables/voice.py rename src/sampletones_application/logic/sequencer/{samples.py => voices.py} (55%) rename src/sampletones_application/ui/panels/sequencer/{samples.py => voices.py} (79%) create mode 100644 src/sampletones_application/view_model/sequencer/kind.py delete mode 100644 src/sampletones_application/view_model/sequencer/samples.py create mode 100644 src/sampletones_application/view_model/sequencer/voices.py rename src/sampletones_config/theme/tables/{instruments_row.yaml => voices_row.yaml} (81%) rename tests/unit/sampletones_application/logic/sequencer/{test_samples.py => test_voices.py} (76%) delete mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py rename tests/unit/sampletones_application/ui/panels/sequencer/{test_samples_menu.py => test_voices_menu.py} (88%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_samples_selection.py => test_voices_selection.py} (65%) rename tests/unit/sampletones_application/view_model/sequencer/{test_samples.py => test_voices.py} (65%) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 448a0264b..b5254e922 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -84,8 +84,12 @@ class SequencerOrderElements(AbstractElement): CONTEXT_UNMUTE_ALL = "context_unmute_all" -class SequencerInstrumentsElements(AbstractElement): - INSTRUMENTS_TEXT = "instruments_text" +class SequencerVoicesElements(AbstractElement): + VOICES_TEXT = "voices_text" + NEW_SHAPE = "new_shape" + KIND_SAMPLE = "kind_sample" + KIND_SHAPE = "kind_shape" + COLUMN_KIND = "column_kind" COLUMN_ID = "column_id" COLUMN_NAME = "column_name" COLUMN_LOOP = "column_loop" diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 1969c9bef..a0386ac4b 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -85,6 +85,7 @@ class Panel(StrEnum): TRACKER = auto() ORDER = auto() MODULE = auto() + VOICES = auto() INSTRUMENTS = auto() HISTORY = auto() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index ced21012b..9ef1f2790 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -45,7 +45,6 @@ ) from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer -from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlock, @@ -53,6 +52,7 @@ TrackerBlockWriter, TrackerRegionAdjuster, ) +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters from sampletones_application.services.song_player.player import SongPlayerService @@ -72,12 +72,12 @@ TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY, TAG_SEQUENCER_BROWSER_PANEL, TAG_SEQUENCER_HISTORY_PANEL, - TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE, - TAG_SEQUENCER_INSTRUMENTS_PANEL, TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, TAG_SEQUENCER_MODULE_PANEL, TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, TAG_SEQUENCER_TRACKER_PANEL, + TAG_SEQUENCER_VOICES_DIALOG_REMOVE, + TAG_SEQUENCER_VOICES_PANEL, ) from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns from sampletones_application.ui.elements.layout.responsive import expanded_side_width @@ -86,8 +86,8 @@ from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.clipboard import ( SystemTextClipboard, @@ -111,13 +111,13 @@ TrackerCell, TrackerRegion, ) -from sampletones_application.view_model.sequencer.samples import ( - SequencerSamplesViewModel, -) from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, +) from sampletones_application.view_model.shared.history import ( HistoryDetail, HistoryDetailSegment, @@ -128,6 +128,7 @@ from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode +from sampletones_core.utils.display import display_id from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback, VoidCallback @@ -225,7 +226,7 @@ def __init__( self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic) self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic) - self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( + self._sequencer_voices_logic: SequencerVoicesLogic = SequencerVoicesLogic( project_controller, session_manager, audio_device_manager, @@ -279,10 +280,10 @@ def __init__( tab_active=tab_active, shortcut_source=shortcut_source, ) - self._sequencer_samples_panel: GUISequencerSamplesPanel = GUISequencerSamplesPanel( + self._sequencer_voices_panel: GUISequencerVoicesPanel = GUISequencerVoicesPanel( layout=layout.sequencer, detail_color=layout.muted_color, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_INSTRUMENTS_PANEL), + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_VOICES_PANEL), language_manager=language_manager, key_router=key_router, tab_active=tab_active, @@ -297,7 +298,7 @@ def __init__( ) self._history_detail: SequencerHistoryDetail = SequencerHistoryDetail( self._sequencer_tracker_logic, - self._sequencer_samples_logic, + self._sequencer_voices_logic, ) self._wire_callbacks() @@ -321,7 +322,7 @@ def _wire_collapse_handlers(self) -> None: self._sequencer_order_panel, self._sequencer_tracker_panel, self._sequencer_module_panel, - self._sequencer_samples_panel, + self._sequencer_voices_panel, self._sequencer_history_panel, ): panel.set_collapse_handler(self._on_card_collapse_changed) @@ -604,30 +605,46 @@ def _paste_order_block(self, cell: OrderCell) -> None: self._order_block_writer.write(block, cell) def _wire_samples_callbacks(self) -> None: - self._sequencer_samples_logic.on_voices_changed = self._on_voices_changed - self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample - self._sequencer_samples_logic.on_autoplay_error = self._on_preview_error - self._sequencer_samples_panel.sample_footprint = self._sequencer_samples_logic.build_sample_footprint - self._sequencer_samples_panel.on_sample_selected = self._on_sample_selected - self._sequencer_samples_panel.on_sample_edit_requested = self._sequencer_samples_logic.request_edit - self._sequencer_samples_panel.on_loop_changed = self._undoable( + self._sequencer_voices_logic.on_voices_changed = self._on_voices_changed + self._sequencer_voices_logic.on_edit_sample_requested = self._dispatch_edit_sample + self._sequencer_voices_logic.on_autoplay_error = self._on_preview_error + self._sequencer_voices_panel.sample_footprint = self._sequencer_voices_logic.build_voice_footprint + self._sequencer_voices_panel.on_sample_selected = self._on_sample_selected + self._sequencer_voices_panel.on_sample_edit_requested = self._sequencer_voices_logic.request_edit + self._sequencer_voices_panel.on_loop_changed = self._undoable( HistoryAction.SET_SAMPLE_LOOP, - self._sequencer_samples_logic.set_sample_loop, + self._sequencer_voices_logic.set_sample_loop, detail=self._history_detail.set_sample_loop, ) - self._sequencer_samples_panel.on_remove_requested = self._remove_voice - self._sequencer_samples_panel.on_play_requested = self._sequencer_samples_logic.play_sample - self._sequencer_samples_panel.on_move_requested = self._undoable( + self._sequencer_voices_panel.on_remove_requested = self._remove_voice + self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice + self._sequencer_voices_panel.on_move_requested = self._undoable( HistoryAction.MOVE_SAMPLE, - self._sequencer_samples_logic.move_voice, + self._sequencer_voices_logic.move_voice, detail=self._history_detail.move_voice, ) - self._sequencer_samples_panel.on_rename_committed = self._submit_rename - self._sequencer_samples_panel.on_duplicate_requested = self._undoable( + self._sequencer_voices_panel.on_rename_committed = self._submit_rename + self._sequencer_voices_panel.on_duplicate_requested = self._undoable( HistoryAction.DUPLICATE_SAMPLE, - self._sequencer_samples_logic.duplicate_voice, + self._sequencer_voices_logic.duplicate_voice, detail=self._history_detail.duplicate_voice, ) + self._sequencer_voices_panel.on_new_shape_requested = self._add_shape + + def _add_shape(self) -> None: + """Appends a hand-written voice, named for the position it takes in the list. + + A shape opens with no envelope, so it is the reader's to write; naming it by its position + gives the list a readable entry until they rename it. + """ + name = self._language_manager["sequencer.voices.template.shape_name"].format( + position=display_id(self._project_controller.voice_count), + ) + with self._history.transaction( + HistoryAction.ADD_SHAPE, + detail=self._history_detail.add_shape(name), + ): + self._sequencer_voices_logic.add_shape(name) def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed) @@ -651,7 +668,7 @@ def _wire_playback_callbacks(self) -> None: def _wire_project_callbacks(self) -> None: self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings self._project_controller.on_song_changed = self._on_song_changed - self._project_controller.on_voices_changed = self._sequencer_samples_logic.push_samples + self._project_controller.on_voices_changed = self._sequencer_voices_logic.push_voices self._project_controller.on_project_replaced = self._on_project_replaced def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: @@ -717,7 +734,7 @@ def _sync_samples_height(self) -> None: else: footprint = self._history_expanded_height - self._sequencer_samples_panel.set_expanded_height(-(self._inter_card_gap + footprint)) + self._sequencer_voices_panel.set_expanded_height(-(self._inter_card_gap + footprint)) def _wire_history(self) -> None: self._sequencer_history_panel.on_undo = self.undo @@ -943,7 +960,7 @@ def refresh(self) -> None: self._song_player_logic.stop() self._sequencer_tracker_logic.refresh() self._sequencer_order_logic.refresh() - self._sequencer_samples_logic.push_samples() + self._sequencer_voices_logic.push_voices() self._sequencer_channels_logic.push_channels() is_open = self._project_controller.is_open self._sequencer_module_panel.set_enabled(is_open) @@ -960,7 +977,7 @@ def repaint(self) -> None: """ self._sequencer_tracker_panel.repaint() self._sequencer_order_panel.repaint() - self._sequencer_samples_panel.repaint() + self._sequencer_voices_panel.repaint() def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() @@ -1101,7 +1118,7 @@ def _add_reconstruction_with_frequency_check( name, adopt_frequency=adopt_frequency, ), - can_adopt_frequency=not self._project_controller.has_samples, + can_adopt_frequency=not self._project_controller.has_voices, ) def _reconcile_nes_frequency( @@ -1178,7 +1195,7 @@ def replace_reconstruction(self, filepath: Path) -> None: an import does. The target is whatever the samples panel has selected as the gesture starts, which is also what named the menu item the user clicked. """ - selection = self._sequencer_samples_panel.selection + selection = self._sequencer_voices_panel.selection if selection is None: return @@ -1197,7 +1214,7 @@ def replace_reconstruction(self, filepath: Path) -> None: filepath.stem, adopt_frequency=adopt_frequency, ), - can_adopt_frequency=self._project_controller.sample_count == 1, + can_adopt_frequency=self._project_controller.voice_count == 1, ) def _commit_replace_reconstruction( @@ -1225,13 +1242,13 @@ def _commit_replace_reconstruction( if adopt_frequency is not None: self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency) - self._sequencer_samples_logic.rename_voice(voice_id, name) + self._sequencer_voices_logic.rename_voice(voice_id, name) self._on_sample_reconstruction_replaced(voice_id, reconstruction) self._sequencer_browser_logic.replace_reconstruction(voice_id, reconstruction) def _replace_target_label(self) -> Optional[str]: """The indexed label of the sample a browser replacement would overwrite, while one is selected.""" - selection = self._sequencer_samples_panel.selection + selection = self._sequencer_voices_panel.selection if selection is None: return None @@ -1249,15 +1266,15 @@ def _on_tracker_play_from_row(self, row_index: int) -> None: def _on_voices_changed( self, - view_model: SequencerSamplesViewModel, + view_model: SequencerVoicesViewModel, ) -> None: - self._sequencer_samples_panel.update_view(view_model) + self._sequencer_voices_panel.update_view(view_model) self._sequencer_tracker_panel.update_samples(view_model) def _on_sample_selected(self, voice_id: str) -> None: self._sequencer_tracker_panel.deselect_cell() self._sequencer_order_panel.deselect_cell() - self._sequencer_samples_logic.request_autoplay(voice_id) + self._sequencer_voices_logic.request_autoplay(voice_id) logger.debug(f"Sequencer sample selected: {voice_id}") def _remove_voice(self, voice_id: str) -> None: @@ -1266,13 +1283,13 @@ def _remove_voice(self, voice_id: str) -> None: An unused sample is dropped silently; a referenced one would clear every row that points at it, so the user confirms that loss first. """ - if not self._sequencer_samples_logic.is_voice_used(voice_id): + if not self._sequencer_voices_logic.is_voice_used(voice_id): self._perform_remove_voice(voice_id) return - name = self._sequencer_samples_logic.sample_name(voice_id) + name = self._sequencer_voices_logic.voice_name(voice_id) self._dialogs.show_confirmation( - tag=TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE, + tag=TAG_SEQUENCER_VOICES_DIALOG_REMOVE, title=self._language_manager["global.dialog.title.remove_sample"], message=self._language_manager["global.dialog.message.remove_sample"].format(name=name), on_confirm=lambda: self._perform_remove_voice(voice_id), @@ -1285,21 +1302,21 @@ def _perform_remove_voice(self, voice_id: str) -> None: HistoryAction.REMOVE_SAMPLE, detail=detail, ): - self._sequencer_samples_logic.remove_voice(voice_id) + self._sequencer_voices_logic.remove_voice(voice_id) def _submit_rename(self, voice_id: str, name: str) -> None: """Applies an inline rename, ignoring a blank name so the sample keeps its current one.""" stripped = name.strip() if stripped: detail = self._history_detail.rename_voice( - self._sequencer_samples_logic.sample_name(voice_id), + self._sequencer_voices_logic.voice_name(voice_id), stripped, ) with self._history.transaction( HistoryAction.RENAME_SAMPLE, detail=detail, ): - self._sequencer_samples_logic.rename_voice(voice_id, stripped) + self._sequencer_voices_logic.rename_voice(voice_id, stripped) def _request_nes_frequency_change(self, nes_frequency: int) -> None: """Applies a NES-frequency change, confirming first when it would re-time existing samples. @@ -1311,7 +1328,7 @@ def _request_nes_frequency_change(self, nes_frequency: int) -> None: if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency: return - if self._nes_frequency_change_acknowledged or not self._project_controller.has_samples: + if self._nes_frequency_change_acknowledged or not self._project_controller.has_voices: self._perform_nes_frequency_change(nes_frequency) return @@ -1453,12 +1470,12 @@ def _on_tracker_cell_focused(self) -> None: panel consume keystrokes. """ self._sequencer_order_panel.deselect_cell() - self._sequencer_samples_panel.deselect() + self._sequencer_voices_panel.deselect() def _on_order_cell_focused(self) -> None: """Drops the tracker cursor and sample selection when the order tracker takes focus.""" self._sequencer_tracker_panel.deselect_cell() - self._sequencer_samples_panel.deselect() + self._sequencer_voices_panel.deselect() def create_tab(self) -> None: with dpg.tab( @@ -1507,7 +1524,7 @@ def _build_right_column(self, parent: str) -> None: """Stacks the module settings, samples, and history cards in the right column.""" self._sequencer_module_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap) - self._sequencer_samples_panel.create_panel(parent) + self._sequencer_voices_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap) self._sequencer_history_panel.create_panel(parent) self._sync_samples_height() @@ -1526,5 +1543,5 @@ def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: return ( self._sequencer_tracker_panel.edit_surface, self._sequencer_order_panel.edit_surface, - self._sequencer_samples_panel, + self._sequencer_voices_panel, ) diff --git a/src/sampletones_application/layout/glyphs/glyphs.py b/src/sampletones_application/layout/glyphs/glyphs.py index f54ee443f..0083f2fab 100644 --- a/src/sampletones_application/layout/glyphs/glyphs.py +++ b/src/sampletones_application/layout/glyphs/glyphs.py @@ -3,9 +3,11 @@ from sampletones_application.layout.glyphs.common import CommonGlyphs from sampletones_application.layout.glyphs.header import HeaderGlyphs from sampletones_application.layout.glyphs.player import PlayerGlyphs +from sampletones_application.layout.glyphs.voice import VoiceGlyphs class Glyphs(BaseModel, extra="forbid", frozen=True): common: CommonGlyphs headers: HeaderGlyphs player: PlayerGlyphs + voices: VoiceGlyphs diff --git a/src/sampletones_application/layout/glyphs/header.py b/src/sampletones_application/layout/glyphs/header.py index 660fbbbd9..a08092c03 100644 --- a/src/sampletones_application/layout/glyphs/header.py +++ b/src/sampletones_application/layout/glyphs/header.py @@ -14,7 +14,7 @@ class HeaderGlyphs(BaseModel, extra="forbid", frozen=True): parameters: str source: str instruments: str - samples: str + voices: str tracker: str order: str history: str diff --git a/src/sampletones_application/layout/glyphs/voice.py b/src/sampletones_application/layout/glyphs/voice.py new file mode 100644 index 000000000..d6ecfd2e7 --- /dev/null +++ b/src/sampletones_application/layout/glyphs/voice.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class VoiceGlyphs(BaseModel, extra="forbid", frozen=True): + sample: str + shape: str diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py index 6bdbac1ff..244f8b250 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py +++ b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.layout.tabs.sequencer.tables.instrument import InstrumentColumnWidths +from sampletones_application.layout.tabs.sequencer.tables.voice import VoiceColumnWidths class SequencerTableCells(BaseModel, extra="forbid", frozen=True): @@ -8,4 +8,4 @@ class SequencerTableCells(BaseModel, extra="forbid", frozen=True): sample: int divider: int channel: int - instrument: InstrumentColumnWidths + voice: VoiceColumnWidths diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py b/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py deleted file mode 100644 index 83ac368b2..000000000 --- a/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic import BaseModel - - -class InstrumentColumnWidths(BaseModel, extra="forbid", frozen=True): - """Widths of the three sub-columns that make up an instrument row: its id, its - name, and its loop marker. They only mean anything as a set, so they live together. - """ - - id: int - name: int - loop: int diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/voice.py b/src/sampletones_application/layout/tabs/sequencer/tables/voice.py new file mode 100644 index 000000000..014ba346f --- /dev/null +++ b/src/sampletones_application/layout/tabs/sequencer/tables/voice.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class VoiceColumnWidths(BaseModel, extra="forbid", frozen=True): + """Widths of the four sub-columns that make up a voice row: its kind mark, its id, + its name, and its loop marker. They only mean anything as a set, so they live together. + """ + + kind: int + id: int + name: int + loop: int diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 606515bef..9aa060a19 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -67,11 +67,11 @@ def name(self) -> str: return self._project_manager.name @property - def has_samples(self) -> bool: + def has_voices(self) -> bool: return bool(self.project.voices) @property - def sample_count(self) -> int: + def voice_count(self) -> int: return len(self.project.voices) @property diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 225b3dfb1..3a50d3a15 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -1,7 +1,7 @@ from typing import Dict, Final, List, Optional, Set -from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, @@ -79,7 +79,7 @@ class SequencerHistoryDetail: def __init__( self, tracker_logic: SequencerTrackerLogic, - samples_logic: SequencerSamplesLogic, + samples_logic: SequencerVoicesLogic, ) -> None: self._tracker_logic = tracker_logic self._samples_logic = samples_logic @@ -228,10 +228,13 @@ def set_master_entry( def add_sample(self, name: str) -> Segments: return (self._name(name),) + def add_shape(self, name: str) -> Segments: + return (self._name(name),) + def remove_voice(self, voice_id: str) -> Segments: return ( self._sample(voice_id, colon=True), - self._name(self._samples_logic.sample_name(voice_id)), + self._name(self._samples_logic.voice_name(voice_id)), ) def replace_sample(self, voice_id: str, name: str) -> Segments: @@ -242,7 +245,7 @@ def replace_sample(self, voice_id: str, name: str) -> Segments: """ return ( self._sample(voice_id, colon=True), - self._name(self._samples_logic.sample_name(voice_id)), + self._name(self._samples_logic.voice_name(voice_id)), self._arrow(), self._name(name), ) @@ -260,7 +263,7 @@ def move_voice(self, voice_id: str, to_index: int) -> Segments: def duplicate_voice(self, voice_id: str) -> Segments: return ( self._sample(voice_id, colon=True), - self._name(self._samples_logic.sample_name(voice_id)), + self._name(self._samples_logic.voice_name(voice_id)), ) def set_sample_loop(self, voice_id: str, loop: bool) -> Segments: @@ -421,7 +424,7 @@ def _sample( *, colon: bool = False, ) -> HistoryDetailSegment: - position = self._samples_logic.sample_position(voice_id) + position = self._samples_logic.voice_position(voice_id) text = f"{position}:" if colon else position return HistoryDetailSegment(text=text, role=HistoryDetailRole.SAMPLE) diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/voices.py similarity index 55% rename from src/sampletones_application/logic/sequencer/samples.py rename to src/sampletones_application/logic/sequencer/voices.py index ebae12aeb..67db178f7 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -1,4 +1,6 @@ -from typing import Callable, Optional +from typing import Callable, Final, Optional + +import numpy as np from sampletones_application.config.managers.session import SessionManager from sampletones_application.layout.behavior.scheduling.scheduling import ( @@ -7,15 +9,23 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_application.view_model.sequencer.samples import ( - SampleEntryViewModel, - SequencerSamplesViewModel, +from sampletones_application.view_model.sequencer.kind import voice_kind +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, + VoiceEntryViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.audio import AudioDeviceManager -from sampletones_core.formats.famitracker.footprint import reconstruction_footprints +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.footprint import ( + features_footprint, + reconstruction_footprints, +) +from sampletones_core.generators.render import render_instructions from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import display_voice from sampletones_shared.exceptions import PlaybackError @@ -23,8 +33,10 @@ from sampletones_shared.types.callback import StringCallback from sampletones_shared.utils.callbacks import CallbackMixin +PREVIEW_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 -class SequencerSamplesLogic(CallbackMixin): + +class SequencerVoicesLogic(CallbackMixin): """Drives the samples panel: lists the pool, edits it, and previews samples. Every pool edit goes through the controller so the project stays the single @@ -51,59 +63,68 @@ def __init__( self._scheduling = scheduling self._pending_autoplay_sample: Optional[str] = None - self.on_voices_changed: Optional[Callable[[SequencerSamplesViewModel], None]] = None + self.on_voices_changed: Optional[Callable[[SequencerVoicesViewModel], None]] = None self.on_edit_sample_requested: Optional[StringCallback] = None self.on_autoplay_error: Optional[Callable[[Exception], None]] = None - def build_samples(self) -> SequencerSamplesViewModel: + def build_voices(self) -> SequencerVoicesViewModel: entries = tuple( - SampleEntryViewModel( - voice_id=sample.id, - name=sample.name, - loop=sample.loops, + VoiceEntryViewModel( + voice_id=voice.id, + name=voice.name, + kind=voice_kind(voice), + loop=voice.loops, ) - for sample in self._controller.project.voices + for voice in self._controller.project.voices ) - return SequencerSamplesViewModel(samples=entries) + return SequencerVoicesViewModel(voices=entries) - def push_samples(self) -> None: - self.call(self.on_voices_changed, self.build_samples()) + def push_voices(self) -> None: + self.call(self.on_voices_changed, self.build_voices()) def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: return self._controller.add_sample(reconstruction, name) + def add_shape(self, name: str) -> Shape: + return self._controller.add_shape(name) + def rename_voice(self, voice_id: str, name: str) -> None: self._controller.rename_voice(voice_id, name) def is_voice_used(self, voice_id: str) -> bool: return self._controller.is_voice_used(voice_id) - def build_sample_footprint(self, voice_id: str) -> Optional[SampleFootprintViewModel]: - """Measures one sample's instruments as the module export writes them. + def build_voice_footprint(self, voice_id: str) -> Optional[SampleFootprintViewModel]: + """Measures one voice's instruments as the module export writes them. - A sample carries its own loop flag, and a looping instrument is compiled to the shortest - length its envelopes share, so the sample is measured the way it is placed. Measuring a - single sample on demand keeps a pool edit clear of an export it was not asked for. + A voice carries its own loop point, and a looping instrument is compiled to one shared + length, so it is measured the way it is placed. A sample yields a figure per channel its + reconstruction covers; a shape yields the one instrument every channel reaches. Measuring + a single voice on demand keeps a pool edit clear of an export it was not asked for. Args: - voice_id: The sample to measure. + voice_id: The voice to measure. Returns: - Optional[SampleFootprintViewModel]: The sample's byte figures, or ``None`` while the - pool holds no such sample. + Optional[SampleFootprintViewModel]: The voice's byte figures, or ``None`` while the + pool holds no such voice. """ - sample = self._controller.project.voices.get(voice_id) - if not isinstance(sample, Sample): - return None - - return SampleFootprintViewModel.from_footprints( - reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) - ) - - def sample_name(self, voice_id: str) -> str: + match self._controller.project.voices.get(voice_id): + case Sample() as sample: + return SampleFootprintViewModel.from_footprints( + reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) + ) + case Shape() as shape: + return SampleFootprintViewModel.from_instrument( + features_footprint(shape.instrument_features(), loop_point=shape.loop_point) + ) + case _: + return None + + def voice_name(self, voice_id: str) -> str: return self._controller.project.voices[voice_id].name - def sample_position(self, voice_id: str) -> str: + def voice_position(self, voice_id: str) -> str: """Returns the sample's hex list position, matching how the tracker labels it.""" return display_voice( voices=self._controller.project.voices, @@ -131,13 +152,13 @@ def request_edit(self, voice_id: str) -> None: self.cancel_autoplay() self.call(self.on_edit_sample_requested, voice_id) - def play_sample(self, voice_id: str) -> None: + def play_voice(self, voice_id: str) -> None: """Plays a sample on demand, regardless of the autoplay setting. Explicit playback is intentional, so it uses ``NORMAL`` priority and thereby preempts the sequencer song / reconstruction players. """ - self._play_sample(voice_id, priority=PlaybackPriority.NORMAL) + self._play_voice(voice_id, priority=PlaybackPriority.NORMAL) def request_autoplay(self, voice_id: str) -> None: """Schedules a debounced preview that a following double-click can cancel.""" @@ -158,21 +179,52 @@ def _execute_autoplay(self) -> None: voice_id = self._pending_autoplay_sample self._pending_autoplay_sample = None if self._session_manager.autoplay: - self._play_sample(voice_id, priority=PlaybackPriority.PREVIEW) + self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW) + + def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: + """The audio a preview sounds: a sample's approximation, or a shape rendered on the pulse. + + The pulse channel offers every dimension a shape writes, so rendering the preview there + sounds the whole instrument rather than the part another channel would read. + + Args: + voice_id: The voice to preview. + + Returns: + Optional[np.ndarray]: The waveform to play, or ``None`` where the voice sounds nothing. + """ + match self._controller.project.voices.get(voice_id): + case Sample() as sample: + return sample.reconstruction.approximation + case Shape() as shape: + instructions = shape.instructions(PREVIEW_CHANNEL) + if not instructions: + return None + + return render_instructions(instructions, PREVIEW_CHANNEL, self._preview_config()) + case _: + return None + + def _preview_config(self) -> Config: + settings = self._controller.project.settings + return Config().with_library( + nes_frequency=settings.nes_frequency, + sample_rate=settings.sample_rate, + ) - def _play_sample( + def _play_voice( self, voice_id: str, *, priority: PlaybackPriority, ) -> None: - sample = self._controller.project.voices.get(voice_id) - if not isinstance(sample, Sample): + audio = self._preview_audio(voice_id) + if audio is None: return try: self._audio_device_manager.play( - sample.reconstruction.approximation, + audio, update=False, priority=priority, ) diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index 0fddc3982..ad40df512 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -140,39 +140,39 @@ Widget.BUTTON, "pair", ) -TAG_SEQUENCER_INSTRUMENTS_PANEL = TagName( +TAG_SEQUENCER_VOICES_PANEL = TagName( Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, Widget.PANEL, - "instruments", + "voices", ) -TAG_SEQUENCER_INSTRUMENTS_TABLE = TagName( +TAG_SEQUENCER_VOICES_TABLE = TagName( Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, Widget.TABLE, - "instruments", + "voices", ) -TAG_SEQUENCER_INSTRUMENTS_WINDOW = TagName( +TAG_SEQUENCER_VOICES_WINDOW = TagName( Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, Widget.WINDOW, - "instruments", + "voices", ) -TAG_SEQUENCER_INSTRUMENTS_THEME_ROW = TagName( +TAG_SEQUENCER_VOICES_THEME_ROW = TagName( Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, Widget.THEME, "row", ) -TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE = TagName( +TAG_SEQUENCER_VOICES_DIALOG_REMOVE = TagName( Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, Widget.DIALOG, "remove", ) -TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME = TagName( +TAG_SEQUENCER_VOICES_INPUT_RENAME = TagName( Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, Widget.INPUT, "rename", ) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 8f9dd91e5..d47afe88f 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -90,9 +90,6 @@ SequencerChannelsViewModel, ) from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion -from sampletones_application.view_model.sequencer.samples import ( - SequencerSamplesViewModel, -) from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) @@ -106,6 +103,9 @@ SequencerRowViewModel, SequencerTrackerViewModel, ) +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, +) from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.project.song_position import SongPosition @@ -250,7 +250,7 @@ def __init__( self._header_theme: int = 0 self._muted_header_theme: int = 0 self._column_label_theme: int = 0 - self._current_samples: Optional[SequencerSamplesViewModel] = None + self._current_samples: Optional[SequencerVoicesViewModel] = None self._current_channels: Optional[SequencerChannelsViewModel] = None self.on_clear_row: Optional[OnClearRowCallback] = None @@ -936,7 +936,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None: self._selection.repaint() self._update_caret() - def update_samples(self, view_model: SequencerSamplesViewModel) -> None: + def update_samples(self, view_model: SequencerVoicesViewModel) -> None: self._current_samples = view_model def update_channels(self, view_model: SequencerChannelsViewModel) -> None: @@ -1017,10 +1017,10 @@ def _resolve_voice_id( self, sample_index: int, ) -> Optional[Tuple[int, str]]: - if not self._current_samples or not self._current_samples.samples: + if not self._current_samples or not self._current_samples.voices: return None - samples = self._current_samples.samples + samples = self._current_samples.voices sample_index = max(0, min(sample_index, len(samples) - 1)) return sample_index, samples[sample_index].voice_id @@ -1401,7 +1401,7 @@ def _add_select_items(self, cell: TrackerCursor) -> None: def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): - samples = self._current_samples.samples if self._current_samples is not None else () + samples = self._current_samples.voices if self._current_samples is not None else () if not samples: dpg.add_menu_item( label=self._lbl_context_no_samples, diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/voices.py similarity index 79% rename from src/sampletones_application/ui/panels/sequencer/samples.py rename to src/sampletones_application/ui/panels/sequencer/voices.py index ba7082d84..0762edad4 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -6,7 +6,7 @@ from sampletones_application.categories.context import channel_label, context_label, context_text from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( - SequencerInstrumentsElements, + SequencerVoicesElements, ) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager @@ -14,11 +14,11 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.sequencer import ( - TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, - TAG_SEQUENCER_INSTRUMENTS_PANEL, - TAG_SEQUENCER_INSTRUMENTS_TABLE, - TAG_SEQUENCER_INSTRUMENTS_THEME_ROW, - TAG_SEQUENCER_INSTRUMENTS_WINDOW, + TAG_SEQUENCER_VOICES_INPUT_RENAME, + TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TABLE, + TAG_SEQUENCER_VOICES_THEME_ROW, + TAG_SEQUENCER_VOICES_WINDOW, ) from sampletones_application.ui.elements.context_menu import ( add_detail_items, @@ -39,18 +39,20 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.sequencer.move import MoveDirection -from sampletones_application.view_model.sequencer.samples import ( - SampleEntryViewModel, - SampleSelection, - SequencerSamplesViewModel, +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, + VoiceEntryViewModel, + VoiceKind, + VoiceSelection, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import StringCallback +from sampletones_shared.types.callback import StringCallback, VoidCallback FROZEN_HEADER_ROWS: Final[int] = 1 @@ -59,38 +61,38 @@ class SampleMove: """One of the four moves, as its key press and its menu item each name it.""" - element: SequencerInstrumentsElements + element: SequencerVoicesElements shortcut: ShortcutId direction: MoveDirection -SAMPLE_MOVES: Final[Tuple[SampleMove, ...]] = ( +VOICE_MOVES: Final[Tuple[SampleMove, ...]] = ( SampleMove( - element=SequencerInstrumentsElements.CONTEXT_MOVE_UP, + element=SequencerVoicesElements.CONTEXT_MOVE_UP, shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_UP, direction=MoveDirection.PREVIOUS, ), SampleMove( - element=SequencerInstrumentsElements.CONTEXT_MOVE_DOWN, + element=SequencerVoicesElements.CONTEXT_MOVE_DOWN, shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN, direction=MoveDirection.NEXT, ), SampleMove( - element=SequencerInstrumentsElements.CONTEXT_MOVE_TOP, + element=SequencerVoicesElements.CONTEXT_MOVE_TOP, shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP, direction=MoveDirection.FIRST, ), SampleMove( - element=SequencerInstrumentsElements.CONTEXT_MOVE_BOTTOM, + element=SequencerVoicesElements.CONTEXT_MOVE_BOTTOM, shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM, direction=MoveDirection.LAST, ), ) -MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in SAMPLE_MOVES} +MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in VOICE_MOVES} -class GUISequencerSamplesPanel(GUIPanel): +class GUISequencerVoicesPanel(GUIPanel): def __init__( self, *, @@ -108,15 +110,18 @@ def __init__( self._router = key_router self._tab_active = tab_active self._shortcuts = shortcut_source - self._row_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_TABLE, SUF_HANDLER_REGISTRY) - self._rename_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, SUF_HANDLER_REGISTRY) + self._row_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_TABLE, SUF_HANDLER_REGISTRY) + self._rename_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_INPUT_RENAME, SUF_HANDLER_REGISTRY) self._selected_voice_id: Optional[str] = None self._selected_row: Optional[int] = None self._editing_voice_id: Optional[str] = None - self._entries: Tuple[SampleEntryViewModel, ...] = () + self._entries: Tuple[VoiceEntryViewModel, ...] = () self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) + self._tip_new_shape = self._tooltip(language_manager, SequencerVoicesElements.NEW_SHAPE) + self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) + self._tip_kind_shape = self._tooltip(language_manager, SequencerVoicesElements.KIND_SHAPE) self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None @@ -126,9 +131,10 @@ def __init__( self.on_move_requested: Optional[Callable[[str, int], None]] = None self.on_rename_committed: Optional[Callable[[str, str], None]] = None self.on_duplicate_requested: Optional[StringCallback] = None + self.on_new_shape_requested: Optional[VoidCallback] = None super().__init__( - tag=TAG_SEQUENCER_INSTRUMENTS_PANEL, + tag=TAG_SEQUENCER_VOICES_PANEL, width=-1, height=-layout.history.height, ) @@ -137,10 +143,11 @@ def __init__( def create_panel(self, parent: str) -> None: with self._collapsible_card( parent, - self._label(self._language_manager, SequencerInstrumentsElements.INSTRUMENTS_TEXT), - glyph=self._glyphs.headers.samples, + self._label(self._language_manager, SequencerVoicesElements.VOICES_TEXT), + glyph=self._glyphs.headers.voices, ): - self._create_samples_table() + self._create_new_shape_button() + self._create_voices_table() self._create_row_handlers() self._create_rename_handler() @@ -162,16 +169,26 @@ def _create_key_handler(self) -> None: active=self._keys_active, ) - def _create_samples_table(self) -> None: + def _create_new_shape_button(self) -> None: + """Offers a hand-written voice, which is the one kind no browser brings in.""" + button = dpg.add_button( + label=self._label(self._language_manager, SequencerVoicesElements.NEW_SHAPE), + width=-1, + callback=lambda: self.call(self.on_new_shape_requested), + ) + FontRegistry.bind_to_item(button, Font.REGULAR_SMALL) + show_tooltip(button, self._tip_new_shape) + + def _create_voices_table(self) -> None: with ( dpg.child_window( - tag=TAG_SEQUENCER_INSTRUMENTS_WINDOW, + tag=TAG_SEQUENCER_VOICES_WINDOW, border=False, width=-1, height=-1, ), dpg.table( - tag=TAG_SEQUENCER_INSTRUMENTS_TABLE, + tag=TAG_SEQUENCER_VOICES_TABLE, width=-1, height=-1, header_row=True, @@ -189,31 +206,39 @@ def _create_samples_table(self) -> None: dpg.add_table_column( label=self._label( self._language_manager, - SequencerInstrumentsElements.COLUMN_ID, + SequencerVoicesElements.COLUMN_KIND, ), width_fixed=True, - init_width_or_weight=self._layout.table_cells.instrument.id, + init_width_or_weight=self._layout.table_cells.voice.kind, ) dpg.add_table_column( label=self._label( self._language_manager, - SequencerInstrumentsElements.COLUMN_NAME, + SequencerVoicesElements.COLUMN_ID, + ), + width_fixed=True, + init_width_or_weight=self._layout.table_cells.voice.id, + ) + dpg.add_table_column( + label=self._label( + self._language_manager, + SequencerVoicesElements.COLUMN_NAME, ), width_stretch=True, - init_width_or_weight=self._layout.table_cells.instrument.name, + init_width_or_weight=self._layout.table_cells.voice.name, ) dpg.add_table_column( label=self._label( self._language_manager, - SequencerInstrumentsElements.COLUMN_LOOP, + SequencerVoicesElements.COLUMN_LOOP, ), width_fixed=True, - init_width_or_weight=self._layout.table_cells.instrument.loop, + init_width_or_weight=self._layout.table_cells.voice.loop, ) - ThemeRegistry.get(TAG_SEQUENCER_INSTRUMENTS_THEME_ROW).bind_to_item(TAG_SEQUENCER_INSTRUMENTS_TABLE) + ThemeRegistry.get(TAG_SEQUENCER_VOICES_THEME_ROW).bind_to_item(TAG_SEQUENCER_VOICES_TABLE) - def update_view(self, view_model: SequencerSamplesViewModel) -> None: - self._entries = view_model.samples + def update_view(self, view_model: SequencerVoicesViewModel) -> None: + self._entries = view_model.voices self._editing_voice_id = None self._rebuild() @@ -225,7 +250,7 @@ def _rebuild(self) -> None: process-global, so explicit parents keep this build independent of that shared stack. """ - dpg_delete_children(TAG_SEQUENCER_INSTRUMENTS_TABLE, slot=1) + dpg_delete_children(TAG_SEQUENCER_VOICES_TABLE, slot=1) self._selected_row = None for position, entry in enumerate(self._entries): self._build_sample_row(position, entry) @@ -235,9 +260,10 @@ def _rebuild(self) -> None: def _build_sample_row( self, position: int, - entry: SampleEntryViewModel, + entry: VoiceEntryViewModel, ) -> None: - row_id = dpg.add_table_row(parent=TAG_SEQUENCER_INSTRUMENTS_TABLE) + row_id = dpg.add_table_row(parent=TAG_SEQUENCER_VOICES_TABLE) + self._build_kind_cell(row_id, entry) self._build_id_cell(row_id, position, entry) self._build_name_cell(row_id, position, entry) self._build_loop_cell(row_id, entry) @@ -247,7 +273,7 @@ def _build_sample_row( def _highlight_selected_row(self, position: int) -> None: dpg.highlight_table_row( - TAG_SEQUENCER_INSTRUMENTS_TABLE, + TAG_SEQUENCER_VOICES_TABLE, position, color=self._layout.colors.cell_cursor.rgba, ) @@ -258,16 +284,44 @@ def repaint(self) -> None: DearPyGui keeps a row highlight on the table rather than on an item, so the colour reaches it only by being pushed again. """ - if self._selected_row is None or not dpg.does_item_exist(TAG_SEQUENCER_INSTRUMENTS_TABLE): + if self._selected_row is None or not dpg.does_item_exist(TAG_SEQUENCER_VOICES_TABLE): return self._highlight_selected_row(self._selected_row) + def _build_kind_cell( + self, + row_id: int | str, + entry: VoiceEntryViewModel, + ) -> None: + """Marks which kind the row carries, so a converted voice reads apart from a written one.""" + kind_cell = dpg.add_table_cell(parent=row_id) + mark = dpg.add_text( + parent=kind_cell, + default_value=self._kind_glyph(entry.kind), + ) + FontRegistry.bind_to_item(mark, Font.ICON) + show_tooltip(mark, self._kind_tooltip(entry.kind)) + + def _kind_glyph(self, kind: VoiceKind) -> str: + match kind: + case VoiceKind.SAMPLE: + return self._glyphs.voices.sample + case VoiceKind.SHAPE: + return self._glyphs.voices.shape + + def _kind_tooltip(self, kind: VoiceKind) -> str: + match kind: + case VoiceKind.SAMPLE: + return self._tip_kind_sample + case VoiceKind.SHAPE: + return self._tip_kind_shape + def _build_id_cell( self, row_id: int | str, position: int, - entry: SampleEntryViewModel, + entry: VoiceEntryViewModel, ) -> None: id_cell = dpg.add_table_cell(parent=row_id) id_selectable = dpg.add_selectable( @@ -283,7 +337,7 @@ def _build_name_cell( self, row_id: int | str, position: int, - entry: SampleEntryViewModel, + entry: VoiceEntryViewModel, ) -> None: name_cell = dpg.add_table_cell(parent=row_id) if entry.voice_id == self._editing_voice_id: @@ -295,7 +349,7 @@ def _build_name_selectable( self, name_cell: int | str, position: int, - entry: SampleEntryViewModel, + entry: VoiceEntryViewModel, ) -> None: name_selectable = dpg.add_selectable( parent=name_cell, @@ -309,10 +363,10 @@ def _build_name_selectable( def _build_name_input( self, name_cell: int | str, - entry: SampleEntryViewModel, + entry: VoiceEntryViewModel, ) -> None: name_input = dpg.add_input_text( - tag=TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, + tag=TAG_SEQUENCER_VOICES_INPUT_RENAME, parent=name_cell, default_value=entry.name, width=-1, @@ -325,7 +379,7 @@ def _build_name_input( def _build_loop_cell( self, row_id: int | str, - entry: SampleEntryViewModel, + entry: VoiceEntryViewModel, ) -> None: loop_cell = dpg.add_table_cell(parent=row_id) loop_checkbox = dpg.add_checkbox( @@ -346,7 +400,7 @@ def _on_sample_selected( dpg.set_value(sender, False) if self._selected_row is not None: dpg.unhighlight_table_row( - TAG_SEQUENCER_INSTRUMENTS_TABLE, + TAG_SEQUENCER_VOICES_TABLE, self._selected_row, ) @@ -356,7 +410,7 @@ def _on_sample_selected( self.call(self.on_sample_selected, voice_id) @property - def selection(self) -> Optional[SampleSelection]: + def selection(self) -> Optional[VoiceSelection]: """The selected sample, or ``None`` while the panel holds no selection. Derived from the highlighted row and the cached entries on each read, so it reports @@ -370,10 +424,11 @@ def selection(self) -> Optional[SampleSelection]: if entry is None: return None - return SampleSelection( + return VoiceSelection( voice_id=entry.voice_id, position=self._selected_row, name=entry.name, + kind=entry.kind, ) def deselect(self) -> None: @@ -385,7 +440,7 @@ def deselect(self) -> None: """ if self._selected_row is not None: dpg.unhighlight_table_row( - TAG_SEQUENCER_INSTRUMENTS_TABLE, + TAG_SEQUENCER_VOICES_TABLE, self._selected_row, ) @@ -470,7 +525,7 @@ def _start_rename(self, voice_id: str) -> None: self._editing_voice_id = voice_id self._rebuild() - FrameCallbackManager.set_frame_callback(lambda: dpg.focus_item(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME)) + FrameCallbackManager.set_frame_callback(lambda: dpg.focus_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)) def _commit_rename(self) -> None: """Applies the edited name and restores the read-only cell. @@ -482,7 +537,7 @@ def _commit_rename(self) -> None: return voice_id = self._editing_voice_id - name = dpg.get_value(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME) + name = dpg.get_value(TAG_SEQUENCER_VOICES_INPUT_RENAME) self._editing_voice_id = None self.call(self.on_rename_committed, voice_id, name) self._rebuild() @@ -539,7 +594,7 @@ def _on_sample_clicked( position, voice_id = user_data self._show_context_menu(position, voice_id) - def _entry_for(self, voice_id: str) -> Optional[SampleEntryViewModel]: + def _entry_for(self, voice_id: str) -> Optional[VoiceEntryViewModel]: return next((entry for entry in self._entries if entry.voice_id == voice_id), None) def _show_context_menu(self, position: int, voice_id: str) -> None: @@ -547,10 +602,11 @@ def _show_context_menu(self, position: int, voice_id: str) -> None: if entry is None: return - target = SampleSelection( + target = VoiceSelection( voice_id=voice_id, position=position, name=entry.name, + kind=entry.kind, ) with context_menu(): header = dpg.add_text(target.label) @@ -612,7 +668,7 @@ def build_edit_actions(self) -> None: if selection is not None: self.add_action_items(selection) - def add_action_items(self, target: SampleSelection) -> None: + def add_action_items(self, target: VoiceSelection) -> None: """Builds every action a sample offers, in the order each menu prints them. The panel states its actions once, and whoever asks for them decides where they are shown: @@ -622,14 +678,14 @@ def add_action_items(self, target: SampleSelection) -> None: dpg.add_menu_item( label=self._label( self._language_manager, - SequencerInstrumentsElements.CONTEXT_EDIT, + SequencerVoicesElements.CONTEXT_EDIT, ), callback=lambda: self.call(self.on_sample_edit_requested, target.voice_id), ) dpg.add_menu_item( label=self._label( self._language_manager, - SequencerInstrumentsElements.CONTEXT_RENAME, + SequencerVoicesElements.CONTEXT_RENAME, ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), callback=lambda: self._start_rename(target.voice_id), @@ -637,7 +693,7 @@ def add_action_items(self, target: SampleSelection) -> None: dpg.add_menu_item( label=self._label( self._language_manager, - SequencerInstrumentsElements.CONTEXT_DUPLICATE, + SequencerVoicesElements.CONTEXT_DUPLICATE, ), callback=lambda: self.call(self.on_duplicate_requested, target.voice_id), ) @@ -645,19 +701,19 @@ def add_action_items(self, target: SampleSelection) -> None: dpg.add_menu_item( label=self._label( self._language_manager, - SequencerInstrumentsElements.CONTEXT_REMOVE, + SequencerVoicesElements.CONTEXT_REMOVE, ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), callback=lambda: self.call(self.on_remove_requested, target.voice_id), ) dpg.add_separator() - for move in SAMPLE_MOVES: + for move in VOICE_MOVES: self._add_move_item(move, target) def _add_move_item( self, move: SampleMove, - target: SampleSelection, + target: VoiceSelection, ) -> None: """Builds one move item, offered while the move carries the sample somewhere new.""" position = move.direction.target(target.position, len(self._entries)) @@ -675,11 +731,23 @@ def _add_move_item( @staticmethod def _label( language_manager: LanguageManager, - element: SequencerInstrumentsElements, + element: SequencerVoicesElements, ) -> str: return language_manager[ Page.SEQUENCER, - Panel.INSTRUMENTS, + Panel.VOICES, TextType.LABEL, element, ] + + @staticmethod + def _tooltip( + language_manager: LanguageManager, + element: SequencerVoicesElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.VOICES, + TextType.TOOLTIP, + element, + ] diff --git a/src/sampletones_application/view_model/sequencer/kind.py b/src/sampletones_application/view_model/sequencer/kind.py new file mode 100644 index 000000000..2176f977d --- /dev/null +++ b/src/sampletones_application/view_model/sequencer/kind.py @@ -0,0 +1,20 @@ +from sampletones_application.view_model.sequencer.voices import VoiceKind +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.voice import VoiceUnion + + +def voice_kind(voice: VoiceUnion) -> VoiceKind: + """Which of the two kinds a voice is, as the list marks it. + + Args: + voice: The voice being listed. + + Returns: + VoiceKind: The kind the row shows and its gestures follow from. + """ + match voice: + case Sample(): + return VoiceKind.SAMPLE + case Shape(): + return VoiceKind.SHAPE diff --git a/src/sampletones_application/view_model/sequencer/samples.py b/src/sampletones_application/view_model/sequencer/samples.py deleted file mode 100644 index 2006bb0f9..000000000 --- a/src/sampletones_application/view_model/sequencer/samples.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Tuple - -from pydantic import BaseModel - -from sampletones_core.utils.display import display_voice_label - - -class SampleEntryViewModel(BaseModel, frozen=True): - voice_id: str - name: str - loop: bool - - -class SampleSelection(BaseModel, frozen=True): - """The samples panel's selected row, offered to the sequencer as an operation target. - - Carries the sample's identity for acting on it and its position for naming it, so an - operation reached from elsewhere in the tab — the browser's replace item — addresses the - selection the same way the samples panel displays it. - """ - - voice_id: str - position: int - name: str - - @property - def label(self) -> str: - """The sample's list label, matching how the samples panel and tracker name it.""" - return display_voice_label(self.position, self.name) - - -class SequencerSamplesViewModel(BaseModel, frozen=True): - """The ordered sample pool shown in the right-hand samples panel.""" - - samples: Tuple[SampleEntryViewModel, ...] diff --git a/src/sampletones_application/view_model/sequencer/voices.py b/src/sampletones_application/view_model/sequencer/voices.py new file mode 100644 index 000000000..f592942d2 --- /dev/null +++ b/src/sampletones_application/view_model/sequencer/voices.py @@ -0,0 +1,49 @@ +from enum import StrEnum +from typing import Tuple + +from pydantic import BaseModel + +from sampletones_core.utils.display import display_voice_label + + +class VoiceKind(StrEnum): + """Which of the two kinds a voice list entry carries. + + A sample stands on a recording it was converted from; a shape was written by hand. The list + marks each so a reader tells them apart, and the gestures a row offers follow from it. + """ + + SAMPLE = "sample" + SHAPE = "shape" + + +class VoiceEntryViewModel(BaseModel, frozen=True): + voice_id: str + name: str + kind: VoiceKind + loop: bool + + +class VoiceSelection(BaseModel, frozen=True): + """The voices panel's selected row, offered to the sequencer as an operation target. + + Carries the voice's identity for acting on it and its position for naming it, so an + operation reached from elsewhere in the tab — the browser's replace item — addresses the + selection the same way the voices panel displays it. + """ + + voice_id: str + position: int + name: str + kind: VoiceKind + + @property + def label(self) -> str: + """The voice's list label, matching how the voices panel and tracker name it.""" + return display_voice_label(self.position, self.name) + + +class SequencerVoicesViewModel(BaseModel, frozen=True): + """The ordered voice pool shown in the right-hand voices panel.""" + + voices: Tuple[VoiceEntryViewModel, ...] diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index 9349642e0..0c2d8f0b3 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -10,28 +10,31 @@ class InstrumentSizeViewModel(BaseModel, frozen=True): - """The bytes one channel's instrument occupies once a tracker compiles it. + """The bytes one instrument occupies once a tracker compiles it. The measurement is carried as it was taken, both regions intact, so a display naming the - whole and one naming a region read the same figure. + whole and one naming a region read the same figure. An instrument naming a channel is a + sample's slice of that channel; one naming none is a shape, stored once for every channel + that reaches it. """ - channel: ChannelName + channel: Optional[ChannelName] footprint: InstrumentFootprint @property def total_bytes(self) -> int: - """The bytes this channel's instrument occupies, its two regions together.""" + """The bytes this instrument occupies, its two regions together.""" return self.footprint.total_bytes class SampleFootprintViewModel(BaseModel, frozen=True): - """The byte sizes a sample's instruments occupy, one entry per channel it covers. + """The byte sizes a voice's instruments occupy. A sample exports one instrument per channel its reconstruction covers, so a display reads - :attr:`total_bytes` for the sample as a whole and :meth:`bytes_for` for a single channel. - Both the instruments panel and the samples menu read their figures from here, so the two - name the same size for the same sample. + :attr:`total_bytes` for the voice as a whole and :meth:`bytes_for` for a single channel. A + shape exports one instrument every channel reaches, so it carries a single entry and its + whole figure is that instrument's. Both the instruments panel and the voices menu read their + figures from here, so the two name the same size for the same voice. """ instruments: Tuple[InstrumentSizeViewModel, ...] @@ -53,11 +56,16 @@ def from_footprints( ), ) + @classmethod + def from_instrument(cls, footprint: InstrumentFootprint) -> Self: + """Carries one instrument every channel reaches, which is what a shape exports.""" + return cls(instruments=(InstrumentSizeViewModel(channel=None, footprint=footprint),)) + @property def total_bytes(self) -> int: - """The bytes the whole sample occupies, its instruments summed region by region. + """The bytes the whole voice occupies, its instruments summed region by region. - The sum is the measurement's own, so a sample's figure and a channel's are arrived at + The sum is the measurement's own, so a voice's figure and a channel's are arrived at the same way. """ return total_footprint(instrument.footprint for instrument in self.instruments).total_bytes diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 07c2ed7ce..3fd7b06fc 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -593,18 +593,24 @@ sequencer.order.tooltip.label_master: "Click to mute every channel, or to bring # ============================================================================= # Sequencer tab — Instruments # ============================================================================= -sequencer.instruments.label.instruments_text: "Samples" -sequencer.instruments.label.column_id: "ID" -sequencer.instruments.label.column_name: "Name" -sequencer.instruments.label.column_loop: "Loop" -sequencer.instruments.label.context_edit: "Edit" -sequencer.instruments.label.context_rename: "Rename" -sequencer.instruments.label.context_duplicate: "Duplicate" -sequencer.instruments.label.context_remove: "Remove" -sequencer.instruments.label.context_move_up: "Move up" -sequencer.instruments.label.context_move_down: "Move down" -sequencer.instruments.label.context_move_top: "Move to top" -sequencer.instruments.label.context_move_bottom: "Move to bottom" +sequencer.voices.label.voices_text: "Voices" +sequencer.voices.label.new_shape: "New shape" +sequencer.voices.label.column_kind: "Kind" +sequencer.voices.label.column_id: "ID" +sequencer.voices.label.column_name: "Name" +sequencer.voices.label.column_loop: "Loop" +sequencer.voices.label.context_edit: "Edit" +sequencer.voices.label.context_rename: "Rename" +sequencer.voices.label.context_duplicate: "Duplicate" +sequencer.voices.label.context_remove: "Remove" +sequencer.voices.label.context_move_up: "Move up" +sequencer.voices.label.context_move_down: "Move down" +sequencer.voices.label.context_move_top: "Move to top" +sequencer.voices.label.context_move_bottom: "Move to bottom" +sequencer.voices.tooltip.new_shape: "Add a hand-written voice, playable on any channel" +sequencer.voices.tooltip.kind_sample: "A converted recording" +sequencer.voices.tooltip.kind_shape: "Written by hand" +sequencer.voices.template.shape_name: "Shape {position}" sequencer.history.label.history_text: "History" sequencer.history.label.undo: "Undo" diff --git a/src/sampletones_config/layout/glyphs.yaml b/src/sampletones_config/layout/glyphs.yaml index a564c1537..468d24326 100644 --- a/src/sampletones_config/layout/glyphs.yaml +++ b/src/sampletones_config/layout/glyphs.yaml @@ -6,6 +6,10 @@ common: chevron_left: "◂" chevron_right: "▸" +voices: + sample: "≈" + shape: "∿" + headers: waveform: "∿" spectrum: "⌇" @@ -19,7 +23,7 @@ headers: parameters: "▦" source: "♪" instruments: "♬" - samples: "♫" + voices: "♫" tracker: "▦" order: "≣" history: "↺" diff --git a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml index 3eb40df3f..0c50a00a4 100644 --- a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml @@ -2,7 +2,8 @@ row: 30 sample: 80 divider: 4 channel: 80 -instrument: +voice: + kind: 24 id: 40 name: 1 loop: 40 diff --git a/src/sampletones_config/theme/tables/instruments_row.yaml b/src/sampletones_config/theme/tables/voices_row.yaml similarity index 81% rename from src/sampletones_config/theme/tables/instruments_row.yaml rename to src/sampletones_config/theme/tables/voices_row.yaml index 276e88836..f36c1536f 100644 --- a/src/sampletones_config/theme/tables/instruments_row.yaml +++ b/src/sampletones_config/theme/tables/voices_row.yaml @@ -1,5 +1,5 @@ -name: table_instruments_row -tag: sequencer.instruments.theme.row +name: table_voices_row +tag: sequencer.voices.theme.row components: - item_type: Table diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index ae075a188..c0790050c 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -51,10 +51,10 @@ TrackerCell, TrackerRegion, ) -from sampletones_application.view_model.sequencer.samples import SampleSelection from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceKind, VoiceSelection from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, @@ -88,7 +88,7 @@ def coordinator() -> SequencerTabCoordinator: instance._history_detail = MagicMock() instance._project_controller = MagicMock() instance._project_controller.is_open = True - instance._project_controller.has_samples = True + instance._project_controller.has_voices = True instance._sequencer_browser_logic = MagicMock() instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 instance._sequencer_tracker_logic = MagicMock() @@ -107,7 +107,7 @@ def samples_coordinator() -> SequencerTabCoordinator: instance = object.__new__(SequencerTabCoordinator) instance._history = MagicMock() instance._history_detail = MagicMock() - instance._sequencer_samples_logic = MagicMock() + instance._sequencer_voices_logic = MagicMock() instance._dialogs = MagicMock() instance._language_manager = FakeLanguageManager(TEXTS) return instance @@ -118,20 +118,20 @@ def test_unused_sample_is_removed_without_confirmation( self, samples_coordinator: SequencerTabCoordinator, ) -> None: - samples_coordinator._sequencer_samples_logic.is_voice_used.return_value = False + samples_coordinator._sequencer_voices_logic.is_voice_used.return_value = False samples_coordinator._remove_voice("abc") - samples_coordinator._sequencer_samples_logic.remove_voice.assert_called_once_with("abc") + samples_coordinator._sequencer_voices_logic.remove_voice.assert_called_once_with("abc") samples_coordinator._dialogs.show_confirmation.assert_not_called() def test_used_sample_prompts_confirmation_before_removing( self, samples_coordinator: SequencerTabCoordinator, ) -> None: - logic = samples_coordinator._sequencer_samples_logic + logic = samples_coordinator._sequencer_voices_logic logic.is_voice_used.return_value = True - logic.sample_name.return_value = "lead" + logic.voice_name.return_value = "lead" samples_coordinator._remove_voice("abc") @@ -152,7 +152,7 @@ def test_submit_rename_trims_whitespace( ) -> None: samples_coordinator._submit_rename("abc", " bass ") - samples_coordinator._sequencer_samples_logic.rename_voice.assert_called_once_with("abc", "bass") + samples_coordinator._sequencer_voices_logic.rename_voice.assert_called_once_with("abc", "bass") def test_submit_rename_ignores_blank_name( self, @@ -160,7 +160,7 @@ def test_submit_rename_ignores_blank_name( ) -> None: samples_coordinator._submit_rename("abc", " ") - samples_coordinator._sequencer_samples_logic.rename_sample.assert_not_called() + samples_coordinator._sequencer_voices_logic.rename_sample.assert_not_called() @pytest.fixture @@ -172,7 +172,7 @@ def nes_frequency_coordinator() -> SequencerTabCoordinator: instance._sequencer_tracker_logic = MagicMock() instance._sequencer_tracker_logic.settings.nes_frequency = 60 instance._project_controller = MagicMock() - instance._project_controller.has_samples = True + instance._project_controller.has_voices = True instance._dialogs = MagicMock() instance._on_nes_frequency_changed = MagicMock() instance._nes_frequency_change_acknowledged = False @@ -194,7 +194,7 @@ def test_applies_without_confirmation_when_no_samples( self, nes_frequency_coordinator: SequencerTabCoordinator, ) -> None: - nes_frequency_coordinator._project_controller.has_samples = False + nes_frequency_coordinator._project_controller.has_voices = False nes_frequency_coordinator._request_nes_frequency_change(30) @@ -574,7 +574,7 @@ def test_empty_project_adopts_reconstruction_frequency_silently( self, coordinator: SequencerTabCoordinator, ) -> None: - coordinator._project_controller.has_samples = False + coordinator._project_controller.has_voices = False coordinator._sequencer_tracker_logic.settings.nes_frequency = 60 coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 @@ -589,7 +589,7 @@ def test_mismatch_with_samples_confirms_before_adding( self, coordinator: SequencerTabCoordinator, ) -> None: - coordinator._project_controller.has_samples = True + coordinator._project_controller.has_voices = True coordinator._sequencer_tracker_logic.settings.nes_frequency = 60 coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 @@ -620,17 +620,18 @@ def replace_coordinator() -> SequencerTabCoordinator: instance._history = MagicMock() instance._history_detail = MagicMock() instance._project_controller = MagicMock() - instance._project_controller.sample_count = 2 + instance._project_controller.voice_count = 2 instance._sequencer_browser_logic = MagicMock() instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 instance._sequencer_tracker_logic = MagicMock() instance._sequencer_tracker_logic.settings.nes_frequency = 60 - instance._sequencer_samples_logic = MagicMock() - instance._sequencer_samples_panel = MagicMock() - instance._sequencer_samples_panel.selection = SampleSelection( + instance._sequencer_voices_logic = MagicMock() + instance._sequencer_voices_panel = MagicMock() + instance._sequencer_voices_panel.selection = VoiceSelection( voice_id="bass-id", position=26, name="bass", + kind=VoiceKind.SAMPLE, ) instance._dialogs = MagicMock() instance._on_sample_reconstruction_replaced = MagicMock() @@ -643,7 +644,7 @@ def test_absent_selection_replaces_nothing( self, replace_coordinator: SequencerTabCoordinator, ) -> None: - replace_coordinator._sequencer_samples_panel.selection = None + replace_coordinator._sequencer_voices_panel.selection = None replace_coordinator.replace_reconstruction(Path("kick_02.stn")) @@ -663,7 +664,7 @@ def test_failed_load_shows_error_and_replaces_nothing( replace_coordinator._dialogs.show_error.assert_called_once() replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called() - replace_coordinator._sequencer_samples_logic.rename_sample.assert_not_called() + replace_coordinator._sequencer_voices_logic.rename_sample.assert_not_called() replace_coordinator._on_sample_reconstruction_replaced.assert_not_called() def test_selected_sample_is_renamed_and_substituted( @@ -674,7 +675,7 @@ def test_selected_sample_is_renamed_and_substituted( replace_coordinator.replace_reconstruction(Path("/reconstructions/kick_02.stn")) - replace_coordinator._sequencer_samples_logic.rename_voice.assert_called_once_with( + replace_coordinator._sequencer_voices_logic.rename_voice.assert_called_once_with( "bass-id", "kick_02", ) @@ -738,7 +739,7 @@ def test_sole_sample_adopts_the_reconstruction_frequency_silently( self, replace_coordinator: SequencerTabCoordinator, ) -> None: - replace_coordinator._project_controller.sample_count = 1 + replace_coordinator._project_controller.voice_count = 1 replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 replace_coordinator.replace_reconstruction(Path("kick_02.stn")) @@ -778,7 +779,7 @@ def test_label_is_absent_without_a_selection( self, replace_coordinator: SequencerTabCoordinator, ) -> None: - replace_coordinator._sequencer_samples_panel.selection = None + replace_coordinator._sequencer_voices_panel.selection = None assert replace_coordinator._replace_target_label() is None diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index d2651f05f..145a72eb8 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -368,19 +368,19 @@ def test_order_length_returns_number_of_frames(self) -> None: controller = _controller() assert controller.order_length >= 1 - def test_sample_count_tracks_the_pool( + def test_voice_count_tracks_the_pool( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: controller = _controller() - assert controller.sample_count == 0 + assert controller.voice_count == 0 sample = controller.add_sample(reconstruction_factory(), name="lead") controller.add_sample(reconstruction_factory(), name="pad") - assert controller.sample_count == 2 + assert controller.voice_count == 2 controller.remove_voice(sample.id) - assert controller.sample_count == 1 + assert controller.voice_count == 1 def test_is_dirty_false_initially(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 160056663..17a3209a4 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -9,8 +9,8 @@ from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) -from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, @@ -37,7 +37,7 @@ def _controller() -> ProjectController: def _formatter(controller: ProjectController) -> SequencerHistoryDetail: tracker_logic = SequencerTrackerLogic(controller) - samples_logic = SequencerSamplesLogic( + samples_logic = SequencerVoicesLogic( controller, MagicMock(), MagicMock(), diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py similarity index 76% rename from tests/unit/sampletones_application/logic/sequencer/test_samples.py rename to tests/unit/sampletones_application/logic/sequencer/test_voices.py index b691a33d7..6451dc81b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -5,20 +5,24 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import ChannelName -from sampletones_core.formats.famitracker.footprint import reconstruction_footprints +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.footprint import ( + features_footprint, + reconstruction_footprints, +) from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction from tests.suite.sequencer import sample_reconstruction -def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]: +def _logic() -> Tuple[ProjectController, SequencerVoicesLogic]: controller = ProjectController(ProjectManager()) - logic = SequencerSamplesLogic( + logic = SequencerVoicesLogic( controller, MagicMock(), MagicMock(), @@ -29,14 +33,14 @@ def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]: def _logic_with_mocks() -> Tuple[ ProjectController, - SequencerSamplesLogic, + SequencerVoicesLogic, MagicMock, MagicMock, ]: controller = ProjectController(ProjectManager()) session_manager = MagicMock() audio_device_manager = MagicMock() - logic = SequencerSamplesLogic( + logic = SequencerVoicesLogic( controller, session_manager, audio_device_manager, @@ -60,13 +64,13 @@ def _place_instrument( class TestSampleName: - def test_returns_the_sample_name( + def test_returns_the_voice_name( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - assert logic.sample_name(sample.id) == "lead" + assert logic.voice_name(sample.id) == "lead" class TestIsSampleUsed: @@ -155,13 +159,13 @@ def test_lists_added_samples_in_insertion_order( first = controller.add_sample(reconstruction_factory(), name="first") second = controller.add_sample(reconstruction_factory(), name="second") - view_model = logic.build_samples() + view_model = logic.build_voices() - assert [entry.voice_id for entry in view_model.samples] == [ + assert [entry.voice_id for entry in view_model.voices] == [ first.id, second.id, ] - assert [entry.name for entry in view_model.samples] == [ + assert [entry.name for entry in view_model.voices] == [ "first", "second", ] @@ -175,7 +179,7 @@ def test_it_names_each_playing_channel(self) -> None: channels = (ChannelName.PULSE1, ChannelName.TRIANGLE) sample = controller.add_sample(sample_reconstruction(channels), name="bell") - footprint = logic.build_sample_footprint(sample.id) + footprint = logic.build_voice_footprint(sample.id) assert footprint is not None assert [instrument.channel for instrument in footprint.instruments] == list(channels) @@ -188,7 +192,7 @@ def test_it_measures_the_sample_under_its_own_loop_flag( sample = controller.add_sample(reconstruction_factory(), name="lead") controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) - footprint = logic.build_sample_footprint(sample.id) + footprint = logic.build_voice_footprint(sample.id) assert footprint == SampleFootprintViewModel.from_footprints( reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT) @@ -201,10 +205,10 @@ def test_a_looping_sample_costs_less_than_a_one_shot( """A looping instrument shares the shortest dimension's length, so it stores fewer items.""" controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - one_shot = logic.build_sample_footprint(sample.id) + one_shot = logic.build_voice_footprint(sample.id) controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) - looping = logic.build_sample_footprint(sample.id) + looping = logic.build_voice_footprint(sample.id) assert one_shot is not None and looping is not None assert looping.total_bytes < one_shot.total_bytes @@ -219,7 +223,7 @@ def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: channels = (ChannelName.PULSE1, ChannelName.TRIANGLE) sample = controller.add_sample(sample_reconstruction(channels), name="bell") - footprint = logic.build_sample_footprint(sample.id) + footprint = logic.build_voice_footprint(sample.id) assert footprint is not None assert footprint.bytes_for(ChannelName.TRIANGLE) < footprint.bytes_for(ChannelName.PULSE1) @@ -227,7 +231,7 @@ def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: _, logic = _logic() - assert logic.build_sample_footprint("missing") is None + assert logic.build_voice_footprint("missing") is None class TestPlaySample: @@ -238,7 +242,7 @@ def test_plays_reconstruction_regardless_of_autoplay( session_manager.autoplay = False sample = controller.add_sample(reconstruction_factory(), name="lead") - logic.play_sample(sample.id) + logic.play_voice(sample.id) audio_device_manager.play.assert_called_once() call = audio_device_manager.play.call_args @@ -249,7 +253,7 @@ def test_plays_reconstruction_regardless_of_autoplay( def test_unknown_sample_is_ignored(self) -> None: _, logic, _, audio_device_manager = _logic_with_mocks() - logic.play_sample("missing") + logic.play_voice("missing") audio_device_manager.play.assert_not_called() @@ -308,3 +312,57 @@ def test_request_edit_cancels_pending_preview( logic._execute_autoplay() audio_device_manager.play.assert_not_called() + + +class TestShapesInTheVoiceList: + """A hand-written voice sits in the same list as a converted one, marked by its kind.""" + + def test_a_shape_is_listed_beside_the_samples_that_were_added( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + controller, logic = _logic() + sample = controller.add_sample(reconstruction_factory(), name="bass") + shape = logic.add_shape("lead") + + entries = logic.build_voices().voices + + assert [(entry.voice_id, entry.kind) for entry in entries] == [ + (sample.id, VoiceKind.SAMPLE), + (shape.id, VoiceKind.SHAPE), + ] + + def test_a_shape_is_measured_as_the_one_instrument_it_exports(self) -> None: + controller, logic = _logic() + shape = logic.add_shape("lead") + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12, 9)) + + footprint = logic.build_voice_footprint(shape.id) + + assert footprint is not None + assert ( + footprint.total_bytes + == features_footprint( + shape.instrument_features(), + loop_point=shape.loop_point, + ).total_bytes + ) + assert [instrument.channel for instrument in footprint.instruments] == [None] + + def test_a_shape_previews_through_the_pulse_channel(self) -> None: + controller, logic, session_manager, audio_device_manager = _logic_with_mocks() + shape = logic.add_shape("lead") + controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12)) + + logic.play_voice(shape.id) + + played = audio_device_manager.play.call_args.args[0] + assert played.size > 0 + + def test_a_shape_writing_nothing_sounds_no_preview(self) -> None: + _, logic, _, audio_device_manager = _logic_with_mocks() + shape = logic.add_shape("lead") + + logic.play_voice(shape.id) + + audio_device_manager.play.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py index c3ec6ed86..1c6ed98c0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -9,8 +9,8 @@ ) from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter, focus from sampletones_application.view_model.sequencer.subcolumn import SubColumn from tests.suite.base import BaseTestSuite @@ -19,7 +19,7 @@ SequencerPanel = Union[ GUISequencerTrackerPanel, GUISequencerOrderPanel, - GUISequencerSamplesPanel, + GUISequencerVoicesPanel, ] SELECTED_ID = "bass-id" @@ -49,9 +49,9 @@ def _order(tab_active: ActivePredicate) -> GUISequencerOrderPanel: return panel -def _samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel: +def _samples(tab_active: ActivePredicate) -> GUISequencerVoicesPanel: """A samples panel holding a selection, which is what it keeps across a move to another tab.""" - panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) panel._router = KeyRouter() panel._tab_active = tab_active panel._selected_voice_id = SELECTED_ID @@ -59,7 +59,7 @@ def _samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel: return panel -def _renaming_samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel: +def _renaming_samples(tab_active: ActivePredicate) -> GUISequencerVoicesPanel: """A samples panel mid-rename, the one state that keeps the keyboard on its own tab.""" panel = _samples(tab_active) panel._editing_voice_id = SELECTED_ID diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py deleted file mode 100644 index 79b981ee6..000000000 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py +++ /dev/null @@ -1,105 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Tuple - -import pytest - -from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel -from sampletones_application.utils.gui.keyboard.combination import KeyCombination -from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel -from tests.suite.shortcuts import shipped_source - -ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(voice_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(voice_id="bass-id", name="Bass", loop=True), - SampleEntryViewModel(voice_id="lead-id", name="Lead", loop=False), -) - -SELECTED_ID = "bass-id" -SELECTED_ROW = 1 - -Move = Tuple[str, int] - - -@dataclass -class SamplesPanelFixture: - """A panel carrying the state the key path reads, with the calls each action makes recorded.""" - - panel: GUISequencerSamplesPanel - removed: List[str] = field(default_factory=list) - moved: List[Move] = field(default_factory=list) - renamed: List[str] = field(default_factory=list) - cancelled: List[None] = field(default_factory=list) - - -@pytest.fixture -def samples(monkeypatch: pytest.MonkeyPatch) -> SamplesPanelFixture: - panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) - panel._shortcuts = shipped_source() - panel._entries = ENTRIES - panel._selected_voice_id = SELECTED_ID - panel._selected_row = SELECTED_ROW - panel._editing_voice_id = None - - fixture = SamplesPanelFixture(panel=panel) - panel.on_remove_requested = fixture.removed.append - panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target)) - monkeypatch.setattr(panel, "_start_rename", fixture.renamed.append) - monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None)) - return fixture - - -def _press(text: str) -> KeyEvent: - """The press a written combination names, as the router delivers it.""" - combination = KeyCombination.parse(text) - return KeyEvent(key=combination.key, modifiers=combination.modifiers) - - -class TestSelectedSampleActions: - def test_the_remove_key_removes_the_selected_sample(self, samples: SamplesPanelFixture) -> None: - assert samples.panel._on_key_pressed(_press("Del")) is True - assert samples.removed == [SELECTED_ID] - - def test_the_rename_key_starts_the_rename(self, samples: SamplesPanelFixture) -> None: - assert samples.panel._on_key_pressed(_press("F2")) is True - assert samples.renamed == [SELECTED_ID] - - def test_a_press_the_panel_leaves_unnamed_reaches_the_application(self, samples: SamplesPanelFixture) -> None: - assert samples.panel._on_key_pressed(_press("Ctrl+S")) is False - assert samples.removed == [] - - def test_a_press_without_a_selection_reaches_the_application(self, samples: SamplesPanelFixture) -> None: - samples.panel._selected_voice_id = None - - assert samples.panel._on_key_pressed(_press("Del")) is False - - -class TestSampleMoves: - def test_the_move_up_key_moves_the_sample_one_row_back(self, samples: SamplesPanelFixture) -> None: - assert samples.panel._on_key_pressed(_press("Alt+Up")) is True - assert samples.moved == [(SELECTED_ID, SELECTED_ROW - 1)] - - def test_the_move_to_bottom_key_moves_the_sample_last(self, samples: SamplesPanelFixture) -> None: - assert samples.panel._on_key_pressed(_press("Alt+End")) is True - assert samples.moved == [(SELECTED_ID, len(ENTRIES) - 1)] - - def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, samples: SamplesPanelFixture) -> None: - samples.panel._selected_row = 0 - - assert samples.panel._on_key_pressed(_press("Alt+Up")) is True - assert samples.moved == [] - - -class TestRenameInProgress: - def test_the_cancel_key_drops_the_name_being_edited(self, samples: SamplesPanelFixture) -> None: - samples.panel._editing_voice_id = SELECTED_ID - - assert samples.panel._on_key_pressed(_press("Esc")) is True - assert samples.cancelled == [None] - - def test_every_other_key_stays_with_the_field(self, samples: SamplesPanelFixture) -> None: - """A rename keeps the keyboard, so typing a name reaches the input rather than the list.""" - samples.panel._editing_voice_id = SELECTED_ID - - assert samples.panel._on_key_pressed(_press("Del")) is False - assert samples.removed == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 70f341440..d097957e6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -7,11 +7,12 @@ from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.region import TrackerRegion -from sampletones_application.view_model.sequencer.samples import ( - SampleEntryViewModel, - SequencerSamplesViewModel, -) from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, + VoiceEntryViewModel, + VoiceKind, +) from sampletones_core.constants.enums import ChannelName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from tests.suite.shortcuts import shipped_source @@ -135,11 +136,12 @@ def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuIt def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) -> None: panel = _panel() - panel._current_samples = SequencerSamplesViewModel( - samples=( - SampleEntryViewModel( + panel._current_samples = SequencerVoicesViewModel( + voices=( + VoiceEntryViewModel( voice_id="lead-id", name="lead", + kind=VoiceKind.SAMPLE, loop=False, ), ), diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py new file mode 100644 index 000000000..97f52e2d7 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass, field +from typing import List, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind +from tests.suite.shortcuts import shipped_source + +ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE, loop=False), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE, loop=True), + VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE, loop=False), +) + +SELECTED_ID = "bass-id" +SELECTED_ROW = 1 + +Move = Tuple[str, int] + + +@dataclass +class VoicesPanelFixture: + """A panel carrying the state the key path reads, with the calls each action makes recorded.""" + + panel: GUISequencerVoicesPanel + removed: List[str] = field(default_factory=list) + moved: List[Move] = field(default_factory=list) + renamed: List[str] = field(default_factory=list) + cancelled: List[None] = field(default_factory=list) + + +@pytest.fixture +def voices(monkeypatch: pytest.MonkeyPatch) -> VoicesPanelFixture: + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) + panel._shortcuts = shipped_source() + panel._entries = ENTRIES + panel._selected_voice_id = SELECTED_ID + panel._selected_row = SELECTED_ROW + panel._editing_voice_id = None + + fixture = VoicesPanelFixture(panel=panel) + panel.on_remove_requested = fixture.removed.append + panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target)) + monkeypatch.setattr(panel, "_start_rename", fixture.renamed.append) + monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None)) + return fixture + + +def _press(text: str) -> KeyEvent: + """The press a written combination names, as the router delivers it.""" + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +class TestSelectedSampleActions: + def test_the_remove_key_removes_the_selected_sample(self, voices: VoicesPanelFixture) -> None: + assert voices.panel._on_key_pressed(_press("Del")) is True + assert voices.removed == [SELECTED_ID] + + def test_the_rename_key_starts_the_rename(self, voices: VoicesPanelFixture) -> None: + assert voices.panel._on_key_pressed(_press("F2")) is True + assert voices.renamed == [SELECTED_ID] + + def test_a_press_the_panel_leaves_unnamed_reaches_the_application(self, voices: VoicesPanelFixture) -> None: + assert voices.panel._on_key_pressed(_press("Ctrl+S")) is False + assert voices.removed == [] + + def test_a_press_without_a_selection_reaches_the_application(self, voices: VoicesPanelFixture) -> None: + voices.panel._selected_voice_id = None + + assert voices.panel._on_key_pressed(_press("Del")) is False + + +class TestSampleMoves: + def test_the_move_up_key_moves_the_sample_one_row_back(self, voices: VoicesPanelFixture) -> None: + assert voices.panel._on_key_pressed(_press("Alt+Up")) is True + assert voices.moved == [(SELECTED_ID, SELECTED_ROW - 1)] + + def test_the_move_to_bottom_key_moves_the_sample_last(self, voices: VoicesPanelFixture) -> None: + assert voices.panel._on_key_pressed(_press("Alt+End")) is True + assert voices.moved == [(SELECTED_ID, len(ENTRIES) - 1)] + + def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, voices: VoicesPanelFixture) -> None: + voices.panel._selected_row = 0 + + assert voices.panel._on_key_pressed(_press("Alt+Up")) is True + assert voices.moved == [] + + +class TestRenameInProgress: + def test_the_cancel_key_drops_the_name_being_edited(self, voices: VoicesPanelFixture) -> None: + voices.panel._editing_voice_id = SELECTED_ID + + assert voices.panel._on_key_pressed(_press("Esc")) is True + assert voices.cancelled == [None] + + def test_every_other_key_stays_with_the_field(self, voices: VoicesPanelFixture) -> None: + """A rename keeps the keyboard, so typing a name reaches the input rather than the list.""" + voices.panel._editing_voice_id = SELECTED_ID + + assert voices.panel._on_key_pressed(_press("Del")) is False + assert voices.removed == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py similarity index 88% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py rename to tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index 6b239eac9..d796bbeee 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -5,24 +5,24 @@ import pytest from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.categories.elements.sequencer import SequencerVoicesElements from sampletones_application.ui.elements import context_menu as context_menu_module from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.panels.sequencer import samples as samples_module -from sampletones_application.ui.panels.sequencer.samples import SAMPLE_MOVES, GUISequencerSamplesPanel +from sampletones_application.ui.panels.sequencer import voices as voices_module +from sampletones_application.ui.panels.sequencer.voices import VOICE_MOVES, GUISequencerVoicesPanel from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.colors.literal import LiteralColor -from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.utils.display import display_voice_label from tests.suite.shortcuts import shipped_source -ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(voice_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(voice_id="bass-id", name="Bass", loop=True), - SampleEntryViewModel(voice_id="lead-id", name="Lead", loop=False), +ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE, loop=False), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE, loop=True), + VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE, loop=False), ) SELECTED_ID = "bass-id" @@ -94,16 +94,16 @@ def add_menu_item(self, **kwargs: Any) -> int: @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: recorded = _MenuRecorder() - monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) - monkeypatch.setattr(samples_module.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(voices_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(voices_module.dpg, "add_separator", lambda **_kwargs: 0) return recorded @dataclass -class SamplesPanelFixture: +class VoicesPanelFixture: """A panel holding a selection, with the calls each menu item makes recorded.""" - panel: GUISequencerSamplesPanel + panel: GUISequencerVoicesPanel requests: Requests @@ -116,9 +116,9 @@ def _panel( field_focused: bool = False, footprint: Optional[SampleFootprintViewModel] = FOOTPRINT, footprint_wired: bool = True, -) -> SamplesPanelFixture: +) -> VoicesPanelFixture: """A samples panel whose menu builder can run with no DearPyGui context behind it.""" - panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) panel._language_manager = _Labels() panel._shortcuts = shipped_source() panel._entries = ENTRIES @@ -139,7 +139,7 @@ def _panel( panel.on_remove_requested = requests.removed.append panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target)) monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) - return SamplesPanelFixture(panel=panel, requests=requests) + return VoicesPanelFixture(panel=panel, requests=requests) class _Labels: @@ -200,10 +200,10 @@ def _null_menu() -> Iterator[None]: def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: """Records a whole context-menu build, with the DearPyGui calls behind it stood down.""" recorded = _MenuBuildRecorder() - monkeypatch.setattr(samples_module.dpg, "add_text", recorded.add_text) - monkeypatch.setattr(samples_module.dpg, "add_separator", recorded.add_separator) - monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) - monkeypatch.setattr(samples_module, "context_menu", _null_menu) + monkeypatch.setattr(voices_module.dpg, "add_text", recorded.add_text) + monkeypatch.setattr(voices_module.dpg, "add_separator", recorded.add_separator) + monkeypatch.setattr(voices_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(voices_module, "context_menu", _null_menu) monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None) monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip) monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None) @@ -230,11 +230,11 @@ def test_the_menu_reads_as_the_sample_actions( _panel(monkeypatch).panel.build_edit_actions() assert [item.label for item in recorder.items] == [ - SequencerInstrumentsElements.CONTEXT_EDIT.value, - SequencerInstrumentsElements.CONTEXT_RENAME.value, - SequencerInstrumentsElements.CONTEXT_DUPLICATE.value, - SequencerInstrumentsElements.CONTEXT_REMOVE.value, - *(move.element.value for move in SAMPLE_MOVES), + SequencerVoicesElements.CONTEXT_EDIT.value, + SequencerVoicesElements.CONTEXT_RENAME.value, + SequencerVoicesElements.CONTEXT_DUPLICATE.value, + SequencerVoicesElements.CONTEXT_REMOVE.value, + *(move.element.value for move in VOICE_MOVES), ] def test_the_items_print_the_keys_the_panel_answers_to( @@ -249,7 +249,7 @@ def test_the_items_print_the_keys_the_panel_answers_to( assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE) assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE) assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [ - shortcuts.display(move.shortcut) for move in SAMPLE_MOVES + shortcuts.display(move.shortcut) for move in VOICE_MOVES ] def test_the_items_act_on_the_sample_they_were_raised_on( diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_selection.py similarity index 65% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py rename to tests/unit/sampletones_application/ui/panels/sequencer/test_voices_selection.py index cf72758a5..9ac0544cb 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_selection.py @@ -1,25 +1,25 @@ from typing import Optional, Tuple -from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel -from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel +from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind -ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(voice_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(voice_id="bass-id", name="Bass", loop=True), +ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE, loop=False), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE, loop=True), ) def _panel( selected_voice_id: Optional[str], selected_row: Optional[int], - entries: Tuple[SampleEntryViewModel, ...] = ENTRIES, -) -> GUISequencerSamplesPanel: + entries: Tuple[VoiceEntryViewModel, ...] = ENTRIES, +) -> GUISequencerVoicesPanel: """Builds a panel without its DearPyGui-dependent constructor. The selection accessor reads only the cached entries and the highlighted row, so a running GUI context is unnecessary here. """ - panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) panel._entries = entries panel._selected_voice_id = selected_voice_id panel._selected_row = selected_row @@ -48,7 +48,7 @@ def test_absent_once_the_selected_sample_leaves_the_pool(self) -> None: def test_follows_a_renamed_sample(self) -> None: panel = _panel("kick-id", 0) - panel._entries = (SampleEntryViewModel(voice_id="kick-id", name="Thump", loop=False),) + panel._entries = (VoiceEntryViewModel(voice_id="kick-id", name="Thump", kind=VoiceKind.SAMPLE, loop=False),) selection = panel.selection diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py b/tests/unit/sampletones_application/view_model/sequencer/test_voices.py similarity index 65% rename from tests/unit/sampletones_application/view_model/sequencer/test_samples.py rename to tests/unit/sampletones_application/view_model/sequencer/test_voices.py index e0f19e5a5..14a0f2694 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_voices.py @@ -1,9 +1,9 @@ import pytest -from sampletones_application.view_model.sequencer.samples import SampleSelection +from sampletones_application.view_model.sequencer.voices import VoiceKind, VoiceSelection -class TestSampleSelectionLabel: +class TestVoiceSelectionLabel: @pytest.mark.parametrize( ("position", "name", "expected"), [ @@ -18,6 +18,6 @@ def test_label_pairs_the_hex_position_with_the_name( name: str, expected: str, ) -> None: - selection = SampleSelection(voice_id="id", position=position, name=name) + selection = VoiceSelection(voice_id="id", position=position, name=name, kind=VoiceKind.SAMPLE) assert selection.label == expected From e2f24efaa07cc8ae743803a66030fef69722b7c0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 05:55:40 +0200 Subject: [PATCH 077/142] Read: a pitch cell in the terms of the voice its channel carries --- .../logic/sequencer/tracker/tracker.py | 69 ++++++++- src/sampletones_core/features/__init__.py | 4 + src/sampletones_core/features/spec.py | 47 +++++- src/sampletones_core/performance/modifiers.py | 9 +- src/sampletones_core/utils/display.py | 29 ++++ src/sampletones_core/utils/frequencies.py | 11 ++ src/sampletones_core/utils/pitch_kind.py | 14 ++ .../sequencer/tracker/test_pitch_faces.py | 136 ++++++++++++++++++ 8 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 079dc27a0..3941870b4 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -16,10 +16,13 @@ from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion, voice_channels from sampletones_core.utils.display import ( display_command, display_id, + display_note, display_transpose, display_volume, ) @@ -72,7 +75,8 @@ def build_grid(self) -> SequencerTrackerViewModel: frame_index = self._clamp_frame(frame_count) patterns = self._frame_patterns() - rows = tuple(self._build_row(index, patterns) for index in range(self.frame_row_count())) + carried: Dict[ChannelName, Optional[VoiceUnion]] = {channel: None for channel in ChannelName.items()} + rows = tuple(self._build_row(index, patterns, carried) for index in range(self.frame_row_count())) return SequencerTrackerViewModel( frame_index=frame_index, frame_count=frame_count, @@ -339,13 +343,17 @@ def set_sample_instrument( channel the sample covers, and the remaining channels on that row are cleared so the row reflects exactly that sample. Clearing an empty sample id wipes the whole row. + + The column speaks for samples, which carry a slice per channel; a shape carries one + instrument the reader places on the channel they want it on, so it is named in a channel + column and this one leaves the row as it stands. """ if voice_id is None: self.clear_all_channels(row_index) return sample = self._controller.project.voices.get(voice_id) - if sample is None: + if not isinstance(sample, Sample): return used = self._used_generators(sample) @@ -601,7 +609,13 @@ def _build_row( self, index: int, patterns: Dict[ChannelName, Pattern], + carried: Dict[ChannelName, Optional[VoiceUnion]], ) -> SequencerRowViewModel: + """One grid line, with each channel read in the terms of the voice it is carrying. + + ``carried`` walks down the frame with the rows, so a line bending a note it did not start + still reads in that voice's terms. + """ rows: Dict[ChannelName, Optional[Row]] = {} cells: Dict[ChannelName, SequencerCellViewModel] = {} for channel in ChannelName.items(): @@ -609,7 +623,8 @@ def _build_row( if pattern is not None and index < pattern.length: row = pattern.rows[index] rows[channel] = row - cells[channel] = self._build_cell(row) + carried[channel] = self._carried_voice(row, carried[channel]) + cells[channel] = self._build_cell(row, channel, carried[channel]) else: rows[channel] = None cells[channel] = _EMPTY_CELL @@ -620,16 +635,60 @@ def _build_row( relevant_channels=self._referenced_generators_from_rows(rows), ) - def _build_cell(self, row: Row) -> SequencerCellViewModel: + def _carried_voice( + self, + row: Row, + carried: Optional[VoiceUnion], + ) -> Optional[VoiceUnion]: + """The voice a channel carries once it has reached ``row``. + + A note column names the voice from that line on, a note-off leaves the channel carrying + none, and a line naming neither plays on with whatever it already had. + """ + match row.command: + case NoteOn() as note_on: + return self._controller.project.voices.get(note_on.voice_id) + case NoteOff(): + return None + case None: + return carried + + def _build_cell( + self, + row: Row, + channel: ChannelName, + voice: Optional[VoiceUnion], + ) -> SequencerCellViewModel: + """One cell's three readings, the pitch stated in the terms its voice is written in. + + A sample was converted at a pitch of its own, so its rows read as steps from it; a shape + was written against a root the reader chose, so its rows read as the notes they sound. + """ return SequencerCellViewModel( instrument=display_command( self._controller.project.voices, row.command, ), - transpose=display_transpose(row.transpose), + transpose=self._display_pitch(row.transpose, channel, voice), volume=display_volume(row.volume), ) + @staticmethod + def _display_pitch( + transpose: Optional[int], + channel: ChannelName, + voice: Optional[VoiceUnion], + ) -> str: + match voice: + case Shape() as shape: + return display_note( + transpose, + channel_name=channel, + reference=shape.reference(channel), + ) + case _: + return display_transpose(transpose) + def _clamp_frame(self, frame_count: int) -> int: if frame_count == 0: return 0 diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index 4f25f3da3..41d817841 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -10,8 +10,10 @@ feature_range, resting_held_features, resting_reference, + speaks_in_periods, supported_features, supports, + transposed_reference, ) __all__ = [ @@ -24,8 +26,10 @@ "FeatureRange", "channel_reference", "feature_range", + "speaks_in_periods", "resting_held_features", "resting_reference", "supported_features", "supports", + "transposed_reference", ] diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index 4466b4068..0b7e5f056 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -11,6 +11,7 @@ MAX_VOLUME, NUM_PERIODS, ) +from sampletones_core.utils.frequencies import transpose_period, transpose_pitch @dataclass(frozen=True) @@ -73,6 +74,21 @@ class FeatureRange: } +def speaks_in_periods(channel_name: ChannelName) -> bool: + """Whether this channel reads a pitch-like value as a noise period rather than a semitone. + + The noise channel selects one of sixteen periods where the others name a note, so every rule + that reads a pitch — a reference, a note name, a transpose — turns on this one answer. + + Args: + channel_name: The channel being read. + + Returns: + bool: Whether the channel speaks in noise periods. + """ + return CHANNEL_GENERATOR_KIND[channel_name] is GeneratorName.NOISE + + def channel_reference( channel_name: ChannelName, *, @@ -93,11 +109,32 @@ def channel_reference( Returns: int: The reference this channel reads. """ - match CHANNEL_GENERATOR_KIND[channel_name]: - case GeneratorName.NOISE: - return period - case _: - return pitch + return period if speaks_in_periods(channel_name) else pitch + + +def transposed_reference( + channel_name: ChannelName, + reference: int, + transpose: int, +) -> int: + """Where a voice sounds on one channel once a row's transpose has moved it. + + This is the pitch the channel plays, so a grid printing a note and a channel sounding one + arrive at the same value: a tonal channel is held inside the range it plays, and the noise + channel walks around the sixteen periods the hardware offers. + + Args: + channel_name: The channel sounding the voice. + reference: The value the voice is measured against on this channel. + transpose: The semitones the row has reached. + + Returns: + int: The pitch, or the period, the channel sounds. + """ + if speaks_in_periods(channel_name): + return transpose_period(reference, transpose) + + return transpose_pitch(reference, transpose) def resting_reference(channel_name: ChannelName) -> int: diff --git a/src/sampletones_core/performance/modifiers.py b/src/sampletones_core/performance/modifiers.py index 52a88b7e4..e4f7a9d1a 100644 --- a/src/sampletones_core/performance/modifiers.py +++ b/src/sampletones_core/performance/modifiers.py @@ -1,10 +1,11 @@ -from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH +from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction, ) +from sampletones_core.utils.frequencies import transpose_period, transpose_pitch def apply_modifiers( @@ -31,13 +32,13 @@ def apply_modifiers( match instruction: case PulseInstruction(): scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) - effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) + effective_pitch = transpose_pitch(instruction.pitch, transpose) return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) case TriangleInstruction(): - effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) + effective_pitch = transpose_pitch(instruction.pitch, transpose) on = instruction.on and row_volume > MAX_VOLUME // 2 return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) case NoiseInstruction(): scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) - effective_period = (instruction.period + transpose) % 16 + effective_period = transpose_period(instruction.period, transpose) return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index 878cf94e8..7dc169af3 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -1,9 +1,12 @@ from typing import Final, Optional, Union +from sampletones_core.constants.enums import ChannelName +from sampletones_core.features import transposed_reference from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.structures import IdentifiedCollection +from sampletones_core.utils.pitch_kind import channel_pitch_kind from sampletones_shared.constants.symbols import MINUS, PLUS DEFAULT_DISPLAY_LENGTH: Final[int] = 2 @@ -69,6 +72,32 @@ def display_volume(value: Optional[int]) -> str: return display_value(value, length=1, hexadecimal=True) +def display_note( + value: Optional[int], + *, + channel_name: ChannelName, + reference: int, +) -> str: + """Render a pitch column as the note it sounds, or ``...`` for an empty one. + + The note is where the voice's reference lands once the row's transpose has moved it, so the + grid prints the note the channel plays. The noise channel names its period the same way + FamiTracker does. + + Args: + value: The semitones the row states, or ``None`` for an empty cell. + channel_name: The channel the row sits on. + reference: The value the voice is measured against on that channel. + + Returns: + str: The note name, three characters wide like every other reading of the column. + """ + if value is None: + return NOTE_BLANK + + return channel_pitch_kind(channel_name).to_name(transposed_reference(channel_name, reference, value)) + + def display_transpose(value: Optional[int]) -> str: """Render a transpose as a signed two-digit offset, or ``...`` for an empty one. diff --git a/src/sampletones_core/utils/frequencies.py b/src/sampletones_core/utils/frequencies.py index eeb2d966d..e0481de98 100644 --- a/src/sampletones_core/utils/frequencies.py +++ b/src/sampletones_core/utils/frequencies.py @@ -4,6 +4,7 @@ MIN_PITCH, NOISE_PERIODS, NOTE_NAMES, + NUM_PERIODS, ) from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.frequencies import validate_pitch @@ -40,6 +41,16 @@ def validate_period(period: int) -> None: raise ValueError(f"Period must be in the range 0-{MAX_PERIOD}") +def transpose_pitch(pitch: int, transpose: int) -> int: + """The pitch a transpose reaches, held inside the range the channels play.""" + return clamp_pitch(pitch + transpose) + + +def transpose_period(period: int, transpose: int) -> int: + """The period a transpose reaches, walked around the sixteen the hardware offers.""" + return (period + transpose) % NUM_PERIODS + + def pitch_to_name(pitch: int, transpose: int = 0) -> str: """ Converts a MIDI pitch value to a human-readable note name diff --git a/src/sampletones_core/utils/pitch_kind.py b/src/sampletones_core/utils/pitch_kind.py index 05e16184b..4f561fe29 100644 --- a/src/sampletones_core/utils/pitch_kind.py +++ b/src/sampletones_core/utils/pitch_kind.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from typing import Callable, Mapping +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH +from sampletones_core.features import speaks_in_periods from sampletones_core.utils.frequencies import ( SANITIZED_NAME_TO_PERIOD, SANITIZED_NAME_TO_PITCH, @@ -54,3 +56,15 @@ def from_text(self, text: str, fallback: int) -> int: sanitize=sanitize_period, sanitized_name_to_value=SANITIZED_NAME_TO_PERIOD, ) + + +def channel_pitch_kind(channel_name: ChannelName) -> PitchValueKind: + """The terms a channel states its pitch-like values in. + + Args: + channel_name: The channel being read. + + Returns: + PitchValueKind: The noise channel's periods, or the semitones the others name. + """ + return PERIOD_VALUE_KIND if speaks_in_periods(channel_name) else PITCH_VALUE_KIND diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py new file mode 100644 index 000000000..862fcc630 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -0,0 +1,136 @@ +from typing import Final, Optional, Tuple + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.shape import Shape +from sampletones_core.utils.display import NOTE_BLANK, display_transpose +from sampletones_core.utils.frequencies import period_to_name, pitch_to_name +from tests.suite.sequencer import sample_reconstruction + +ROOT_PITCH: Final[int] = 60 +ROOT_PERIOD: Final[int] = 5 +TRANSPOSE: Final[int] = 4 +BEND: Final[int] = 7 + + +def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]: + controller = ProjectController(ProjectManager()) + return controller, SequencerTrackerLogic(controller) + + +def _shape(controller: ProjectController) -> Shape: + shape = controller.add_shape("lead") + controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) + shape.envelopes = ShapeEnvelopes(volume=(15,)) + shape.invalidate() + return shape + + +def _write( + controller: ProjectController, + channel: ChannelName, + row_index: int, + *, + command: Optional[object] = None, + transpose: Optional[int] = None, +) -> None: + pattern_index = controller.project.song.order[0][channel] + controller.set_row( + channel, + pattern_index, + row_index, + command=command, + transpose=transpose, + ) + + +def _pitch_cell(logic: SequencerTrackerLogic, channel: ChannelName, row_index: int) -> str: + return logic.build_grid().rows[row_index].cells[channel].transpose + + +class TestACellReadsInTheTermsOfItsVoice: + def test_a_sample_reads_as_a_step_from_its_own_pitch(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass") + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=sample.id), transpose=TRANSPOSE) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(TRANSPOSE) + + def test_a_shape_reads_as_the_note_it_sounds(self) -> None: + controller, logic = _logic() + shape = _shape(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=TRANSPOSE) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == pitch_to_name(ROOT_PITCH + TRANSPOSE) + + def test_a_shape_on_noise_names_its_period(self) -> None: + controller, logic = _logic() + shape = _shape(controller) + _write(controller, ChannelName.NOISE, 0, command=NoteOn(voice_id=shape.id), transpose=TRANSPOSE) + + assert _pitch_cell(logic, ChannelName.NOISE, 0) == period_to_name(ROOT_PERIOD + TRANSPOSE) + + def test_an_empty_cell_reads_blank_whichever_voice_is_carried(self) -> None: + controller, logic = _logic() + shape = _shape(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=TRANSPOSE) + + assert _pitch_cell(logic, ChannelName.PULSE1, 1) == NOTE_BLANK + + +class TestTheFaceFollowsTheVoiceTheChannelCarries: + def test_a_bend_below_a_shape_still_reads_as_a_note(self) -> None: + """A row bending a note it did not start reads in the terms of the voice in force.""" + controller, logic = _logic() + shape = _shape(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=0) + _write(controller, ChannelName.PULSE1, 1, transpose=BEND) + + assert _pitch_cell(logic, ChannelName.PULSE1, 1) == pitch_to_name(ROOT_PITCH + BEND) + + def test_a_bend_below_a_sample_still_reads_as_a_step(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass") + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=sample.id), transpose=0) + _write(controller, ChannelName.PULSE1, 1, transpose=BEND) + + assert _pitch_cell(logic, ChannelName.PULSE1, 1) == display_transpose(BEND) + + def test_a_note_off_hands_the_column_back_to_the_neutral_face(self) -> None: + controller, logic = _logic() + shape = _shape(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=0) + _write(controller, ChannelName.PULSE1, 1, command=NoteOff()) + _write(controller, ChannelName.PULSE1, 2, transpose=BEND) + + assert _pitch_cell(logic, ChannelName.PULSE1, 2) == display_transpose(BEND) + + def test_a_frame_naming_no_voice_reads_as_a_step(self) -> None: + controller, logic = _logic() + _write(controller, ChannelName.PULSE1, 0, transpose=BEND) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(BEND) + + +class TestTheSampleColumnSpeaksForSamples: + def test_it_declines_a_shape(self) -> None: + controller, logic = _logic() + shape = _shape(controller) + + logic.set_sample_instrument(0, shape.id) + + assert all(logic.row(channel, 0) is None or logic.row(channel, 0).is_empty() for channel in ChannelName.items()) + + def test_it_reads_mixed_where_the_channels_disagree_on_the_face(self) -> None: + controller, logic = _logic() + shape = _shape(controller) + sample = controller.add_sample(sample_reconstruction(list(ChannelName.items())), name="bass") + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=0) + _write(controller, ChannelName.PULSE2, 0, command=NoteOn(voice_id=sample.id), transpose=0) + + assert logic.build_grid().rows[0].sample_transpose == "?" From c76300e3b76626a5921423af186f7d0729117ba7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 06:18:02 +0200 Subject: [PATCH 078/142] Typed: a note into the grid, piano-style, at the octave in force --- .../categories/elements/sequencer.py | 1 + .../config/managers/application.py | 7 + .../config/managers/session.py | 7 + .../config/session/application/config.py | 5 + .../config/session/application/tracker.py | 12 ++ .../constants/tracker.py | 5 + .../coordinators/tabs/sequencer.py | 17 +++ .../layout/tabs/sequencer/tracker/tracker.py | 1 + .../logic/sequencer/history_detail.py | 18 +++ .../logic/sequencer/tracker/tracker.py | 56 +++++++- src/sampletones_application/tags/sequencer.py | 6 + .../ui/panels/sequencer/tracker.py | 75 ++++++++++- .../utils/gui/keyboard/piano.py | 46 +++++++ src/sampletones_config/lang/en.yaml | 2 + .../layout/tabs/sequencer/tracker.yaml | 1 + src/sampletones_core/performance/voice.py | 11 +- .../project/voices/__init__.py | 3 +- src/sampletones_core/project/voices/voice.py | 20 +++ src/sampletones_core/utils/frequencies.py | 5 +- src/sampletones_shared/constants/music.py | 1 + .../sequencer/tracker/test_write_note.py | 85 ++++++++++++ .../ui/panels/sequencer/test_tracker_piano.py | 122 ++++++++++++++++++ 22 files changed, 492 insertions(+), 14 deletions(-) create mode 100644 src/sampletones_application/config/session/application/tracker.py create mode 100644 src/sampletones_application/constants/tracker.py create mode 100644 src/sampletones_application/utils/gui/keyboard/piano.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index b5254e922..fb39ab673 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -20,6 +20,7 @@ class SequencerModuleElements(AbstractElement): class SequencerTrackerElements(AbstractElement): TRACKER_TEXT = "tracker_text" + OCTAVE = "octave" COLUMN_ROW = "column_row" COLUMN_SAMPLE = "column_sample" COLUMN_PULSE_1 = "column_pulse_1" diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 20f912569..2e911a106 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -153,6 +153,13 @@ def follow_mode(self) -> FollowMode: def set_follow_mode(self, value: FollowMode) -> None: self.config.playback.follow_mode = value + @property + def octave(self) -> int: + return self.config.tracker.octave + + def set_octave(self, value: int) -> None: + self.config.tracker.octave = value + @property def loop_song(self) -> bool: return self.config.playback.loop_song diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index c5ee6fa1b..d19ab68b1 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -87,6 +87,9 @@ def set_follow_mode(self, value: FollowMode) -> None: def set_loop_song(self, value: bool) -> None: self._config_manager.set_loop_song(value) + def set_octave(self, value: int) -> None: + self._config_manager.set_octave(value) + def toggle_favorite(self, path: Path) -> None: self._config_manager.toggle_favorite(path) @@ -263,6 +266,10 @@ def auto_expand_favorite_directories(self) -> bool: def follow_mode(self) -> FollowMode: return self._config_manager.follow_mode + @property + def octave(self) -> int: + return self._config_manager.octave + @property def loop_song(self) -> bool: return self._config_manager.loop_song diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index 0c3fc0c9f..467fb425b 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -7,6 +7,7 @@ from sampletones_application.config.session.application.history import HistoryConfig from sampletones_application.config.session.application.playback import PlaybackConfig from sampletones_application.config.session.application.shortcuts import ShortcutsConfig +from sampletones_application.config.session.application.tracker import TrackerConfig from sampletones_core.data import Metadata @@ -45,3 +46,7 @@ class ApplicationConfig(BaseModel): default_factory=ShortcutsConfig, description="The keybinding scheme and the actions rebound on it.", ) + tracker: TrackerConfig = Field( + default_factory=TrackerConfig, + description="How the pattern grid is typed into.", + ) diff --git a/src/sampletones_application/config/session/application/tracker.py b/src/sampletones_application/config/session/application/tracker.py new file mode 100644 index 000000000..b2a325131 --- /dev/null +++ b/src/sampletones_application/config/session/application/tracker.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, Field + +from sampletones_application.constants.tracker import DEFAULT_OCTAVE, MAX_OCTAVE, MIN_OCTAVE + + +class TrackerConfig(BaseModel): + octave: int = Field( + default=DEFAULT_OCTAVE, + ge=MIN_OCTAVE, + le=MAX_OCTAVE, + description="The octave a note key types into the pattern grid.", + ) diff --git a/src/sampletones_application/constants/tracker.py b/src/sampletones_application/constants/tracker.py new file mode 100644 index 000000000..e72527fa4 --- /dev/null +++ b/src/sampletones_application/constants/tracker.py @@ -0,0 +1,5 @@ +from typing import Final + +MIN_OCTAVE: Final[int] = 0 +MAX_OCTAVE: Final[int] = 7 +DEFAULT_OCTAVE: Final[int] = 4 diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 9ef1f2790..9bbf470fb 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -258,6 +258,7 @@ def __init__( self._sequencer_tracker_logic.settings, layout=layout.sequencer, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL), + initial_octave=session_manager.octave, language_manager=language_manager, key_router=key_router, tab_active=tab_active, @@ -371,6 +372,13 @@ def _wire_tracker_callbacks(self) -> None: detail=self._history_detail.note_off, coalesce=self._cell_key, ) + self._sequencer_tracker_panel.on_note_typed = self._undoable( + HistoryAction.EDIT_ROW, + self._sequencer_tracker_logic.write_note, + detail=self._history_detail.note_typed, + coalesce=self._note_key, + ) + self._sequencer_tracker_panel.on_octave_changed = self._session_manager.set_octave self._sequencer_tracker_panel.on_cell_selected = self._on_tracker_cell_focused self._sequencer_tracker_panel.on_play_from_row = self._on_tracker_play_from_row self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame @@ -795,6 +803,15 @@ def _cell_key( channel_key = channel if channel is not None else "" return (self._sequencer_tracker_logic.frame_index, channel_key, row_index) + def _note_key( + self, + row_index: int, + channel: ChannelName, + _pitch: int, + ) -> CoalesceKey: + """Identifies the cell a typed note landed in, so retyping one note coalesces onto it.""" + return self._cell_key(row_index, channel) + def _adjustment_key( self, region: TrackerRegion, diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index 07c908cd5..5ed92d673 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -19,5 +19,6 @@ class TrackerLayout(BaseModel, extra="forbid", frozen=True): row_height: int header_height: int subcolumn_widths: SubcolumnWidths + octave_width: int channel_column_tint: float muted_text_fraction: float diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 3a50d3a15..7680651cb 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -22,6 +22,7 @@ abbreviate_channel_names, ) from sampletones_core.utils.display import display_id, display_transpose, display_volume +from sampletones_core.utils.pitch_kind import channel_pitch_kind Segments = HistoryDetail @@ -115,6 +116,23 @@ def edit_row( return tuple(segments) + def note_typed( + self, + row_index: int, + channel: ChannelName, + pitch: int, + ) -> Segments: + """Names a typed note by the cell it landed in and the note the key stood for.""" + segments = list(self._location(row_index, channel, [channel])) + segments.append(self._subcolumn(SubColumn.TRANSPOSE)) + segments.append( + self._segment( + channel_pitch_kind(channel).to_name(pitch), + HistoryDetailRole.TRANSPOSE, + ), + ) + return tuple(segments) + def note_off( self, row_index: int, diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 3941870b4..70b8fbed1 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -18,7 +18,7 @@ from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.shape import Shape -from sampletones_core.project.voices.voice import VoiceUnion, voice_channels +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels, voice_reference from sampletones_core.utils.display import ( display_command, display_id, @@ -367,6 +367,56 @@ def set_sample_instrument( else: self.clear_row(channel, row_index) + def write_note( + self, + row_index: int, + channel: ChannelName, + pitch: int, + ) -> None: + """Writes the step that reaches ``pitch`` on the voice this channel is carrying. + + A note key names the note the reader wants to hear; the row states it as the step from the + voice's own reference, which is the one number a pitch cell holds. A row carrying no voice + has nothing to measure the note against, so the press leaves it as it stands. + + Args: + row_index: The row within the frame shown. + channel: The channel the note is typed into. + pitch: The note the key names, on the scale the channel reads. + """ + voice = self.carried_voice(channel, row_index) + if voice is None: + return + + self.write_cell( + row_index, + channel, + None, + pitch - voice_reference(voice, channel), + None, + ) + + def carried_voice( + self, + channel: ChannelName, + row_index: int, + ) -> Optional[VoiceUnion]: + """The voice a channel is carrying at a row of the frame shown. + + A row may bend a note it did not start, so the answer is found by reading down the frame's + rows to this one, the way the grid reads its pitch column. + """ + pattern_index = self._pattern_index_at_frame(channel) + pattern = self._controller.project.song.pattern(channel, pattern_index) if pattern_index is not None else None + if pattern is None: + return None + + carried: Optional[VoiceUnion] = None + for row in pattern.rows[: row_index + 1]: + carried = self._carried_voice(row, carried) + + return carried + def set_note_off(self, channel: ChannelName, row_index: int) -> None: """Writes a note-off into one channel's cell, materialising the pattern if needed.""" self.set_row(channel, row_index, command=NoteOff()) @@ -680,11 +730,11 @@ def _display_pitch( voice: Optional[VoiceUnion], ) -> str: match voice: - case Shape() as shape: + case Shape(): return display_note( transpose, channel_name=channel, - reference=shape.reference(channel), + reference=voice_reference(voice, channel), ) case _: return display_transpose(transpose) diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index ad40df512..0eeec703f 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -140,6 +140,12 @@ Widget.BUTTON, "pair", ) +TAG_SEQUENCER_TRACKER_INPUT_OCTAVE = TagName( + Page.SEQUENCER, + Panel.TRACKER, + Widget.INPUT, + "octave", +) TAG_SEQUENCER_VOICES_PANEL = TagName( Page.SEQUENCER, Panel.VOICES, diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index d47afe88f..73e6820ff 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -7,6 +7,7 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.tracker import DEFAULT_OCTAVE, MAX_OCTAVE, MIN_OCTAVE from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( @@ -17,6 +18,7 @@ from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_THEME_TABLE_PATTERN, TAG_SEQUENCER_TRACKER_GROUP, + TAG_SEQUENCER_TRACKER_INPUT_OCTAVE, TAG_SEQUENCER_TRACKER_PANEL, TAG_SEQUENCER_TRACKER_TABLE, TAG_SEQUENCER_TRACKER_WINDOW, @@ -80,6 +82,7 @@ ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS, SIGN_KEYS from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -108,9 +111,10 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import speaks_in_periods from sampletones_core.project.song_position import SongPosition from sampletones_core.utils.display import NOTE_OFF, display_id -from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP +from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES, SEMITONE_STEP from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback @@ -118,6 +122,7 @@ OnClearSubcolumnCallback = Callable[[int, Optional[ChannelName], SubColumn], None] OnSetRowCallback = Callable[[int, Optional[ChannelName], Optional[str], Optional[int], Optional[int]], None] OnSetNoteOffCallback = Callable[[int, Optional[ChannelName]], None] +OnNoteTypedCallback = Callable[[int, ChannelName, int], None] OnCellSelectedCallback = VoidCallback OnPlayFromRowCallback = Callable[[int], None] OnPlayFromFrameCallback = VoidCallback @@ -202,9 +207,11 @@ def __init__( key_router: KeyRouter, tab_active: ActivePredicate, shortcut_source: ShortcutSource, + initial_octave: int = DEFAULT_OCTAVE, initial_collapsed: bool = False, ) -> None: self._layout = layout + self._octave = initial_octave self._settings = initial_settings self._language_manager = language_manager self._router = key_router @@ -257,6 +264,8 @@ def __init__( self.on_clear_subcolumn: Optional[OnClearSubcolumnCallback] = None self.on_set_row: Optional[OnSetRowCallback] = None self.on_set_note_off: Optional[OnSetNoteOffCallback] = None + self.on_note_typed: Optional[OnNoteTypedCallback] = None + self.on_octave_changed: Optional[Callable[[int], None]] = None self.on_cell_selected: Optional[OnCellSelectedCallback] = None self.on_play_from_row: Optional[OnPlayFromRowCallback] = None self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None @@ -288,6 +297,16 @@ def __init__( ) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) + self._lbl_octave = self._label( + language_manager, + SequencerTrackerElements.OCTAVE, + ) + self._tip_octave = language_manager[ + Page.SEQUENCER, + Panel.TRACKER, + TextType.TOOLTIP, + SequencerTrackerElements.OCTAVE, + ] self._lbl_tracker = self._label( language_manager, SequencerTrackerElements.TRACKER_TEXT, @@ -454,6 +473,29 @@ def _create_header_themes(self) -> None: ) self._column_label_theme = create_label_selectable_theme(self._layout.colors.label) + def _create_octave_control(self) -> None: + """Offers the octave a note key types at, which is what turns one key row into a keyboard.""" + with dpg.group(horizontal=True): + label = dpg.add_text(self._lbl_octave) + FontRegistry.bind_to_item(label, Font.REGULAR_SMALL) + octave_input = dpg.add_input_int( + tag=TAG_SEQUENCER_TRACKER_INPUT_OCTAVE, + default_value=self._octave, + min_value=MIN_OCTAVE, + max_value=MAX_OCTAVE, + min_clamped=True, + max_clamped=True, + width=self._layout.tracker.octave_width, + step=1, + callback=self._on_octave_typed, + ) + FontRegistry.bind_to_item(octave_input, Font.MONO_SMALL) + show_tooltip(octave_input, self._tip_octave) + + def _on_octave_typed(self, _sender: Sender, app_data: int) -> None: + self._octave = max(MIN_OCTAVE, min(MAX_OCTAVE, app_data)) + self.call(self.on_octave_changed, self._octave) + def _create_tracker_view(self, parent: str) -> None: """Builds the tracker card and the empty table its rows are filled into. @@ -472,6 +514,7 @@ def _create_tracker_view(self, parent: str) -> None: self._lbl_tracker, glyph=self._glyphs.headers.tracker, ): + self._create_octave_control() dpg.add_group(tag=TAG_SEQUENCER_TRACKER_GROUP) with ( dpg.child_window( @@ -1872,6 +1915,9 @@ def _type_character(self, event: KeyEvent) -> bool: if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers: return False + if self._type_note(event): + return True + char = HEX_KEYS.get(event.key) or SIGN_KEYS.get(event.key) if char is None: return False @@ -1884,6 +1930,33 @@ def _type_character(self, event: KeyEvent) -> bool: self._apply_state(new_state) return True + def _type_note(self, event: KeyEvent) -> bool: + """Types the note a piano key names into the cell under the cursor. + + The keys reach the pitch column of a channel that names notes: the sample column speaks + for a whole sample, whose channels rest at pitches of their own, and the noise channel + selects one of sixteen periods, which its own hex entry already writes. + """ + cursor = self._input_state.cursor + if cursor is None or cursor.subcolumn is not SubColumn.TRANSPOSE or cursor.channel is None: + return False + + if speaks_in_periods(cursor.channel): + return False + + semitone = PIANO_KEYS.get(event.key) + if semitone is None: + return False + + self.call( + self.on_note_typed, + cursor.row, + cursor.channel, + (self._octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone, + ) + self._apply_state(self._input_state.reset_pending().navigate_row(1, self._current_row_count)) + return True + def _on_row_number_clicked( self, sender: Sender, diff --git a/src/sampletones_application/utils/gui/keyboard/piano.py b/src/sampletones_application/utils/gui/keyboard/piano.py new file mode 100644 index 000000000..8b5afc979 --- /dev/null +++ b/src/sampletones_application/utils/gui/keyboard/piano.py @@ -0,0 +1,46 @@ +from typing import Dict, Final, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_shared.constants.music import OCTAVE_SEMITONES + +_LOWER_ROW: Final[Tuple[int, ...]] = ( + dpg.mvKey_Z, + dpg.mvKey_S, + dpg.mvKey_X, + dpg.mvKey_D, + dpg.mvKey_C, + dpg.mvKey_V, + dpg.mvKey_G, + dpg.mvKey_B, + dpg.mvKey_H, + dpg.mvKey_N, + dpg.mvKey_J, + dpg.mvKey_M, +) + +_UPPER_ROW: Final[Tuple[int, ...]] = ( + dpg.mvKey_Q, + dpg.mvKey_2, + dpg.mvKey_W, + dpg.mvKey_3, + dpg.mvKey_E, + dpg.mvKey_R, + dpg.mvKey_5, + dpg.mvKey_T, + dpg.mvKey_6, + dpg.mvKey_Y, + dpg.mvKey_7, + dpg.mvKey_U, +) + +PIANO_KEYS: Final[Dict[int, int]] = { + **{key: semitone for semitone, key in enumerate(_LOWER_ROW)}, + **{key: OCTAVE_SEMITONES + semitone for semitone, key in enumerate(_UPPER_ROW)}, +} +"""Each note key, as the semitones it stands above the C of the octave being typed at. + +Two rows of the keyboard make two octaves of a piano, the way a tracker lays them out: the +bottom row opens at the octave in force and the top row an octave above it, with the black keys +on the row over each. +""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 3fd7b06fc..52c9cae62 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -526,6 +526,8 @@ sequencer.module.label.speed: "Speed" # Sequencer tab — Tracker # ============================================================================= sequencer.tracker.label.tracker_text: "Tracker" +sequencer.tracker.label.octave: "Octave" +sequencer.tracker.tooltip.octave: "The octave a note key types at" sequencer.tracker.label.column_row: "Row" sequencer.tracker.label.column_sample: "Sample" sequencer.tracker.label.column_pulse_1: "Pulse 1" diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index ffc48a358..611b4bff6 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -2,6 +2,7 @@ rows: 64 page_size: 16 row_height: 29 header_height: 30 +octave_width: 90 channel_column_tint: 0.09 muted_text_fraction: 0.45 subcolumn_widths: diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index 492d25378..7e691d1f4 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -8,7 +8,7 @@ from sampletones_core.instructions import InstructionUnion from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.shape import Shape -from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_core.project.voices.voice import VoiceUnion, voice_reference @dataclass(frozen=True) @@ -61,13 +61,10 @@ def read( """ match voice: case Sample(): - reconstruction = voice.reconstruction - instructions: Sequence[InstructionUnion] = reconstruction.instructions[channel_name] - reference = reconstruction.initial_pitches[channel_name] - held_features = reconstruction.held_features[channel_name] + instructions: Sequence[InstructionUnion] = voice.reconstruction.instructions[channel_name] + held_features = voice.reconstruction.held_features[channel_name] case Shape(): instructions = voice.instructions(channel_name) - reference = voice.reference(channel_name) held_features = voice.held_features(channel_name) if not instructions: @@ -76,7 +73,7 @@ def read( return cls( exporter=CHANNEL_TO_EXPORTER_MAP[channel_name], instructions=instructions, - reference=reference, + reference=voice_reference(voice, channel_name), held_features=held_features, loop_point=voice.loop_point, ) diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py index 058958c04..e6539f967 100644 --- a/src/sampletones_core/project/voices/__init__.py +++ b/src/sampletones_core/project/voices/__init__.py @@ -5,7 +5,7 @@ from .record import SampleRecord, VoiceRecord from .sample import Sample from .shape import Shape -from .voice import VoiceUnion, samples, voice_channels +from .voice import VoiceUnion, samples, voice_channels, voice_reference __all__ = [ "WHOLE_LOOP_POINT", @@ -19,4 +19,5 @@ "VoiceUnion", "samples", "voice_channels", + "voice_reference", ] diff --git a/src/sampletones_core/project/voices/voice.py b/src/sampletones_core/project/voices/voice.py index a0396a7eb..ec1c04e0a 100644 --- a/src/sampletones_core/project/voices/voice.py +++ b/src/sampletones_core/project/voices/voice.py @@ -39,3 +39,23 @@ def voice_channels(voice: VoiceUnion) -> Tuple[ChannelName, ...]: return voice.reconstruction.playing_channels case Shape(): return tuple(channel for channel in ChannelName.items() if voice.instructions(channel)) + + +def voice_reference(voice: VoiceUnion, channel_name: ChannelName) -> int: + """The value a voice's arpeggio is measured against on one channel. + + A sample carries the reference its conversion chose for that channel; a shape states the root + the reader gave it. A row's transpose is the step from this, whichever kind it names. + + Args: + voice: The voice being sounded. + channel_name: The channel sounding it. + + Returns: + int: The pitch, or the period, the voice rests at on that channel. + """ + match voice: + case Sample(): + return voice.reconstruction.initial_pitches[channel_name] + case Shape(): + return voice.reference(channel_name) diff --git a/src/sampletones_core/utils/frequencies.py b/src/sampletones_core/utils/frequencies.py index e0481de98..83be0b003 100644 --- a/src/sampletones_core/utils/frequencies.py +++ b/src/sampletones_core/utils/frequencies.py @@ -6,6 +6,7 @@ NOTE_NAMES, NUM_PERIODS, ) +from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.frequencies import validate_pitch @@ -95,8 +96,8 @@ def pitch_to_name(pitch: int, transpose: int = 0) -> str: pitch += transpose validate_pitch(pitch) - octave = (pitch // 12) - 2 - note_index = pitch % 12 + octave = pitch // OCTAVE_SEMITONES - OCTAVE_OFFSET + note_index = pitch % OCTAVE_SEMITONES return f"{NOTE_NAMES[note_index]}{octave}" diff --git a/src/sampletones_shared/constants/music.py b/src/sampletones_shared/constants/music.py index dbea837c3..13b227431 100644 --- a/src/sampletones_shared/constants/music.py +++ b/src/sampletones_shared/constants/music.py @@ -2,6 +2,7 @@ SEMITONE_STEP: Final[int] = 1 OCTAVE_SEMITONES: Final[int] = 12 +OCTAVE_OFFSET: Final[int] = 2 LIMIT_MIN_PITCH: Final[int] = 24 LIMIT_MAX_PITCH: Final[int] = 127 diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py new file mode 100644 index 000000000..be56ab8a9 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py @@ -0,0 +1,85 @@ +from typing import Final, Optional, Tuple + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.voice import voice_reference +from tests.suite.sequencer import sample_reconstruction + +ROOT_PITCH: Final[int] = 60 +TYPED_PITCH: Final[int] = 67 + + +def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]: + controller = ProjectController(ProjectManager()) + return controller, SequencerTrackerLogic(controller) + + +def _write( + controller: ProjectController, + channel: ChannelName, + row_index: int, + command: Optional[object], +) -> None: + pattern_index = controller.project.song.order[0][channel] + controller.set_row(channel, pattern_index, row_index, command=command) + + +def _transpose(logic: SequencerTrackerLogic, channel: ChannelName, row_index: int) -> Optional[int]: + row = logic.row(channel, row_index) + return row.transpose if row is not None else None + + +class TestATypedNoteIsStatedAsAStepFromTheVoice: + def test_a_shape_takes_the_step_that_reaches_the_note(self) -> None: + controller, logic = _logic() + shape = controller.add_shape("lead") + controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=8) + shape.envelopes = ShapeEnvelopes(volume=(15,)) + shape.invalidate() + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) + + logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH) + + assert _transpose(logic, ChannelName.PULSE1, 0) == TYPED_PITCH - ROOT_PITCH + + def test_a_sample_takes_the_step_from_its_own_pitch(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass") + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=sample.id)) + + logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH) + + expected = TYPED_PITCH - voice_reference(sample, ChannelName.PULSE1) + assert _transpose(logic, ChannelName.PULSE1, 0) == expected + + def test_a_row_below_the_note_is_measured_against_the_voice_it_carries(self) -> None: + controller, logic = _logic() + shape = controller.add_shape("lead") + controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=8) + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) + + logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH) + + assert _transpose(logic, ChannelName.PULSE1, 2) == TYPED_PITCH - ROOT_PITCH + + def test_a_row_carrying_no_voice_is_left_as_it_stands(self) -> None: + _, logic = _logic() + + logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH) + + assert _transpose(logic, ChannelName.PULSE1, 0) is None + + def test_a_row_past_a_note_off_carries_no_voice(self) -> None: + controller, logic = _logic() + shape = controller.add_shape("lead") + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) + _write(controller, ChannelName.PULSE1, 1, NoteOff()) + + logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH) + + assert _transpose(logic, ChannelName.PULSE1, 2) is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py new file mode 100644 index 000000000..386499ef6 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass, field +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.constants.tracker import DEFAULT_OCTAVE +from sampletones_application.ui.panels.sequencer.input.tracker import ( + TrackerCursor, + TrackerInputState, +) +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES + +ROW: Final[int] = 3 +ROW_COUNT: Final[int] = 64 + +Typed = Tuple[int, ChannelName, int] + + +@dataclass +class PianoFixture: + panel: GUISequencerTrackerPanel + typed: List[Typed] = field(default_factory=list) + states: List[TrackerInputState] = field(default_factory=list) + + +def _panel( + monkeypatch: pytest.MonkeyPatch, + channel: Optional[ChannelName], + subcolumn: SubColumn = SubColumn.TRANSPOSE, +) -> PianoFixture: + """A tracker panel carrying only the state the note path reads.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._octave = DEFAULT_OCTAVE + panel._current_row_count = ROW_COUNT + panel._input_state = TrackerInputState(cursor=TrackerCursor(ROW, channel, subcolumn)) + + fixture = PianoFixture(panel=panel) + panel.on_note_typed = lambda row, target, pitch: fixture.typed.append((row, target, pitch)) + monkeypatch.setattr(panel, "_apply_state", fixture.states.append) + return fixture + + +def _press(text: str) -> KeyEvent: + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +def _pitch(octave: int, semitone: int) -> int: + return (octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone + + +class TestANoteKeyWritesTheNoteItNames: + def test_the_bottom_row_opens_at_the_octave_in_force(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press("Z")) is True + assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE, 0))] + + def test_the_top_row_opens_an_octave_above_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + fixture.panel._type_note(_press("Q")) + + assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE + 1, 0))] + + def test_a_black_key_lands_a_semitone_above_the_white_one_below_it( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + fixture.panel._type_note(_press("S")) + + assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE, 1))] + + def test_the_octave_in_force_moves_the_note(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + fixture.panel._octave = DEFAULT_OCTAVE - 1 + + fixture.panel._type_note(_press("Z")) + + assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE - 1, 0))] + + def test_a_typed_note_steps_onto_the_next_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + fixture.panel._type_note(_press("Z")) + + assert fixture.states[-1].cursor is not None + assert fixture.states[-1].cursor.row == ROW + 1 + + +class TestWhereTheNoteKeysStayOut: + def test_the_sample_column_keeps_its_own_face(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The column speaks for a whole sample, whose channels rest at pitches of their own.""" + fixture = _panel(monkeypatch, None) + + assert fixture.panel._type_note(_press("Z")) is False + assert fixture.typed == [] + + def test_the_noise_channel_keeps_its_hex_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.NOISE) + + assert fixture.panel._type_note(_press("Z")) is False + assert fixture.typed == [] + + def test_another_subcolumn_keeps_its_own_keys(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1, SubColumn.VOLUME) + + assert fixture.panel._type_note(_press("Z")) is False + assert fixture.typed == [] + + def test_a_key_no_note_stands_on_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press("K")) is False + assert fixture.typed == [] From ef6881eef3a5542a5dd96586b446f0fdf022e17e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 06:54:49 +0200 Subject: [PATCH 079/142] Edited: a shape's envelopes on the Reconstructions tab --- src/sampletones_application/application.py | 29 ++-- .../constants/instruments.py | 5 + .../coordinators/tabs/reconstruction.py | 29 +++- .../logic/reconstruction/editing.py | 59 +++++++ .../logic/reconstruction/editor.py | 108 ++++++++++++ .../logic/reconstruction/instruments.py | 115 +++++++++++-- .../tags/reconstructions.py | 18 ++ .../reconstruction/instruments/instruments.py | 132 +++++++++++++-- .../view_model/reconstruction/instruments.py | 36 +++- src/sampletones_config/lang/en.yaml | 2 + .../logic/reconstruction/test_editor.py | 159 ++++++++++++++++++ .../logic/reconstruction/test_instruments.py | 118 ++++++++++++- .../sampletones_application/test_startup.py | 2 +- .../reconstruction/test_instruments_panel.py | 6 +- 14 files changed, 775 insertions(+), 43 deletions(-) create mode 100644 src/sampletones_application/constants/instruments.py create mode 100644 src/sampletones_application/logic/reconstruction/editing.py create mode 100644 src/sampletones_application/logic/reconstruction/editor.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/test_editor.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 7f41390fe..ca314c73b 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -158,6 +158,7 @@ from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.stage import ExportStage from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -418,6 +419,7 @@ def __init__( session_manager=self.session_manager, audio_device_manager=self.audio_device_manager, reconstruction_manager=self.reconstruction_manager, + project_controller=self.project_controller, browser_manager=self.browser_manager, export_service=self.export_service, export_backends=self.export_backends, @@ -489,7 +491,7 @@ def __init__( language_manager=self.language_manager, dialogs=self.dialogs, status_bar=self.status_bar, - on_edit_sample_requested=self._edit_project_sample, + on_edit_sample_requested=self._edit_project_voice, on_favorite_changed=self._repaint_reconstruction_favorites, on_sample_reconstruction_replaced=self._rebind_replaced_sample, on_tab_switch=self._set_current_tab, @@ -1024,16 +1026,23 @@ def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) - def _edit_project_sample(self, voice_id: str) -> None: - sample = self.project_manager.current.voice(voice_id) - if not isinstance(sample, Sample): - logger.warning(f"Cannot edit unknown project sample: {voice_id}") - return + def _edit_project_voice(self, voice_id: str) -> None: + """Opens the voice list's selection on the Reconstructions tab, in the terms of its kind. - self.reconstruction_manager.load_reconstruction_object( - sample.reconstruction, - name=sample.name, - ) + A sample opens as the reconstruction behind it, waveform and stems and all; a shape stands + on no recording, so the tab shows its envelopes alone. + """ + match self.project_manager.current.voice(voice_id): + case Sample() as sample: + self._reconstructions_tab.release_shape() + self.reconstruction_manager.load_reconstruction_object( + sample.reconstruction, + name=sample.name, + ) + case Shape(): + self._reconstructions_tab.edit_shape(voice_id) + case _: + logger.warning(f"Cannot edit unknown project voice: {voice_id}") def _rebind_replaced_sample( self, diff --git a/src/sampletones_application/constants/instruments.py b/src/sampletones_application/constants/instruments.py new file mode 100644 index 000000000..4b9e3b7f6 --- /dev/null +++ b/src/sampletones_application/constants/instruments.py @@ -0,0 +1,5 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +SHAPE_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 5679bc9fd..e4151d099 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -16,9 +16,11 @@ from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol +from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.edit import StemRemoval +from sampletones_application.logic.reconstruction.editor import InstrumentEditor from sampletones_application.logic.reconstruction.instruments import ( OnReconstructionInstrumentUpdatedCallback, ReconstructionInstrumentsLogic, @@ -117,6 +119,7 @@ def __init__( session_manager: SessionManager, audio_device_manager: AudioDeviceManager, reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, browser_manager: BrowserManager, export_service: ExportService, export_backends: Dict[ExportFormat, ExportBackend], @@ -134,6 +137,10 @@ def __init__( ) -> None: self._language_manager = language_manager self._reconstruction_manager = reconstruction_manager + self._instrument_editor: InstrumentEditor = InstrumentEditor( + reconstruction_manager, + project_controller, + ) self._session_manager = session_manager self._export_backends = export_backends self._dialogs = dialogs @@ -233,7 +240,7 @@ def __init__( ) self._reconstruction_instruments_panel.set_collapse_handler(self._on_instruments_collapse_changed) self._reconstruction_instruments_logic: ReconstructionInstrumentsLogic = ReconstructionInstrumentsLogic( - reconstruction_manager, + self._instrument_editor, scheduling=layout.scheduling, ) @@ -292,6 +299,12 @@ def __init__( self._reconstruction_instruments_panel.on_raw_data_changed = ( self._reconstruction_instruments_logic.handle_raw_data_changed ) + self._reconstruction_instruments_panel.on_shape_root_period_changed = ( + self._reconstruction_instruments_logic.handle_shape_root_period_changed + ) + self._reconstruction_instruments_panel.on_shape_loop_point_changed = ( + self._reconstruction_instruments_logic.handle_shape_loop_point_changed + ) def _on_export_result(self, result: ExportResult) -> None: """Reports a finished export in the words of the artefact it produced. @@ -626,9 +639,23 @@ def repaint_browser_favorites( self._browser_panel.update_favorite_indicators(nodes) def display_reconstruction(self) -> None: + self._instrument_editor.release_shape() self._reconstruction_panel_logic.display_reconstruction() self._reconstruction_instruments_logic.update_display() + def edit_shape(self, voice_id: str) -> None: + """Puts a shape in front of the tab, closing whatever reconstruction it held. + + The tab describes one voice at a time — a shape stands on no recording, so the waveform, + the plot and the stems beside the instruments panel have nothing of it to draw. + """ + self._instrument_editor.edit_shape(voice_id) + self._reconstruction_instruments_logic.update_display() + + def release_shape(self) -> None: + """Lets go of the shape the tab held, which is what opening a reconstruction does.""" + self._instrument_editor.release_shape() + def close_reconstruction(self) -> None: self._reconstruction_panel_logic.close_reconstruction() self._reconstruction_instruments_logic.update_display() diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py new file mode 100644 index 000000000..74433372a --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Protocol, Union + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features +from sampletones_core.types.feature import FeatureValue + + +@dataclass(frozen=True) +class ReconstructionEdit: + """The channels of a loaded reconstruction, each with the envelopes it carries.""" + + channels: Dict[ChannelName, Features] + + +@dataclass(frozen=True) +class ShapeEdit: + """The one envelope set a shape carries, with the roots and the loop point it states. + + Attributes: + voice_id: The shape an edit is written back into. + name: The name the panel titles it by. + features: The envelopes, read as the channel offering every dimension a shape writes. + root_pitch: The note the tonal channels measure the arpeggio against. + root_period: The period the noise channel measures the arpeggio against. + loop_point: The tick the envelopes repeat from, or ``None`` where they play once. + """ + + voice_id: str + name: str + features: Features + root_pitch: int + root_period: int + loop_point: Optional[int] + + +EditedInstrument = Union[ReconstructionEdit, ShapeEdit] + + +class InstrumentEditingProtocol(Protocol): + """Where the instruments panel's envelopes come from, and where an edit to a shape goes. + + The panel edits one voice at a time — the channels of a loaded reconstruction, or a shape's + own set — so it asks what is in front of it and renders whichever answer comes back. A + reconstruction's envelopes travel back out through the regeneration service; a shape stands on + no audio, so its edits are written here. + """ + + def edited_instrument(self) -> Optional[EditedInstrument]: + """What the panel is editing, or ``None`` while it holds nothing.""" + + def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: + """Writes one dimension of the shape in front of the panel.""" + + def write_roots(self, *, pitch: int, period: int) -> None: + """Moves the roots the shape in front of the panel is measured against.""" + + def write_loop_point(self, loop_point: Optional[int]) -> None: + """Sets the tick the shape in front of the panel repeats from.""" diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py new file mode 100644 index 000000000..43d1fbad3 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -0,0 +1,108 @@ +from typing import Optional, Tuple + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.reconstruction.editing import ( + EditedInstrument, + ReconstructionEdit, + ShapeEdit, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.project.voices.shape import Shape +from sampletones_core.types.feature import FeatureValue + + +class InstrumentEditor: + """Which voice the Reconstructions tab has in front of it, and where an edit to it goes. + + The tab shows one voice at a time: a reconstruction, whose waveform and stems the rest of the + tab draws, or a shape, which has none of those and is its envelopes alone. Opening one puts + the other away, so the cards beside the instruments panel always describe what it is editing. + """ + + def __init__( + self, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + ) -> None: + self._reconstruction_manager = reconstruction_manager + self._controller = project_controller + self._voice_id: Optional[str] = None + + def edit_shape(self, voice_id: str) -> None: + """Puts a shape in front of the tab, closing whatever reconstruction it held.""" + self._voice_id = voice_id + self._reconstruction_manager.close_reconstruction() + + def release_shape(self) -> None: + """Lets go of the shape, which is what opening a reconstruction does.""" + self._voice_id = None + + @property + def shape(self) -> Optional[Shape]: + """The shape in front of the tab, or ``None`` where it holds a reconstruction or nothing.""" + if self._voice_id is None: + return None + + voice = self._controller.project.voices.get(self._voice_id) + return voice if isinstance(voice, Shape) else None + + def edited_instrument(self) -> Optional[EditedInstrument]: + """What the instruments panel is editing, or ``None`` while it holds nothing.""" + shape = self.shape + if shape is not None: + return ShapeEdit( + voice_id=shape.id, + name=shape.name, + features=shape.instrument_features(), + root_pitch=shape.root_pitch, + root_period=shape.root_period, + loop_point=shape.loop_point, + ) + + feature_data = self._reconstruction_manager.current_features + return None if feature_data is None else ReconstructionEdit(channels=feature_data.channels) + + def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: + """Writes one dimension of the shape in front of the tab. + + Raises: + TypeError: If the tab holds no shape to write into. + """ + shape = self.shape + if shape is None: + raise TypeError("The tab holds no shape to write an envelope into") + + self._controller.set_shape_envelope(shape.id, feature_key, _items(data)) + + def write_roots(self, *, pitch: int, period: int) -> None: + """Moves the roots the shape in front of the tab is measured against. + + Raises: + TypeError: If the tab holds no shape to write into. + """ + shape = self.shape + if shape is None: + raise TypeError("The tab holds no shape to move the roots of") + + self._controller.set_shape_root(shape.id, pitch=pitch, period=period) + + def write_loop_point(self, loop_point: Optional[int]) -> None: + """Sets the tick the shape in front of the tab repeats from. + + Raises: + TypeError: If the tab holds no shape to write into. + """ + shape = self.shape + if shape is None: + raise TypeError("The tab holds no shape to set a loop point on") + + self._controller.set_voice_loop_point(shape.id, loop_point) + + +def _items(data: FeatureValue) -> Tuple[int, ...]: + """The items an envelope edit carries, as the plain tuple a shape stores.""" + if isinstance(data, int): + return (data,) + + return tuple(int(value) for value in data) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 398cd3bfa..0b1b48015 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -2,13 +2,19 @@ import numpy as np +from sampletones_application.constants.instruments import SHAPE_CHANNEL from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.editing import ( + InstrumentEditingProtocol, + ReconstructionEdit, + ShapeEdit, +) from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, + ShapeInstrumentViewModel, ) from sampletones_application.view_model.reconstruction.update import ( ReconstructionUpdate, @@ -29,11 +35,11 @@ class ReconstructionInstrumentsLogic(CallbackMixin): def __init__( self, - reconstruction_manager: ReconstructionManager, + editor: InstrumentEditingProtocol, *, scheduling: SchedulingBehavior, ) -> None: - self.reconstruction_manager = reconstruction_manager + self._editor = editor self._scheduling = scheduling self._pending_reconstruction_update: Optional[ReconstructionUpdate] = None @@ -43,9 +49,21 @@ def __init__( self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None def update_display(self) -> None: - channels = self._current_generators() - self.call(self.on_view_changed, self._build_view_model(channels)) - self.call(self.on_feature_data_changed, channels) + """Renders whatever the panel has in front of it, envelopes and figures together.""" + self.call(self.on_view_changed, self._build_view_model(self._current_generators())) + self.call(self.on_feature_data_changed, self._displayed_features()) + + def _displayed_features(self) -> Optional[Dict[ChannelName, Features]]: + """The envelopes the panel draws: a reconstruction's channels, or a shape's own set. + + A shape is drawn on the tab the panel shows it under, which is the channel offering every + dimension a shape writes. + """ + shape = self.shape_edit + if shape is not None: + return {SHAPE_CHANNEL: shape.features} + + return self._current_generators() def refresh_view(self) -> None: """Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are. @@ -57,13 +75,42 @@ def refresh_view(self) -> None: self.call(self.on_view_changed, self._build_view_model(self._current_generators())) def _current_generators(self) -> Optional[Dict[ChannelName, Features]]: - feature_data = self.reconstruction_manager.current_features - return None if feature_data is None else feature_data.channels + """The channels of the reconstruction in front of the panel, where one is.""" + match self._editor.edited_instrument(): + case ReconstructionEdit() as edit: + return edit.channels + case _: + return None + + @property + def shape_edit(self) -> Optional[ShapeEdit]: + """The shape in front of the panel, where one is.""" + match self._editor.edited_instrument(): + case ShapeEdit() as edit: + return edit + case _: + return None def _build_view_model( self, channels: Optional[Dict[ChannelName, Features]], ) -> ReconstructionInstrumentsViewModel: + shape = self.shape_edit + if shape is not None: + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + playing_channels=frozenset({SHAPE_CHANNEL}), + footprint=SampleFootprintViewModel.from_instrument( + features_footprint(shape.features, loop_point=shape.loop_point) + ), + shape=ShapeInstrumentViewModel( + name=shape.name, + root_pitch=shape.root_pitch, + root_period=shape.root_period, + loop_point=shape.loop_point, + ), + ) + if channels is None: return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, @@ -104,6 +151,12 @@ def handle_pitch_value_changed( channel_name: ChannelName, value: int, ) -> None: + shape = self.shape_edit + if shape is not None: + self._editor.write_roots(pitch=value, period=shape.root_period) + self.update_display() + return + self._schedule_reconstruction_update( ReconstructionUpdate( channel_name, @@ -118,6 +171,9 @@ def handle_bar_point_clicked( feature_key: FeatureKey, data: np.ndarray, ) -> None: + if self._write_shape_envelope(feature_key, data): + return + self._report_edited_size(channel_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( @@ -133,6 +189,9 @@ def handle_raw_data_changed( feature_key: FeatureKey, data: np.ndarray, ) -> None: + if self._write_shape_envelope(feature_key, data): + return + self._report_edited_size(channel_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( @@ -142,6 +201,40 @@ def handle_raw_data_changed( ) ) + def handle_shape_root_period_changed(self, value: int) -> None: + """Moves the period the shape in front of the panel rests at on the noise channel.""" + shape = self.shape_edit + if shape is None: + return + + self._editor.write_roots(pitch=shape.root_pitch, period=value) + self.update_display() + + def handle_shape_loop_point_changed(self, loop_point: Optional[int]) -> None: + """Sets the tick the shape in front of the panel repeats from.""" + if self.shape_edit is None: + return + + self._editor.write_loop_point(loop_point) + self.update_display() + + def _write_shape_envelope( + self, + feature_key: FeatureKey, + data: np.ndarray, + ) -> bool: + """Writes one dimension of the shape in front of the panel, reporting whether it did. + + A shape stands on no audio, so an edit reaches it at once rather than through the + regeneration a reconstruction's envelopes go back through. + """ + if self.shape_edit is None: + return False + + self._editor.write_envelope(feature_key, data) + self.update_display() + return True + def _report_edited_size( self, channel_name: ChannelName, @@ -215,7 +308,7 @@ def _on_reconstruction_update_scheduled(self) -> None: ) def _get_features(self, channel_name: ChannelName) -> Features: - current_features = self.reconstruction_manager.current_features - assert current_features is not None, "Current features should not be None" + channels = self._current_generators() + assert channels is not None, "A channel edit arrives only while a reconstruction is open" - return current_features[channel_name] + return channels[channel_name] diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index d654aed38..ee0d297f8 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -158,6 +158,24 @@ Widget.BUTTON, "export_instrument", ) +TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_SHAPE = TagName( + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + Widget.GROUP, + "shape", +) +TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS = TagName( + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + Widget.CHECKBOX, + "loops", +) +TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT = TagName( + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + Widget.INPUT, + "loop_point", +) TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE = TagName( Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index a2288f673..281dc0dd1 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -9,6 +9,7 @@ from sampletones_application.categories.hierarchy import TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.categories.pitch import PitchTooltips +from sampletones_application.constants.instruments import SHAPE_CHANNEL from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag @@ -34,6 +35,9 @@ SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, + TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS, + TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_SHAPE, + TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE, @@ -66,6 +70,7 @@ from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, + ShapeInstrumentViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ( @@ -74,7 +79,12 @@ GeneratorName, ) from sampletones_core.exporters import Features -from sampletones_core.features import CHANNEL_GENERATOR_KIND, resting_reference, supported_features +from sampletones_core.features import ( + CHANNEL_GENERATOR_KIND, + RESTING_REFERENCE_PERIOD, + resting_reference, + supported_features, +) from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) @@ -109,6 +119,7 @@ def __init__( self.channel_plots: Dict[ChannelName, Dict[FeatureKey, GUIBarGraph]] = {} self._pitch_steppers: Dict[ChannelName, GUIPitchStepper] = {} + self._shape_root_period: Optional[GUIPitchStepper] = None self._export_buttons: Dict[ChannelName, GUIButton] = {} self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR @@ -116,6 +127,9 @@ def __init__( self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY) self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) + self.shape_group_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_SHAPE + self.shape_loops_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS + self.shape_loop_point_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT self._graphs: Dict[str, GUIBarGraph] = {} self._sequence_lengths: Dict[Tuple[ChannelName, FeatureKey], int] = {} @@ -137,6 +151,8 @@ def __init__( self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None self.on_bar_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None self.on_raw_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None + self.on_shape_root_period_changed: Optional[Callable[[int], None]] = None + self.on_shape_loop_point_changed: Optional[Callable[[Optional[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) @@ -347,8 +363,74 @@ def _create_generator_content( window_tag, ) self._create_pitch_stepper(channel_name, initial_pitch, window_tag) + if channel_name is SHAPE_CHANNEL: + self._create_shape_fields(window_tag) + self._create_generator_feature_displays(channel_name, window_tag) + def _create_shape_fields(self, window_tag: str) -> None: + """Draws what a shape states beyond its envelopes: its noise root and its loop point. + + A shape sounds on every channel, so it states a root for the tonal channels — the stepper + above these — and one for the noise channel's periods. The loop point is the tick its + envelopes repeat from while a note is held. + """ + with dpg.group(tag=self.shape_group_tag, parent=window_tag, show=False): + self._shape_root_period = GUIPitchStepper( + tag=self.shape_group_tag, + parent=self.shape_group_tag, + kind=PERIOD_VALUE_KIND, + initial_value=RESTING_REFERENCE_PERIOD, + label=self._language_manager["reconstructions.instruments.label.root_period"], + tooltip=self._pitch_tooltips.for_kind(PERIOD_VALUE_KIND), + status_message=self._language_manager["reconstructions.instruments.message.status_input_period"], + status_bar=self._status_bar, + layout=self._pitch_stepper_style.dimensions, + plus_minus_layout=self._pitch_stepper_style.plus_minus, + value_color=self._pitch_stepper_style.value_color, + ) + self._shape_root_period.on_value_changed = self._on_shape_root_period_changed + + with labeled_field( + self._language_manager["reconstructions.instruments.label.loop_point"], + self._pitch_stepper_style.dimensions.label_width, + parent=self.shape_group_tag, + ): + dpg.add_checkbox( + tag=self.shape_loops_tag, + default_value=False, + callback=self._on_shape_loops_toggled, + ) + dpg.add_input_int( + tag=self.shape_loop_point_tag, + default_value=0, + min_value=0, + min_clamped=True, + width=self._pitch_stepper_style.dimensions.value_width, + step=1, + callback=self._on_shape_loop_point_typed, + ) + + def _on_shape_root_period_changed(self, value: int) -> None: + self.call(self.on_shape_root_period_changed, value) + + def _on_shape_loops_toggled(self, _sender: Sender, app_data: bool) -> None: + point = dpg.get_value(self.shape_loop_point_tag) if app_data else None + self.call(self.on_shape_loop_point_changed, point) + + def _on_shape_loop_point_typed(self, _sender: Sender, app_data: int) -> None: + if dpg.get_value(self.shape_loops_tag): + self.call(self.on_shape_loop_point_changed, max(0, app_data)) + + def _apply_shape_fields(self, shape: ShapeInstrumentViewModel) -> None: + """Writes what a shape states into the fields that show it.""" + if self._shape_root_period is not None: + self._shape_root_period.set_value(shape.root_period) + + dpg_set_value(self.shape_loops_tag, shape.loops) + dpg_set_value(self.shape_loop_point_tag, shape.loop_point if shape.loop_point is not None else 0) + dpg_configure_item(self.shape_loop_point_tag, enabled=shape.loops) + def _default_initial_pitch(self, channel_name: ChannelName) -> int: return resting_reference(channel_name) @@ -439,26 +521,43 @@ def update_view( self, view_model: ReconstructionInstrumentsViewModel, ) -> None: - """Shows a tab per channel, marking the ones standing by. + """Shows what the panel has in front of it, marking the channels standing by. - Every channel is editable for as long as a reconstruction is open, so writing an - envelope into a channel standing by is what puts it in play. A muted tab label and a - withheld export say which channels are there. + A reconstruction shows a tab per channel, and every channel is editable for as long as it + is open, so writing an envelope into a channel standing by is what puts it in play; a + muted tab label and a withheld export say which channels are there. A shape is one + instrument every channel reads, so it shows a single tab under its own name, carrying the + roots and the loop point it states. """ - is_loaded = view_model.reconstruction_loaded - dpg_configure_item(self.no_data_message_tag, show=not is_loaded) - dpg_configure_item(self.tab_bar_tag, show=is_loaded) - dpg_configure_item(self.sample_size_group_tag, show=is_loaded) - self._update_sizes(view_model.footprint) + shape = view_model.shape + is_open = view_model.is_open + dpg_configure_item(self.no_data_message_tag, show=not is_open) + dpg_configure_item(self.tab_bar_tag, show=is_open) + dpg_configure_item(self.sample_size_group_tag, show=is_open) + dpg_configure_item(self.shape_group_tag, show=shape is not None) + self._update_sizes(view_model.footprint, shows_one_instrument=shape is not None) for channel_name in ChannelName.items(): tab_tag = self._get_generator_tab_tag(channel_name) - dpg_configure_item(tab_tag, show=is_loaded) + shown = channel_name is SHAPE_CHANNEL if shape is not None else view_model.reconstruction_loaded + dpg_configure_item(tab_tag, show=shown) + if shape is not None and channel_name is SHAPE_CHANNEL: + dpg_configure_item(tab_tag, label=shape.name) + else: + dpg_configure_item(tab_tag, label=self._channel_labels[channel_name]) + self._apply_playing_state( channel_name, channel_name in view_model.playing_channels, ) + export_button = self._export_buttons.get(SHAPE_CHANNEL) + if export_button is not None and shape is not None: + export_button.set_enabled(False) + + if shape is not None: + self._apply_shape_fields(shape) + def _apply_playing_state( self, channel_name: ChannelName, @@ -479,10 +578,14 @@ def _apply_playing_state( def _update_sizes( self, footprint: Optional[SampleFootprintViewModel], + *, + shows_one_instrument: bool, ) -> None: - """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's. + """Writes the byte figures the voice in front of the panel occupies. - A channel standing by is written by no export, so it reads as the nothing it costs. + A reconstruction states its own total and a figure per channel, and a channel standing by + is written by no export, so it reads as the nothing it costs. A shape is one instrument + every channel reaches, so the tab it is shown under carries the whole figure. """ if footprint is None: return @@ -490,6 +593,9 @@ def _update_sizes( dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes)) for channel_name in ChannelName.items(): instrument_bytes = footprint.bytes_for(channel_name) + if shows_one_instrument and channel_name is SHAPE_CHANNEL: + instrument_bytes = footprint.total_bytes + dpg_set_value( self._get_instrument_size_tag(channel_name), self._format_size(instrument_bytes if instrument_bytes is not None else 0), diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 200d972ff..b594aef4f 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -6,14 +6,44 @@ from sampletones_core.constants.enums import ChannelName +class ShapeInstrumentViewModel(BaseModel, frozen=True): + """What the instruments panel shows of a shape: its name and the values it states. + + A shape is its envelopes and the roots they are measured against, so the panel renders one + instrument rather than a tab per channel. + """ + + name: str + root_pitch: int + root_period: int + loop_point: Optional[int] + + @property + def loops(self) -> bool: + """Whether the shape repeats its envelopes rather than playing them once.""" + return self.loop_point is not None + + class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): - """What the instruments panel renders: every channel, and which of them play. + """What the instruments panel renders: the voice in front of it, and how it is read. A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays - editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the - panel reads to mark the standing-by tabs and to offer their export. + editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel + reads to mark the standing-by tabs and to offer their export. A shape holds one instrument + every channel reads, so :attr:`shape` is what the panel renders instead. """ reconstruction_loaded: bool playing_channels: FrozenSet[ChannelName] footprint: Optional[SampleFootprintViewModel] + shape: Optional[ShapeInstrumentViewModel] = None + + @property + def edits_a_shape(self) -> bool: + """Whether the panel is showing a shape rather than a reconstruction's channels.""" + return self.shape is not None + + @property + def is_open(self) -> bool: + """Whether the panel has a voice in front of it at all.""" + return self.reconstruction_loaded or self.edits_a_shape diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 52c9cae62..05bc49874 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -483,6 +483,8 @@ reconstructions.instruments.label.volume_label: "Volume" reconstructions.instruments.label.arpeggio_label: "Arpeggio" reconstructions.instruments.label.duty_cycle_label: "Duty cycle" reconstructions.instruments.label.initial_period: "Initial period:" +reconstructions.instruments.label.root_period: "Root period" +reconstructions.instruments.label.loop_point: "Loop point" reconstructions.instruments.label.initial_pitch: "Initial pitch: " reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type value. Enter note name (e.g. \"C-4\") or MIDI value (72)." reconstructions.instruments.message.status_input_period: "Ctrl + click to type value. Enter period name (e.g. \"4-#\") or integer value (4)." diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py new file mode 100644 index 000000000..bf4b4ac6d --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -0,0 +1,159 @@ +from typing import Final, Tuple +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.editing import ReconstructionEdit, ShapeEdit +from sampletones_application.logic.reconstruction.editor import InstrumentEditor +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT + +ROOT_PITCH: Final[int] = 55 +ROOT_PERIOD: Final[int] = 3 +VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) + + +def _features() -> Features: + return Features( + initial_pitch=ROOT_PITCH, + volume=np.array([15], dtype=np.int8), + arpeggio=np.array([0], dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=None, + ) + + +@pytest.fixture +def controller() -> ProjectController: + return ProjectController(ProjectManager()) + + +@pytest.fixture +def reconstruction_manager() -> MagicMock: + manager = MagicMock(spec=ReconstructionManager) + manager.current_features = None + return manager + + +@pytest.fixture +def editor(reconstruction_manager: MagicMock, controller: ProjectController) -> InstrumentEditor: + return InstrumentEditor(reconstruction_manager, controller) + + +class TestWhatTheTabHasInFront: + def test_it_holds_nothing_to_begin_with(self, editor: InstrumentEditor) -> None: + assert editor.edited_instrument() is None + + def test_a_loaded_reconstruction_answers_with_its_channels( + self, + editor: InstrumentEditor, + reconstruction_manager: MagicMock, + ) -> None: + reconstruction_manager.current_features = MagicMock(channels={ChannelName.PULSE1: _features()}) + + edit = editor.edited_instrument() + + assert isinstance(edit, ReconstructionEdit) + assert list(edit.channels) == [ChannelName.PULSE1] + + def test_a_shape_answers_with_what_it_states( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + shape = controller.add_shape("lead") + controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) + + editor.edit_shape(shape.id) + + edit = editor.edited_instrument() + assert isinstance(edit, ShapeEdit) + assert (edit.voice_id, edit.name) == (shape.id, "lead") + assert (edit.root_pitch, edit.root_period) == (ROOT_PITCH, ROOT_PERIOD) + + def test_opening_a_shape_closes_the_reconstruction_the_tab_held( + self, + editor: InstrumentEditor, + controller: ProjectController, + reconstruction_manager: MagicMock, + ) -> None: + """The tab describes one voice, so its waveform and stems follow what is in front of it.""" + shape = controller.add_shape("lead") + + editor.edit_shape(shape.id) + + reconstruction_manager.close_reconstruction.assert_called_once_with() + + def test_letting_go_of_a_shape_hands_the_tab_back( + self, + editor: InstrumentEditor, + controller: ProjectController, + reconstruction_manager: MagicMock, + ) -> None: + shape = controller.add_shape("lead") + editor.edit_shape(shape.id) + reconstruction_manager.current_features = MagicMock(channels={ChannelName.PULSE1: _features()}) + + editor.release_shape() + + assert isinstance(editor.edited_instrument(), ReconstructionEdit) + + def test_a_shape_removed_from_the_project_leaves_the_tab_holding_nothing( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + shape = controller.add_shape("lead") + editor.edit_shape(shape.id) + + controller.remove_voice(shape.id) + + assert editor.edited_instrument() is None + + +class TestWritingIntoTheShape: + def test_an_envelope_reaches_the_shape( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + shape = controller.add_shape("lead") + editor.edit_shape(shape.id) + + editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) + + assert shape.envelopes.volume == VOLUME + + def test_the_roots_reach_the_shape( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + shape = controller.add_shape("lead") + editor.edit_shape(shape.id) + + editor.write_roots(pitch=ROOT_PITCH, period=ROOT_PERIOD) + + assert (shape.root_pitch, shape.root_period) == (ROOT_PITCH, ROOT_PERIOD) + + def test_the_loop_point_reaches_the_shape( + self, + editor: InstrumentEditor, + controller: ProjectController, + ) -> None: + shape = controller.add_shape("lead") + editor.edit_shape(shape.id) + + editor.write_loop_point(WHOLE_LOOP_POINT) + + assert shape.loop_point == WHOLE_LOOP_POINT + + def test_a_write_with_no_shape_in_front_is_refused(self, editor: InstrumentEditor) -> None: + with pytest.raises(TypeError): + editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index c267a2847..4490ce0ac 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -4,7 +4,11 @@ import numpy as np import pytest +from sampletones_application.constants.instruments import SHAPE_CHANNEL from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.editor import InstrumentEditor from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_application.logic.reconstruction.instruments import ( ReconstructionInstrumentsLogic, @@ -19,6 +23,7 @@ features_footprint, total_footprint, ) +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction @@ -27,13 +32,19 @@ def mock_reconstruction_manager() -> MagicMock: return MagicMock(spec=ReconstructionManager) +@pytest.fixture +def instrument_editor(mock_reconstruction_manager: MagicMock) -> InstrumentEditor: + """The real source the panel reads, over a stand-in for the document it opens.""" + return InstrumentEditor(mock_reconstruction_manager, ProjectController(ProjectManager())) + + @pytest.fixture def instruments_logic( - mock_reconstruction_manager: MagicMock, + instrument_editor: InstrumentEditor, scheduling: SchedulingBehavior, ) -> ReconstructionInstrumentsLogic: return ReconstructionInstrumentsLogic( - mock_reconstruction_manager, + instrument_editor, scheduling=scheduling, ) @@ -302,3 +313,106 @@ def test_no_pending_update_is_a_no_op( instruments_logic._pending_reconstruction_update = None instruments_logic._on_reconstruction_update_scheduled() callback.assert_not_called() + + +class TestTheInstrumentsPanelShowsAShape: + """A shape stands on no audio, so the panel shows one instrument and writes edits at once.""" + + @pytest.fixture + def project_controller(self) -> ProjectController: + return ProjectController(ProjectManager()) + + @pytest.fixture + def shape_logic( + self, + mock_reconstruction_manager: MagicMock, + project_controller: ProjectController, + scheduling: SchedulingBehavior, + ) -> ReconstructionInstrumentsLogic: + mock_reconstruction_manager.current_features = None + editor = InstrumentEditor(mock_reconstruction_manager, project_controller) + shape = project_controller.add_shape("lead") + project_controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12)) + editor.edit_shape(shape.id) + return ReconstructionInstrumentsLogic(editor, scheduling=scheduling) + + def test_the_view_names_the_shape_it_shows( + self, + shape_logic: ReconstructionInstrumentsLogic, + ) -> None: + received: List[ReconstructionInstrumentsViewModel] = [] + shape_logic.on_view_changed = received.append + + shape_logic.update_display() + + assert received[-1].edits_a_shape is True + assert received[-1].shape is not None + assert received[-1].shape.name == "lead" + + def test_the_envelopes_are_drawn_under_the_channel_that_reads_them_all( + self, + shape_logic: ReconstructionInstrumentsLogic, + ) -> None: + received: List[Optional[Dict[ChannelName, Features]]] = [] + shape_logic.on_feature_data_changed = received.append + + shape_logic.update_display() + + assert received[-1] is not None + assert list(received[-1]) == [SHAPE_CHANNEL] + + def test_an_envelope_edit_reaches_the_shape_without_a_regeneration( + self, + shape_logic: ReconstructionInstrumentsLogic, + project_controller: ProjectController, + ) -> None: + regenerated: List[object] = [] + shape_logic.on_reconstruction_instrument_updated = lambda *args: regenerated.append(args) + + shape_logic.handle_raw_data_changed( + SHAPE_CHANNEL, + FeatureKey.ARPEGGIO, + np.array([0, 7], dtype=np.int8), + ) + + shape = project_controller.project.voices[project_controller.project.voices[0].id] + assert shape.envelopes.arpeggio == (0, 7) + assert regenerated == [] + + def test_the_pitch_stepper_moves_the_shapes_tonal_root( + self, + shape_logic: ReconstructionInstrumentsLogic, + project_controller: ProjectController, + ) -> None: + shape_logic.handle_pitch_value_changed(SHAPE_CHANNEL, 48) + + assert project_controller.project.voices[0].root_pitch == 48 + + def test_the_loop_point_reaches_the_shape( + self, + shape_logic: ReconstructionInstrumentsLogic, + project_controller: ProjectController, + ) -> None: + shape_logic.handle_shape_loop_point_changed(WHOLE_LOOP_POINT) + + assert project_controller.project.voices[0].loop_point == WHOLE_LOOP_POINT + + def test_the_figure_measures_the_one_instrument_it_exports( + self, + shape_logic: ReconstructionInstrumentsLogic, + project_controller: ProjectController, + ) -> None: + received: List[ReconstructionInstrumentsViewModel] = [] + shape_logic.on_view_changed = received.append + + shape_logic.update_display() + + shape = project_controller.project.voices[0] + assert received[-1].footprint is not None + assert ( + received[-1].footprint.total_bytes + == features_footprint( + shape.instrument_features(), + loop_point=shape.loop_point, + ).total_bytes + ) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 7a7a701f2..fd7ab04c5 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -250,7 +250,7 @@ def _embed_sample( reconstruction = reconstruction_factory() with app.history.transaction(HistoryAction.ADD_SAMPLE): sample = app.project_controller.add_sample(reconstruction, "Lead") - app._edit_project_sample(sample.id) + app._edit_project_voice(sample.id) return sample def test_embedded_reconstruction_is_owned_and_not_saveable( diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index 04b83ec9a..a8fe14937 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -110,8 +110,10 @@ def shown(monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]: """Records which items the panel shows, standing in for the DPG configuration.""" flags: Dict[str, bool] = {} - def configure(tag: str, *, show: bool) -> None: - flags[tag] = show + def configure(tag: str, **kwargs: object) -> None: + show = kwargs.get("show") + if isinstance(show, bool): + flags[tag] = show monkeypatch.setattr(instruments_module, "dpg_configure_item", configure) return flags From d9f6322e5e201da5858624af1374f649ebae976e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 07:18:56 +0200 Subject: [PATCH 080/142] Documented: voices, shapes and the two faces of a pitch cell --- docs/concepts/project.md | 15 ++-- docs/development/bugs-and-todos.md | 15 +++- docs/development/packages.md | 6 +- docs/development/sequencer-blocks.md | 10 +-- docs/formats/bitphase.md | 16 ++-- docs/formats/famitracker.md | 48 +++++++----- docs/formats/projects.md | 39 +++++++--- docs/glossary.md | 35 +++++++-- docs/guide/interface.md | 11 ++- docs/guide/sequencer.md | 77 +++++++++++++------ docs/index.md | 2 +- .../layout/tabs/sequencer/table_cells.yaml | 2 +- 12 files changed, 191 insertions(+), 85 deletions(-) diff --git a/docs/concepts/project.md b/docs/concepts/project.md index 6f9ddaa75..843b68f7c 100644 --- a/docs/concepts/project.md +++ b/docs/concepts/project.md @@ -1,15 +1,15 @@ # Project A project is a whole composition in _SampleToNES_: a song written for the four NES -channels, together with the reconstructions it is built from. Where a +channels, together with the voices it is built from. Where a [reconstruction](reconstruction.md) is a single converted sound, a project holds -many of them and the arrangement that plays them, so an entire piece lives as one -file. +many of them, the instruments written by hand beside them, and the arrangement that +plays them all, so an entire piece lives as one file. ## What a project brings together -- the **samples** — the reconstructions you have imported, each a playable - instrument in the song; +- the **voices** — the reconstructions you have imported and the shapes you have + written by hand, each a playable instrument in the song; - the **song** — the arrangement itself: the patterns written for each channel and the order they play in; - the **timing and details** — the tempo, speed, and NES frequency the song plays @@ -21,8 +21,9 @@ export the finished piece as a FamiTracker [module](../formats/famitracker.md). ## Self-contained and portable A project embeds the reconstructions it uses rather than pointing at them elsewhere -on disk, so moving or sharing the file carries the whole composition — the -arrangement and every sound it needs. The embedded reconstructions are +on disk, and writes each shape into the document itself, so moving or sharing the +file carries the whole composition — the arrangement and every sound it needs. The +embedded reconstructions are [detached](../formats/reconstructions.md#detached-reconstructions) from their source-audio paths, which mean nothing on another machine, so the project opens the same wherever it goes. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 0452391cf..b7cdc9f1d 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -10,12 +10,23 @@ * Multiple Reconstruction views * In-project sample selection in Reconstruction view * Playing a fragment by clicking on a waveform -* Note pitch shown as a transpose offset rather than a note name * Application installation progress bar ### Tracker -* Basic shapes as instruments +* Pitch and hi-pitch envelopes: a per-tick period bend, where an instruction's pitch is a whole + semitone. Sounding them needs a sub-semitone offset in the instruction model and raw timer values + in the NSF planes, which reaches the reconstruction search space, the instruction library and the + compression pitch table. The two sequences reach a tracker file today and are written empty. +* Release points: `NoteValue.RELEASE` stands in the FamiTracker specification while a note-off cuts + the channel. A release segment would need the playback walk, the NSF driver and `NoteOff` to gain + one. +* Arpeggio modes: a sequence's `setting` byte states absolute. Fixed, relative and scheme need an + enum of their own, and scheme needs the item bit-packing FamiTracker gives it. +* A loop point per envelope: a voice states one point, applied to every populated sequence. +* A sample's loop point is offered as a switch in the voice list, though the model carries the + point for both kinds of voice. +* Exporting a shape as an instrument file from the Reconstructions tab. ### Workflow diff --git a/docs/development/packages.md b/docs/development/packages.md index 767e8f97a..7a9ffa863 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -59,8 +59,10 @@ from above rather than from the engine's own registry. **A song is played out once, for every reader of it.** Turning an arrangement into the instruction each channel sounds on each engine tick — the order walked frame by frame, a row's note -column starting a sample, its transpose and volume bending what the sample carries, a looping sample -wrapping where a one-shot falls silent — is `sampletones_core/performance/`. The sequencer renders +column starting a voice, its transpose and volume bending what that voice carries, a voice with a +loop point circling where one without falls silent — is `sampletones_core/performance/`. One +reading answers for both kinds of voice: a sample plays the frames its conversion found for the +channel, a shape the frames its envelopes make of it. The sequencer renders those instructions to audio and the player encodes them into register values, so what a listener hears and what the console plays are the same walk read two ways rather than two implementations of one rule. diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 177113a38..3748d40e1 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -136,9 +136,9 @@ the span of slots or positions the block stands on, and a body whose lines or fi with it states no block. The span also carries the alignment a tracker block needs, since the first slot decides which subcolumn the block opens on. -**A note names its sample by list position**, the figure the grid prints, so a block carried to -another project plays whichever sample stands at that position there. A position the project's -list falls short of reads as mixed, which is what the writer already makes of a sample it has +**A note names its voice by list position**, the figure the grid prints, so a block carried to +another project plays whichever voice stands at that position there. A position the project's +list falls short of reads as mixed, which is what the writer already makes of a voice it has nothing to place. A field the form has no reading for refuses the whole text, so a parse answers with a block or @@ -268,9 +268,9 @@ Three rules make the travel feel like one gesture: - **The selection stays put after a paste** rather than becoming the pasted footprint. - **A note crosses a project by whichever route it took.** The in-app slot survives a project close, because it must survive `on_project_replaced`, which fires on every undo, and it names - its sample by id: a note whose sample the project in place lacks is left out of the write, and + its voice by id: a note whose voice the project in place lacks is left out of the write, and the target keeps what it had. The clipboard's text names a list position instead, so the same - note pasted through it plays whichever sample stands at that position. Transpose and volume + note pasted through it plays whichever voice stands at that position. Transpose and volume are exact by either route. - **A drag past the edge and the followed playhead both write the scroll.** With **Follow rows** on during playback, `_reveal_playing_row` carries the sounding row to the head of the band diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index c8e751195..4c977b945 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -78,11 +78,17 @@ carries every register value the channel takes for that tick. From | `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled | **Looping.** Playback returns to the instrument's `loop` row once it runs off the end, -which is the only mode there is. A looping slice therefore sets `loop = 0` so its -envelopes repeat from the start while the note is held; a one-shot sets `loop = len - 1` -and rests on the level that row carries — silence where the volume envelope ends on a -note-off item, the channel's own level where the slice holds its volume. A sample's -`loop` flag drives this, the same flag the FamiTracker exporter reads. +which is the only mode there is. A slice with a loop point therefore sets `loop` to that +row so its envelopes repeat from there while the note is held; one playing its rows once +sets `loop = len - 1` and rests on the level that row carries — silence where the volume +envelope ends on a note-off item, the channel's own level where the slice holds its +volume. A voice's loop point drives this, the same point the FamiTracker exporter reads. + +**A shape's slices.** Bitphase bakes a channel's registers tick by tick, so a +[shape](../glossary.md#shape) reaches a document as a slice per channel it sounds on, +each reading the dimensions that channel offers and moving around the root it states. +The envelopes are one set whatever the channel, so the slices differ only in what each +channel reads of them. **A held volume.** A slice whose volume envelope carries no item leaves its level to the channel, so the exporter writes a full `volumeOrRate` for every frame the slice diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 7095d5d73..a92e7a65e 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -141,10 +141,10 @@ Each sequence carries: - **setting** — the sequence mode; for arpeggio, `0` selects absolute (the offsets are added to the played note). -**Looping.** A looping instrument sets the loop point to `0` on every populated -sequence, so its envelopes repeat from the start while the note is held; a one-shot -instrument leaves every loop point at `-1`. A sample's `loop` flag drives this when -the sample is exported into a module. +**Looping.** A voice's loop point sets every populated sequence to repeat from that +item, so its envelopes sustain a held note from there on; a voice playing its +envelopes once leaves every loop point at `-1`. A point beyond a sequence's own items +repeats its final item, which is the value it would hold anyway. **Lengths.** FamiTracker advances each sequence on its own per-tick counter. A sequence that reaches its last item halts and leaves the value it wrote applied, which the driver @@ -177,15 +177,27 @@ and triggering the instrument at `initial_pitch` replays that contour. Volume, d (or noise mode) and any pitch sequences carry across directly. The DPCM key-assignment table is empty by design. -The offset origin is chosen once, when the reconstruction is built, and stored with it -as that channel's reference pitch (see -[Reconstructions](reconstructions.md#contents)). For the pitched channels -`center_pitch` picks it, taking the midpoint of the contour's `(lowest, highest)` -range; the noise channel takes the first sounding period. Every later export reports -that stored pitch as `initial_pitch` and writes each frame as `pitch − initial_pitch`, -wrapped into the 16 available periods on noise. The offsets straddle zero and stay -compact around one note, and the pattern cell holds the contour's midpoint — a rising -contour prints its middle note and opens below it. +A [shape](../glossary.md#shape) is one set of envelopes every channel reads, which is +the instrument model FamiTracker itself uses, so it becomes a single instrument +however many channels play it. Its dimensions are written at one length, each holding +its final value where it is the shorter, so a tracker advancing every sequence on a +counter of its own sounds the shape the way the engine here plays it. Every channel +that names the shape reaches that one instrument, each against the root it reads — +the shape's note on the tonal channels, its period on noise. + +**Where a row's note comes from.** A voice states where its zero is and a row states +the step from it, so a pattern cell holds `reference + transpose`, held inside the +range a tonal channel plays and wrapped into the sixteen periods on noise. A sample's +reference is the offset origin its conversion chose; a shape's is the root it states. + +That origin is chosen once, when the reconstruction is built, and stored with it as +that channel's reference pitch (see [Reconstructions](reconstructions.md#contents)). +For the pitched channels `center_pitch` picks it, taking the midpoint of the contour's +`(lowest, highest)` range; the noise channel takes the first sounding period. Every +later export reports that stored pitch as `initial_pitch` and writes each frame as +`pitch − initial_pitch`, wrapped into the 16 available periods on noise. The offsets +straddle zero and stay compact around one note, and the pattern cell holds the +contour's midpoint — a rising contour prints its middle note and opens below it. ## C. FamiTracker capacity limits @@ -198,13 +210,13 @@ checklist. | Quantity | FamiTracker limit | Project bound today | Exporter behaviour | | --- | --- | --- | --- | -| Instruments | 64 total | unbounded (1–4 per sample, so ≈16–64 samples) | raises when the distinct slices exceed 64 | +| Instruments | 64 total | unbounded (1–4 per sample, one per shape) | raises when the instruments exceed 64 | | Sequences per kind | 128 | unbounded | raises when a kind's pool exceeds 128 | | Items per sequence | 252 | one item per reconstruction frame, unbounded | keeps the opening 252 items and logs a warning | | Patterns per channel | 128 (indices 0–127) | pool keyed by arbitrary ints | raises when a pattern index exceeds 127 | | Order frames | 128 | unbounded | raises when the order exceeds 128 frames | | Pattern length (rows) | 256 | 1–256 (`rows_per_pattern`) | matches; no guard needed | -| Note range | C-0..B-7 (pitch 24–119) | `initial_pitch` 33–119 + `transpose` −24..+36 can exceed it | clamps to the nearest playable note (fidelity loss at the extremes) | +| Note range | C-0..B-7 (pitch 24–119) | a reference of 33–119 plus a transpose reaching either end of that span | clamps to the nearest playable note (fidelity loss at the extremes) | | Title / author | 32 bytes each | 64 characters | truncates to 32 bytes | | Comment | free text (COMMENTS block) | 65536 characters | carried in full | | Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings | @@ -259,6 +271,6 @@ once, so its own sequences are charged once each. **Looping levels the sequences.** A looping instrument brings its populated dimensions to the shortest length, while a one-shot keeps each dimension as written (section B), so the two forms -of one set of envelopes cost differently. A sample carries the flag that decides which applies; -a reconstruction standing on its own is measured as a one-shot, matching the instrument its -**Export instrument** writes. +of one set of envelopes cost differently. A voice carries the loop point that decides which +applies; a reconstruction standing on its own is measured as a one-shot, matching the instrument +its **Export instrument** writes. diff --git a/docs/formats/projects.md b/docs/formats/projects.md index 42547f686..8d513b299 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -1,10 +1,10 @@ # Projects -A project gathers a set of reconstructions and arranges them into a song, saved -as a single `.stp` file. It is what the sequencer works with, and what you hand -over when you share a whole piece. See [Project](../concepts/project.md) for what a -project is; this page documents the file. [Reconstructions](reconstructions.md) -documents the individual samples it contains. +A project gathers a set of voices and arranges them into a song, saved as a single +`.stp` file. It is what the sequencer works with, and what you hand over when you +share a whole piece. See [Project](../concepts/project.md) for what a project is; +this page documents the file. [Reconstructions](reconstructions.md) documents the +converted audio a sample stands on. ## Structure @@ -16,7 +16,8 @@ A `.stp` file is a zip archive with two kinds of member: id. Keeping the reconstructions in separate members lets `project.json` stay small -while the larger audio data travels alongside it in the same archive. +while the larger audio data travels alongside it in the same archive. A +[shape](../glossary.md#shape) carries no audio, so the document holds it whole. ### `project.json` @@ -26,9 +27,20 @@ while the larger audio data travels alongside it in the same archive. | `metadata` | the application name and version (managed automatically) | | `info` | `title`, `author`, and `comment`, plus `created` and `modified` timestamps | | `settings` | the engine settings: `nes_frequency`, `sample_rate`, `tempo`, `speed`, and the metric highlights `first_highlight` and `second_highlight` | -| `samples` | the song's samples — each an `id`, a `name`, and the `reconstruction_id` of its audio member | +| `voices` | the song's voices, each told apart by its `kind` (below) | | `song` | the arrangement (below) | +### `voices` + +Every voice carries an `id`, a `name`, and the `loop_point` its envelopes repeat +from while a note is held, or `null` where they play once. The `kind` says what +else it carries: + +| `kind` | Contents | +| --- | --- | +| `sample` | the `reconstruction_id` of its audio member | +| `shape` | its `envelopes` — the `volume`, `arpeggio` and `duty_cycle` values it writes, each a list of one item per tick — and the `root_pitch` and `root_period` those values are measured against | + ### `song` The arrangement across the four channels: @@ -36,8 +48,10 @@ The arrangement across the four channels: * `rows_per_pattern` — the row count every pattern in the song shares; * `order` — the arrangement itself: an ordered list of frames, each frame mapping every channel to the pattern index it plays, or empty for a silent slot; -* `channels` — per channel, a pool of patterns, each pattern a list of rows - carrying the note, volume, and transpose data. +* `channels` — per channel, a pool of patterns, each pattern a list of rows. A row + states the `command` its note column holds — the `voice_id` to start, or a + note-off — along with its `transpose` and `volume`. The channel a voice sounds on + is the one whose pool holds the row. ## Detached reconstructions @@ -56,6 +70,7 @@ deserialization (see [Data compatibility](../development/compatibility.md)). Unknown or extra fields within a matching version are ignored, which leaves room for the format to grow. -The current format version is 1.1. Version 1.1 renamed each channel pool's -`generator` key to `name` and a row instrument's `generator_name` key to -`channel_name`; the channel values stored inside never changed. +The current format version is 1.2. Version 1.2 gathers `samples` into `voices`, +each record stating its `kind`, and names a row's note command by `voice_id` alone. +Version 1.1 named each channel pool by `name` and a row command's channel by +`channel_name`. diff --git a/docs/glossary.md b/docs/glossary.md index eb7c1f8a6..f7fa783c3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -218,17 +218,40 @@ tables, patterns, and order together. In Bitphase, a per-tick list of semitone offsets a pattern cell attaches to a channel, which carries the pitch contour a FamiTracker arpeggio sequence would. +### Voice + +Anything a tracker row can name: a **sample** or a **shape**. A project holds its +voices in one list, and a row states which one to start and the step it plays at. + ### Sample (sequencer) -A reconstruction added to the sequencer as a playable, placeable voice in the -song. +A reconstruction added to the sequencer as a playable voice, carrying the +instruction stream its conversion found for each channel. + +### Shape + +A voice written by hand: envelopes with no recording behind them. One shape is one +instrument every channel can read, so it is placed on whichever channel suits it — +the way a FamiTracker instrument is. See [The sequencer](guide/sequencer.md). + +### Root + +The note a shape's arpeggio is measured against, which a row's step moves it from. +A shape states one for the tonal channels and one for the noise channel's periods, +so the same envelopes sound on any of the four. The matching value on a sample is +its per-channel [reference pitch](formats/reconstructions.md#contents). + +### Loop point + +The tick a voice's envelopes repeat from while a note is held, which lets an attack +be followed by a sustained tail. A voice without one plays its envelopes once. ### Instrument -A single FamiTracker instrument, saved as an `.fti` file, exported from one -channel of a reconstruction. See [FamiTracker export](formats/famitracker.md). -Bitphase takes the same slice as a `.json` instrument preset. See -[Bitphase export](formats/bitphase.md). +A single FamiTracker instrument, saved as an `.fti` file. A sample exports one per +channel it plays; a shape exports one that every channel reaches. See +[FamiTracker export](formats/famitracker.md). Bitphase takes the same envelopes as +a `.json` instrument preset. See [Bitphase export](formats/bitphase.md). ## File types diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 529eda0a2..f85997075 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -148,6 +148,15 @@ down. **Export instrument...** writes the channel you are looking at, in whichever tracker format you pick in the save dialog — see [where your files live](files.md#exported-files). +A **shape** — a voice you wrote by hand rather than converted, see the +[sequencer guide](sequencer.md#voices-samples-and-shapes) — opens here too, from +the **Voices** list's right-click ▸ **Edit**. It stands on no recording, so the tab +shows its envelopes alone: one instrument every channel reads, under the shape's own +name. **Root pitch** is the note its arpeggio is measured against on the melodic +channels and **Root period** the one on **Noise**, and **Loop point** is the tick its +envelopes repeat from while a note is held — an attack followed by a sustained tail. +Editing a shape puts away whatever reconstruction the tab held. + ## Instructions The **Instructions** tab builds and browses the [instruction @@ -175,7 +184,7 @@ reconstruction and its exports, **Playback** for playing and for muting the sequencer's channels, **View** for settings and the window, and **Help** for **About**. What **Edit** offers below undo and redo follows your cursor: the block actions of the sequencer grid you are in, or the actions of the sample you -have picked in the **Samples** list. +have picked in the **Voices** list. Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** for the reconstruction you have open, and **File ▸ Render song...** diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 8a3722ef7..10a053924 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -1,40 +1,67 @@ # The sequencer -The **Sequencer** tab is a tracker: it arranges reconstructions into a song across -the four NES channels and exports it as a FamiTracker +The **Sequencer** tab is a tracker: it arranges voices into a song across the four +NES channels and exports it as a FamiTracker [module](../formats/famitracker.md) (`.ftm`). It works on a [project](../formats/projects.md), so start one with **File ▸ New project** (or open an existing `.stp`). The pattern grid and order sit in the centre, a browser -for pulling in reconstructions on the left, and the module settings, sample list, +for pulling in reconstructions on the left, and the module settings, voice list, and undo history on the right. -## Adding samples +## Voices: samples and shapes -A song is built from **samples** — reconstructions imported as playable -instruments. Add one from the **Reconstructions** browser on the left (right-click -▸ **Add to Sequencer**), or with **Add to Sequencer** on the **Reconstructions** -tab. If a reconstruction was made at a different NES frequency than the project and -the project already has samples, _SampleToNES_ warns with **Different NES -frequency**; **Add anyway** adds it regardless. +A song is built from **voices**, and there are two kinds. A **sample** is a +reconstruction imported as a playable instrument. A **shape** is written by hand — +envelopes with no recording behind them — for the melodies and basses you write +yourself. Both sit in the **Voices** list on the right, numbered together, and a +mark at the front of each row says which kind it is. -Manage the imported samples in the **Samples** list on the right: right-click one -to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its -**Loop** flag. The **Edit** menu carries the same actions for the sample you have -picked. The right-click menu also names how much room the sample takes on the NES — -its total, then each channel it plays — measured as its **Loop** flag has it. The -figures are in bytes, and they count what a FamiTracker export saves. -Removing a sample that patterns still use asks **Remove sample** first, because it -clears every row that references it. +Add a sample from the **Reconstructions** browser on the left (right-click ▸ **Add +to Sequencer**), or with **Add to Sequencer** on the **Reconstructions** tab. If a +reconstruction was made at a different NES frequency than the project and the +project already has voices, _SampleToNES_ warns with **Different NES frequency**; +**Add anyway** adds it regardless. + +Add a shape with **New shape** at the top of the list. It starts empty and silent — +give it envelopes on the **Reconstructions** tab (right-click ▸ **Edit**) and it +begins to sound. See [editing instruments](interface.md#editing-instruments). + +Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or +reorder it, and toggle its **Loop** flag. The **Edit** menu carries the same actions +for the voice you have picked. The right-click menu also names how much room the +voice takes on the NES — a sample's total and then each channel it plays, a shape's +one instrument — measured as its **Loop** flag has it. The figures are in bytes, and +they count what a FamiTracker export saves. Removing a voice that patterns still use +asks first, because it clears every row that references it. ## Writing a pattern The **Tracker** grid is the pattern editor. Each row is one step in time; the columns are the **Sample** and the four channels — **Pulse 1**, **Pulse 2**, -**Triangle**, **Noise** — each carrying a note, volume, and transpose. Click a cell -and type on your keyboard to enter a note, piano-style. Right-clicking a cell opens -the rest of the operations — **Set instrument**, **Note off**, **Clear cell** and -**Clear row**, transpose and volume adjustments, **Play from here** to audition from -the cursor row, and **Play from this frame** to start at the top of the shown frame. +**Triangle**, **Noise** — each carrying a voice, a pitch, and a volume. Click a cell +and type its value. Right-clicking a cell opens the rest of the operations — **Set +instrument**, **Note off**, **Clear cell** and **Clear row**, transpose and volume +adjustments, **Play from here** to audition from the cursor row, and **Play from +this frame** to start at the top of the shown frame. + +The **Sample** column places a sample across every channel its reconstruction +covers. A shape is one instrument for one channel at a time, so name it in the +channel column you want it on. + +## Reading and typing a pitch + +A pitch cell holds one number, and it reads in the terms of the voice the channel is +carrying. A sample was converted at a pitch of its own, so its cells read as steps +from it — `+00` plays it as recorded, `+0C` an octave up. A shape was written +against a root you chose, so its cells read as the notes they sound — `C-4`, `A#3`. +A row that only bends a note reads the same way as the row that started it. + +Type a note into a shape's cell piano-style: the bottom two rows of the keyboard are +one octave (`Z` `S` `X` `D` `C` …) and the two above them the next (`Q` `2` `W` `3` +`E` …). **Octave** above the grid says where the bottom row opens. The keys work on +a sample's cell too, writing the step that reaches the note you pressed. The noise +channel selects one of sixteen periods rather than a note, so its cells are typed as +a signed value. ## Arranging the song @@ -96,7 +123,7 @@ A copy also goes to your desktop's clipboard as plain text, so a block carries b two open windows of _SampleToNES_ — copy in one, paste in the other — and you can paste one into a message to show someone what you wrote. Anything else on the clipboard leaves you with the last block you copied here. Notes travel by their number in the -**Samples** list, so a block pasted into another project plays whichever sample holds +**Voices** list, so a block pasted into another project plays whichever voice holds that number there. ## Transposing and shading @@ -182,7 +209,7 @@ audible. Set the song's timing in **Module options** on the right: **Rows** per pattern, **Tempo**, **Speed**, and the **NES frequency**. Changing the **NES frequency** -after samples exist re-times how they all play back, so it asks **Change NES +after voices exist re-times how they all play back, so it asks **Change NES frequency** first (with a **Don't ask again** option). The project's title, author, and comment — which carry into the exported module — diff --git a/docs/index.md b/docs/index.md index a40a463e8..1854ddcb6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward. - [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration. - [Getting started](guide/getting-started.md) — your first reconstruction and your first song. - [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus. -- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song, exporting a module, and rendering it to audio. +- [The sequencer](guide/sequencer.md) — the tracker: arranging samples and hand-written shapes into a song, exporting a module, and rendering it to audio. - [Command line](guide/command-line.md) — running without the graphical interface. - [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses. - [Configuration](guide/configuration.md) — the settings you can change, and where. diff --git a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml index 0c50a00a4..dc16a0ff9 100644 --- a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml @@ -3,7 +3,7 @@ sample: 80 divider: 4 channel: 80 voice: - kind: 24 + kind: 44 id: 40 name: 1 loop: 40 From e3b93713e372caee5ca5b13282dbf69f55f08deb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 12:08:56 +0200 Subject: [PATCH 081/142] Sounded: a new shape from the moment it is placed --- docs/guide/sequencer.md | 7 ++-- src/sampletones_application/application.py | 4 ++- .../logic/project/controller.py | 10 +++--- .../logic/sequencer/voices.py | 3 +- .../project/voices/__init__.py | 2 ++ .../project/voices/creation.py | 28 +++++++++++++++ .../logic/project/test_controller.py | 21 ++++++----- .../logic/reconstruction/test_editor.py | 15 ++++---- .../logic/reconstruction/test_instruments.py | 3 +- .../logic/sequencer/test_voices.py | 5 +-- .../sequencer/tracker/test_pitch_faces.py | 3 +- .../sequencer/tracker/test_write_note.py | 7 ++-- .../project/voices/test_creation.py | 36 +++++++++++++++++++ 13 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 src/sampletones_core/project/voices/creation.py create mode 100644 tests/unit/sampletones_core/project/voices/test_creation.py diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 10a053924..6e78fba0c 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -22,9 +22,10 @@ reconstruction was made at a different NES frequency than the project and the project already has voices, _SampleToNES_ warns with **Different NES frequency**; **Add anyway** adds it regardless. -Add a shape with **New shape** at the top of the list. It starts empty and silent — -give it envelopes on the **Reconstructions** tab (right-click ▸ **Edit**) and it -begins to sound. See [editing instruments](interface.md#editing-instruments). +Add a shape with **New shape** at the top of the list. It starts out holding a note at +full volume, so you can place it and hear it straight away; shape it into the sound you +want on the **Reconstructions** tab (right-click ▸ **Edit**). See [editing +instruments](interface.md#editing-instruments). Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop** flag. The **Edit** menu carries the same actions diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ca314c73b..83cbdf156 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1030,7 +1030,8 @@ def _edit_project_voice(self, voice_id: str) -> None: """Opens the voice list's selection on the Reconstructions tab, in the terms of its kind. A sample opens as the reconstruction behind it, waveform and stems and all; a shape stands - on no recording, so the tab shows its envelopes alone. + on no recording, so the tab shows its envelopes alone. Either kind brings that tab to the + front, so the voice a reader asked to edit is the one in view. """ match self.project_manager.current.voice(voice_id): case Sample() as sample: @@ -1041,6 +1042,7 @@ def _edit_project_voice(self, voice_id: str) -> None: ) case Shape(): self._reconstructions_tab.edit_shape(voice_id) + self._navigate_to_reconstructions() case _: logger.warning(f"Cannot edit unknown project voice: {voice_id}") diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 9aa060a19..24261427a 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -202,13 +202,13 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: self._announce(self.on_voices_changed) return sample - def add_shape(self, name: str) -> Shape: - """Appends a hand-written voice, resting at the roots a channel added by hand sounds on. + def add_shape(self, shape: Shape) -> Shape: + """Appends a hand-written voice, which the voice list holds and the tracker can name. - A shape opens with no envelope, so every dimension is the channel's until one is written; - the voice list holds it from this moment and the tracker can name it. + A shape is its own record, so whoever made it — a reader asking for a new one, an + instrument file read from disk, a sample's channel frozen into envelopes — hands the + whole voice over and the pool takes it as it stands. """ - shape = Shape(name=name) self.project.voices.append(shape) self._touch() self._announce(self.on_voices_changed) diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 67db178f7..452fd1814 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -23,6 +23,7 @@ reconstruction_footprints, ) from sampletones_core.generators.render import render_instructions +from sampletones_core.project.voices.creation import new_shape from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.shape import Shape @@ -86,7 +87,7 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: return self._controller.add_sample(reconstruction, name) def add_shape(self, name: str) -> Shape: - return self._controller.add_shape(name) + return self._controller.add_shape(new_shape(name)) def rename_voice(self, voice_id: str, name: str) -> None: self._controller.rename_voice(voice_id, name) diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py index e6539f967..ac0b43544 100644 --- a/src/sampletones_core/project/voices/__init__.py +++ b/src/sampletones_core/project/voices/__init__.py @@ -1,3 +1,4 @@ +from .creation import new_shape from .envelopes import ShapeEnvelopes from .loop import WHOLE_LOOP_POINT from .note_off import NoteOff @@ -17,6 +18,7 @@ "ShapeEnvelopes", "VoiceRecord", "VoiceUnion", + "new_shape", "samples", "voice_channels", "voice_reference", diff --git a/src/sampletones_core/project/voices/creation.py b/src/sampletones_core/project/voices/creation.py new file mode 100644 index 000000000..d024dbbae --- /dev/null +++ b/src/sampletones_core/project/voices/creation.py @@ -0,0 +1,28 @@ +from typing import Final + +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.shape import Shape + +SUSTAINING_ENVELOPES: Final[ShapeEnvelopes] = ShapeEnvelopes(volume=(MAX_VOLUME,)) + + +def new_shape(name: str) -> Shape: + """A shape a reader can place and hear straight away, before writing an envelope of its own. + + A shape sounds the frames its envelopes describe, so one holding a single full-volume tick + that repeats holds a note for as long as a row asks for it, at the roots a channel added by + hand rests on. Arpeggio and duty cycle stay the channel's until the reader writes them. + + Args: + name: The name the voice list shows. + + Returns: + Shape: A voice sustaining at full volume on every channel. + """ + return Shape( + name=name, + envelopes=SUSTAINING_ENVELOPES, + loop_point=WHOLE_LOOP_POINT, + ) diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 145a72eb8..2bdce3e4e 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -8,9 +8,9 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer +from sampletones_core.project.voices.creation import new_shape from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.shape import Shape @@ -504,19 +504,18 @@ def test_set_sample_loop_toggles_loop_flag( class TestShapes: - def test_add_shape_appends_a_voice_resting_where_a_hand_added_channel_rests(self) -> None: + def test_add_shape_appends_the_voice_it_is_given(self) -> None: controller = _controller() + shape = new_shape("lead") - shape = controller.add_shape("lead") + added = controller.add_shape(shape) + assert added is shape assert controller.project.voice(shape.id) is shape - assert shape.root_pitch == RESTING_REFERENCE_PITCH - assert shape.root_period == RESTING_REFERENCE_PERIOD - assert shape.envelopes.frame_count == 0 def test_writing_an_envelope_reaches_the_frames_the_shape_sounds(self) -> None: controller = _controller() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 10)) @@ -525,7 +524,7 @@ def test_writing_an_envelope_reaches_the_frames_the_shape_sounds(self) -> None: def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None: controller = _controller() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, ()) @@ -534,7 +533,7 @@ def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None: def test_moving_the_roots_reaches_the_frames(self) -> None: controller = _controller() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) controller.set_shape_root(shape.id, pitch=48, period=3) @@ -557,14 +556,14 @@ def test_a_sample_takes_no_shape_edit( def test_a_shape_takes_no_reconstruction(self) -> None: controller = _controller() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) with pytest.raises(TypeError): controller.replace_sample_reconstruction(shape.id, Mock()) def test_a_shape_duplicates_into_a_voice_of_its_own(self) -> None: controller = _controller() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) clone = controller.duplicate_voice(shape.id) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index bf4b4ac6d..718e915a1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -11,6 +11,7 @@ from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.project.voices.creation import new_shape from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT ROOT_PITCH: Final[int] = 55 @@ -67,7 +68,7 @@ def test_a_shape_answers_with_what_it_states( editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) editor.edit_shape(shape.id) @@ -84,7 +85,7 @@ def test_opening_a_shape_closes_the_reconstruction_the_tab_held( reconstruction_manager: MagicMock, ) -> None: """The tab describes one voice, so its waveform and stems follow what is in front of it.""" - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) editor.edit_shape(shape.id) @@ -96,7 +97,7 @@ def test_letting_go_of_a_shape_hands_the_tab_back( controller: ProjectController, reconstruction_manager: MagicMock, ) -> None: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) editor.edit_shape(shape.id) reconstruction_manager.current_features = MagicMock(channels={ChannelName.PULSE1: _features()}) @@ -109,7 +110,7 @@ def test_a_shape_removed_from_the_project_leaves_the_tab_holding_nothing( editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) editor.edit_shape(shape.id) controller.remove_voice(shape.id) @@ -123,7 +124,7 @@ def test_an_envelope_reaches_the_shape( editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) editor.edit_shape(shape.id) editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) @@ -135,7 +136,7 @@ def test_the_roots_reach_the_shape( editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) editor.edit_shape(shape.id) editor.write_roots(pitch=ROOT_PITCH, period=ROOT_PERIOD) @@ -147,7 +148,7 @@ def test_the_loop_point_reaches_the_shape( editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) editor.edit_shape(shape.id) editor.write_loop_point(WHOLE_LOOP_POINT) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 4490ce0ac..9c63e4069 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -23,6 +23,7 @@ features_footprint, total_footprint, ) +from sampletones_core.project.voices.creation import new_shape from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction @@ -331,7 +332,7 @@ def shape_logic( ) -> ReconstructionInstrumentsLogic: mock_reconstruction_manager.current_features = None editor = InstrumentEditor(mock_reconstruction_manager, project_controller) - shape = project_controller.add_shape("lead") + shape = project_controller.add_shape(new_shape("lead")) project_controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12)) editor.edit_shape(shape.id) return ReconstructionInstrumentsLogic(editor, scheduling=scheduling) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 6451dc81b..0d6637169 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -16,6 +16,7 @@ ) from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.shape import Shape from sampletones_core.reconstructions import Reconstruction from tests.suite.sequencer import sample_reconstruction @@ -360,8 +361,8 @@ def test_a_shape_previews_through_the_pulse_channel(self) -> None: assert played.size > 0 def test_a_shape_writing_nothing_sounds_no_preview(self) -> None: - _, logic, _, audio_device_manager = _logic_with_mocks() - shape = logic.add_shape("lead") + controller, logic, _, audio_device_manager = _logic_with_mocks() + shape = controller.add_shape(Shape(name="lead")) logic.play_voice(shape.id) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py index 862fcc630..d6c4854ef 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -4,6 +4,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.creation import new_shape from sampletones_core.project.voices.envelopes import ShapeEnvelopes from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn @@ -24,7 +25,7 @@ def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]: def _shape(controller: ProjectController) -> Shape: - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) shape.envelopes = ShapeEnvelopes(volume=(15,)) shape.invalidate() diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py index be56ab8a9..6d92d4ea7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py @@ -4,6 +4,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.creation import new_shape from sampletones_core.project.voices.envelopes import ShapeEnvelopes from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn @@ -37,7 +38,7 @@ def _transpose(logic: SequencerTrackerLogic, channel: ChannelName, row_index: in class TestATypedNoteIsStatedAsAStepFromTheVoice: def test_a_shape_takes_the_step_that_reaches_the_note(self) -> None: controller, logic = _logic() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=8) shape.envelopes = ShapeEnvelopes(volume=(15,)) shape.invalidate() @@ -59,7 +60,7 @@ def test_a_sample_takes_the_step_from_its_own_pitch(self) -> None: def test_a_row_below_the_note_is_measured_against_the_voice_it_carries(self) -> None: controller, logic = _logic() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=8) _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) @@ -76,7 +77,7 @@ def test_a_row_carrying_no_voice_is_left_as_it_stands(self) -> None: def test_a_row_past_a_note_off_carries_no_voice(self) -> None: controller, logic = _logic() - shape = controller.add_shape("lead") + shape = controller.add_shape(new_shape("lead")) _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) _write(controller, ChannelName.PULSE1, 1, NoteOff()) diff --git a/tests/unit/sampletones_core/project/voices/test_creation.py b/tests/unit/sampletones_core/project/voices/test_creation.py new file mode 100644 index 000000000..987d246ab --- /dev/null +++ b/tests/unit/sampletones_core/project/voices/test_creation.py @@ -0,0 +1,36 @@ +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH +from sampletones_core.project.voices.creation import new_shape +from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT + + +class TestNewShape: + def test_a_new_shape_sounds_a_frame_on_every_channel(self) -> None: + shape = new_shape("lead") + + for channel_name in ChannelName.items(): + assert shape.instructions(channel_name) + + def test_a_new_shape_sounds_at_full_volume(self) -> None: + shape = new_shape("lead") + + assert shape.envelopes.volume == (MAX_VOLUME,) + + def test_a_new_shape_repeats_its_envelopes_while_the_note_is_held(self) -> None: + assert new_shape("lead").loop_point == WHOLE_LOOP_POINT + + def test_a_new_shape_leaves_the_arpeggio_and_the_duty_cycle_to_the_channel(self) -> None: + shape = new_shape("lead") + + assert shape.envelopes.arpeggio == () + assert shape.envelopes.duty_cycle == () + + def test_a_new_shape_rests_where_a_channel_added_by_hand_rests(self) -> None: + shape = new_shape("lead") + + assert shape.root_pitch == RESTING_REFERENCE_PITCH + assert shape.root_period == RESTING_REFERENCE_PERIOD + + def test_each_new_shape_is_a_voice_of_its_own(self) -> None: + assert new_shape("lead").id != new_shape("lead").id From 93bd7433acb1d33edb8bf1b10eea5cfb53c8bbdd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 13:25:57 +0200 Subject: [PATCH 082/142] Opened: the voice list --- .../categories/elements/sequencer.py | 1 + .../coordinators/tabs/sequencer.py | 35 +++++- src/sampletones_application/tags/general.py | 1 + src/sampletones_application/tags/sequencer.py | 6 + .../ui/panels/sequencer/voices.py | 81 +++++++++++- src/sampletones_application/utils/gui/dpg.py | 54 ++++++++ src/sampletones_config/lang/en.yaml | 6 +- .../ui/panels/sequencer/test_voices_menu.py | 116 ++++++++++++++++++ 8 files changed, 294 insertions(+), 6 deletions(-) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index fb39ab673..0abb4eda9 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -88,6 +88,7 @@ class SequencerOrderElements(AbstractElement): class SequencerVoicesElements(AbstractElement): VOICES_TEXT = "voices_text" NEW_SHAPE = "new_shape" + ADD_SAMPLE = "add_sample" KIND_SAMPLE = "kind_sample" KIND_SHAPE = "kind_shape" COLUMN_KIND = "column_kind" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 9bbf470fb..f4e8b7f07 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -89,6 +89,9 @@ from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.file_dialogs.api import open_file_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.clipboard import ( SystemTextClipboard, TextClipboard, @@ -131,6 +134,7 @@ from sampletones_core.utils.display import display_id from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from sampletones_shared.types.callback import StringCallback, VoidCallback _UndoableParams = ParamSpec("_UndoableParams") @@ -638,12 +642,14 @@ def _wire_samples_callbacks(self) -> None: detail=self._history_detail.duplicate_voice, ) self._sequencer_voices_panel.on_new_shape_requested = self._add_shape + self._sequencer_voices_panel.on_add_sample_requested = self._add_sample_from_file def _add_shape(self) -> None: """Appends a hand-written voice, named for the position it takes in the list. - A shape opens with no envelope, so it is the reader's to write; naming it by its position - gives the list a readable entry until they rename it. + A shape arrives sustaining at full volume, so it plays as soon as it is placed and the + envelopes stay the reader's to write; naming it by its position gives the list a readable + entry until they rename it. """ name = self._language_manager["sequencer.voices.template.shape_name"].format( position=display_id(self._project_controller.voice_count), @@ -654,6 +660,31 @@ def _add_shape(self) -> None: ): self._sequencer_voices_logic.add_shape(name) + def _add_sample_from_file(self) -> None: + """Brings a reconstruction saved anywhere on disk into the pool as a sample. + + The tree beside the list reaches the reconstructions folder, so a file kept elsewhere + arrives through the system's own browser, which opens on the folder the last one came + from. + """ + filepath = open_file_dialog( + title=self._language_manager["sequencer.voices.title.add_sample_dialog"], + initial_directory=self._session_manager.get_reconstruction_path(), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.reconstruction"], + [EXT_FILE_RECONSTRUCTION], + ), + ), + ) + + self._import_located_reconstruction(filepath) + + @ignore_none_path + def _import_located_reconstruction(self, filepath: Path) -> None: + self._session_manager.set_reconstruction_path(filepath.parent) + self.import_reconstruction(filepath) + def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed) self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index e354a228b..a77da8d0b 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -732,6 +732,7 @@ SUF_HANDLER_DETAIL_TOOLTIP = compose_tag("handler", "detail_tooltip") SUF_HANDLER_HEADER = compose_tag("handler", "header") SUF_HANDLER_DRAG = compose_tag("handler", "drag") +SUF_HANDLER_LIST = compose_tag("handler", "list") SUF_LABEL = "label" SUF_PATH = "path" SUF_TEXT = "text" diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index 0eeec703f..5f0b0b417 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -152,6 +152,12 @@ Widget.PANEL, "voices", ) +TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.BUTTON, + "new_shape", +) TAG_SEQUENCER_VOICES_TABLE = TagName( Page.SEQUENCER, Panel.VOICES, diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices.py index 0762edad4..c98eaab32 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -12,8 +12,9 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.tags.general import SUF_HANDLER_LIST, SUF_HANDLER_REGISTRY from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE, TAG_SEQUENCER_VOICES_INPUT_RENAME, TAG_SEQUENCER_VOICES_PANEL, TAG_SEQUENCER_VOICES_TABLE, @@ -29,7 +30,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_delete_children +from sampletones_application.utils.gui.dpg import dpg_delete_children, dpg_pointer_within_window from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, @@ -112,6 +113,8 @@ def __init__( self._shortcuts = shortcut_source self._row_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_TABLE, SUF_HANDLER_REGISTRY) self._rename_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_INPUT_RENAME, SUF_HANDLER_REGISTRY) + self._list_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_WINDOW, SUF_HANDLER_LIST) + self._list_menu_pending = False self._selected_voice_id: Optional[str] = None self._selected_row: Optional[int] = None self._editing_voice_id: Optional[str] = None @@ -132,6 +135,7 @@ def __init__( self.on_rename_committed: Optional[Callable[[str, str], None]] = None self.on_duplicate_requested: Optional[StringCallback] = None self.on_new_shape_requested: Optional[VoidCallback] = None + self.on_add_sample_requested: Optional[VoidCallback] = None super().__init__( tag=TAG_SEQUENCER_VOICES_PANEL, @@ -150,6 +154,7 @@ def create_panel(self, parent: str) -> None: self._create_voices_table() self._create_row_handlers() + self._create_list_handler() self._create_rename_handler() self._create_key_handler() @@ -158,6 +163,14 @@ def _create_row_handlers(self) -> None: dpg.add_item_clicked_handler(callback=self._on_sample_clicked) dpg.add_item_double_clicked_handler(callback=self._on_sample_double_clicked) + def _create_list_handler(self) -> None: + """Answers a press that lands on the list itself rather than on one of its rows.""" + with dpg.handler_registry(tag=self._list_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Right, + callback=self._on_list_right_clicked, + ) + def _create_rename_handler(self) -> None: with dpg.item_handler_registry(tag=self._rename_handler_tag): dpg.add_item_deactivated_handler(callback=self._on_rename_deactivated) @@ -172,6 +185,7 @@ def _create_key_handler(self) -> None: def _create_new_shape_button(self) -> None: """Offers a hand-written voice, which is the one kind no browser brings in.""" button = dpg.add_button( + tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE, label=self._label(self._language_manager, SequencerVoicesElements.NEW_SHAPE), width=-1, callback=lambda: self.call(self.on_new_shape_requested), @@ -592,8 +606,46 @@ def _on_sample_clicked( return position, voice_id = user_data + self._list_menu_pending = False self._show_context_menu(position, voice_id) + def _on_list_right_clicked( + self, + _sender: Sender, + _app_data: int, + ) -> None: + """Raises the list's own menu a frame later, leaving a row that answers the press first. + + Both doors are offered the same press, the list before the row, so the menu the list + would raise waits a frame: a row landed on claims the press in the meantime and states + the voice it holds, and an empty stretch of the list leaves the claim unmade. + """ + if not self._pointer_within_list(): + return + + self._list_menu_pending = True + FrameCallbackManager.set_frame_callback(self._show_list_menu) + + def _pointer_within_list(self) -> bool: + """Whether the pointer stands over the voice list. + + The button above the list is laid out in the card that holds them both, so where the + button was drawn places the list on screen as well. + """ + return dpg_pointer_within_window( + TAG_SEQUENCER_VOICES_WINDOW, + TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE, + ) + + def _show_list_menu(self) -> None: + """Prints the ways a voice comes in, for a press the list answered.""" + if not self._list_menu_pending: + return + + self._list_menu_pending = False + with context_menu(): + self.add_pool_items() + def _entry_for(self, voice_id: str) -> Optional[VoiceEntryViewModel]: return next((entry for entry in self._entries if entry.voice_id == voice_id), None) @@ -626,6 +678,8 @@ def _show_context_menu(self, position: int, voice_id: str) -> None: ) dpg.add_separator() self.add_action_items(target) + dpg.add_separator() + self.add_pool_items() def _footprint_items(self, voice_id: str) -> List[Tuple[str, str]]: """The byte figures the menu prints for a sample: its total, then each channel that plays. @@ -668,6 +722,29 @@ def build_edit_actions(self) -> None: if selection is not None: self.add_action_items(selection) + def add_pool_items(self) -> None: + """Builds the ways a voice comes into the pool, in the order each menu prints them. + + A voice is written by hand or converted from a recording, and both stand apart from the + actions a listed voice offers, since each answers with an entry the list did not hold. + Every door onto the list prints this section, so a reader reaches it from the list and + from a row alike. + """ + dpg.add_menu_item( + label=self._label( + self._language_manager, + SequencerVoicesElements.NEW_SHAPE, + ), + callback=lambda: self.call(self.on_new_shape_requested), + ) + dpg.add_menu_item( + label=self._label( + self._language_manager, + SequencerVoicesElements.ADD_SAMPLE, + ), + callback=lambda: self.call(self.on_add_sample_requested), + ) + def add_action_items(self, target: VoiceSelection) -> None: """Builds every action a sample offers, in the order each menu prints them. diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index e66dcdc0a..b8d467ebc 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -193,3 +193,57 @@ def dpg_is_item_hovered( ) -> Optional[bool]: is_hovered: Optional[bool] = dpg.is_item_hovered(tag, *args, **kwargs) return is_hovered + + +def dpg_window_origin( + window: Sender, + anchor: Sender, +) -> Tuple[float, float]: + """Where a child window's corner was drawn, in the coordinates a pointer is reported in. + + A child window states where it sits within the container around it, while a widget states + where it was drawn on screen. A widget laid out in that same container therefore carries the + container's own origin, and the two readings of it together place the window on screen. + + Args: + window: The child window whose corner is asked for. + anchor: A widget laid out in the same container as the window. + + Returns: + Tuple[float, float]: The window's left and top edges on screen. + """ + anchor_left, anchor_top = dpg.get_item_rect_min(anchor) + placed_left, placed_top = dpg.get_item_pos(anchor) + window_left, window_top = dpg.get_item_pos(window) + return ( + float(anchor_left - placed_left + window_left), + float(anchor_top - placed_top + window_top), + ) + + +def dpg_pointer_within_window( + window: Sender, + anchor: Sender, +) -> bool: + """Whether the pointer stands within a child window, measured against a widget beside it. + + A scrolling table carries a window of its own, which takes the hover from the child window + holding it, so the rectangle answers where a hover state stays silent. + + Args: + window: The child window the pointer is measured against. + anchor: A widget laid out in the same container as the window. + + Returns: + bool: Whether the pointer stands within the window, which is False while either item + is yet to be built. + """ + if not dpg.does_item_exist(window) or not dpg.does_item_exist(anchor): + return False + + left, top = dpg_window_origin(window, anchor) + width, height = dpg.get_item_rect_size(window) + pointer_left, pointer_top = dpg.get_mouse_pos(local=False) + within_width: bool = left <= pointer_left <= left + width + within_height: bool = top <= pointer_top <= top + height + return within_width and within_height diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 05bc49874..76aba7286 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -599,6 +599,7 @@ sequencer.order.tooltip.label_master: "Click to mute every channel, or to bring # ============================================================================= sequencer.voices.label.voices_text: "Voices" sequencer.voices.label.new_shape: "New shape" +sequencer.voices.label.add_sample: "Add sample from file..." sequencer.voices.label.column_kind: "Kind" sequencer.voices.label.column_id: "ID" sequencer.voices.label.column_name: "Name" @@ -612,8 +613,9 @@ sequencer.voices.label.context_move_down: "Move down" sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" sequencer.voices.tooltip.new_shape: "Add a hand-written voice, playable on any channel" -sequencer.voices.tooltip.kind_sample: "A converted recording" -sequencer.voices.tooltip.kind_shape: "Written by hand" +sequencer.voices.tooltip.kind_sample: "Sample" +sequencer.voices.tooltip.kind_shape: "Shape" +sequencer.voices.title.add_sample_dialog: "Add sample" sequencer.voices.template.shape_name: "Shape {position}" sequencer.history.label.history_text: "History" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index d796bbeee..01a3eb846 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -17,6 +17,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.utils.display import display_voice_label +from sampletones_shared.types.callback import VoidCallback from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( @@ -44,6 +45,8 @@ } ) +RIGHT_BUTTON = 1 + EDIT_ITEM = 0 RENAME_ITEM = 1 DUPLICATE_ITEM = 2 @@ -73,6 +76,7 @@ class Requests: duplicated: List[str] = field(default_factory=list) removed: List[str] = field(default_factory=list) moved: List[Tuple[str, Optional[int]]] = field(default_factory=list) + pool: List[str] = field(default_factory=list) class _MenuRecorder: @@ -125,6 +129,7 @@ def _panel( panel._selected_voice_id = None if selected_row is None else SELECTED_ID panel._selected_row = selected_row panel._editing_voice_id = editing + panel._list_menu_pending = False panel._tab_active = lambda: tab_active panel._router = _Router(field_focused=field_focused) panel._detail_color = DETAIL_COLOR @@ -138,6 +143,8 @@ def _panel( panel.on_duplicate_requested = requests.duplicated.append panel.on_remove_requested = requests.removed.append panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target)) + panel.on_new_shape_requested = lambda: requests.pool.append(SequencerVoicesElements.NEW_SHAPE.value) + panel.on_add_sample_requested = lambda: requests.pool.append(SequencerVoicesElements.ADD_SAMPLE.value) monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) return VoicesPanelFixture(panel=panel, requests=requests) @@ -196,6 +203,17 @@ def _null_menu() -> Iterator[None]: yield +def _deferred_calls(monkeypatch: pytest.MonkeyPatch) -> List[VoidCallback]: + """The callbacks handed to the next frame, which is where the list's menu waits.""" + deferred: List[VoidCallback] = [] + monkeypatch.setattr( + voices_module.FrameCallbackManager, + "set_frame_callback", + lambda callback: deferred.append(callback), + ) + return deferred + + @pytest.fixture def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: """Records a whole context-menu build, with the DearPyGui calls behind it stood down.""" @@ -388,3 +406,101 @@ def test_a_panel_holding_no_selection_builds_nothing( _panel(monkeypatch, selected_row=None).panel.build_edit_actions() assert recorder.items == [] + + +class TestThePoolItems: + """Every door onto the list offers the ways a voice comes in, so adding one is never hidden.""" + + def test_the_list_menu_prints_the_ways_a_voice_comes_in( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + fixture = _panel(monkeypatch) + fixture.panel._list_menu_pending = True + + fixture.panel._show_list_menu() + + assert [widget.text for widget in build_recorder.widgets] == [ + SequencerVoicesElements.NEW_SHAPE.value, + SequencerVoicesElements.ADD_SAMPLE.value, + ] + + def test_a_row_menu_carries_the_pool_section_below_the_voice_actions( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """A row is where a reader already is, so the list's own offers stay within reach there.""" + _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) + + items = [widget.text for widget in build_recorder.widgets if widget.kind == "item"] + + assert items[-2:] == [ + SequencerVoicesElements.NEW_SHAPE.value, + SequencerVoicesElements.ADD_SAMPLE.value, + ] + assert SequencerVoicesElements.CONTEXT_EDIT.value in items + + def test_the_items_ask_for_a_written_voice_and_for_a_located_one( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + fixture = _panel(monkeypatch) + fixture.panel.add_pool_items() + + for item in recorder.items: + item.callback() + + assert fixture.requests.pool == [ + SequencerVoicesElements.NEW_SHAPE.value, + SequencerVoicesElements.ADD_SAMPLE.value, + ] + + +class TestWhichDoorAnswersAPress: + """The list and the row are offered the same press, the list first, so one of them answers it.""" + + def test_a_press_on_the_list_holds_its_menu_for_a_frame( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + fixture = _panel(monkeypatch) + deferred = _deferred_calls(monkeypatch) + monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: True) + + fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON) + + assert fixture.panel._list_menu_pending + assert deferred == [fixture.panel._show_list_menu] + + def test_a_row_claiming_the_press_leaves_the_list_menu_unbuilt( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + fixture = _panel(monkeypatch) + _deferred_calls(monkeypatch) + monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: True) + monkeypatch.setattr(fixture.panel, "_show_context_menu", lambda _position, _voice_id: None) + monkeypatch.setattr(voices_module.dpg, "get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID)) + + fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON) + fixture.panel._on_sample_clicked(0, (RIGHT_BUTTON, 0)) + fixture.panel._show_list_menu() + + assert build_recorder.widgets == [] + + def test_a_press_beyond_the_list_asks_for_no_menu( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + fixture = _panel(monkeypatch) + deferred = _deferred_calls(monkeypatch) + monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: False) + + fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON) + + assert not fixture.panel._list_menu_pending + assert deferred == [] From c89c0222e6c7608cf100d33c888ec409abc6376f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 14:54:33 +0200 Subject: [PATCH 083/142] Restructurized: instrument and project entities --- docs/development/compatibility.md | 20 +++ docs/development/guidelines.md | 1 + docs/formats/projects.md | 12 +- src/sampletones_application/application.py | 16 +-- .../categories/elements/sequencer.py | 4 +- .../constants/instruments.py | 2 +- .../coordinators/reconstruction.py | 8 +- .../coordinators/tabs/reconstruction.py | 24 ++-- .../coordinators/tabs/sequencer.py | 14 +- .../layout/glyphs/voice.py | 2 +- .../logic/history/action.py | 4 +- .../logic/history/fingerprint.py | 4 +- .../logic/project/controller.py | 38 ++--- .../logic/reconstruction/edit.py | 4 +- .../logic/reconstruction/editing.py | 24 ++-- .../logic/reconstruction/editor.py | 80 +++++------ .../logic/reconstruction/instruments.py | 76 +++++----- .../logic/sequencer/history_detail.py | 2 +- .../logic/sequencer/tracker/tracker.py | 8 +- .../logic/sequencer/voices.py | 25 ++-- .../tags/reconstructions.py | 4 +- src/sampletones_application/tags/sequencer.py | 4 +- .../reconstruction/instruments/instruments.py | 106 +++++++------- .../ui/panels/sequencer/voices.py | 34 ++--- .../view_model/reconstruction/instruments.py | 22 +-- .../view_model/sequencer/kind.py | 6 +- .../view_model/sequencer/voices.py | 4 +- .../view_model/shared/footprint.py | 10 +- src/sampletones_config/lang/en.yaml | 14 +- src/sampletones_config/layout/glyphs.yaml | 2 +- .../compatibility/project/__init__.py | 3 +- .../compatibility/project/v1_1.py | 94 ++++++------ .../compatibility/project/v1_2.py | 92 ------------ src/sampletones_core/exporters/slices.py | 12 +- .../formats/famitracker/builder.py | 4 +- src/sampletones_core/performance/voice.py | 6 +- src/sampletones_core/project/container.py | 8 +- .../project/voices/__init__.py | 12 +- .../project/voices/creation.py | 16 +-- .../project/voices/envelopes.py | 16 +-- .../voices/{shape.py => instrument.py} | 56 ++++---- .../project/voices/note_off.py | 2 +- src/sampletones_core/project/voices/record.py | 6 +- src/sampletones_core/project/voices/voice.py | 12 +- src/sampletones_shared/application.py | 2 +- tests/suite/performance.py | 10 +- .../logic/project/test_controller.py | 70 ++++----- .../logic/reconstruction/test_editor.py | 66 ++++----- .../logic/reconstruction/test_instruments.py | 76 +++++----- .../logic/sequencer/test_voices.py | 36 ++--- .../sequencer/tracker/test_pitch_faces.py | 54 +++---- .../sequencer/tracker/test_write_note.py | 26 ++-- .../ui/panels/sequencer/test_voices_menu.py | 8 +- .../compatibility/project/test_v1_1.py | 41 ++++-- .../compatibility/project/test_v1_2.py | 47 ------ .../sampletones_core/exporters/test_slices.py | 46 +++--- ...ocument.py => test_instrument_document.py} | 22 +-- ...pe_module.py => test_instrument_module.py} | 62 ++++---- ..._shape_walk.py => test_instrument_walk.py} | 56 ++++---- .../project/test_container.py | 135 ++++++++++++++---- .../project/voices/test_creation.py | 40 +++--- .../{test_shape.py => test_instrument.py} | 106 +++++++------- ..._shape_song.py => test_instrument_song.py} | 20 +-- 63 files changed, 908 insertions(+), 928 deletions(-) delete mode 100644 src/sampletones_core/compatibility/project/v1_2.py rename src/sampletones_core/project/voices/{shape.py => instrument.py} (76%) delete mode 100644 tests/unit/sampletones_core/compatibility/project/test_v1_2.py rename tests/unit/sampletones_core/formats/bitphase/{test_shape_document.py => test_instrument_document.py} (80%) rename tests/unit/sampletones_core/formats/famitracker/{test_shape_module.py => test_instrument_module.py} (62%) rename tests/unit/sampletones_core/performance/{test_shape_walk.py => test_instrument_walk.py} (66%) rename tests/unit/sampletones_core/project/voices/{test_shape.py => test_instrument.py} (55%) rename tests/unit/sampletones_player/{test_shape_song.py => test_instrument_song.py} (64%) diff --git a/docs/development/compatibility.md b/docs/development/compatibility.md index c16c1dde7..fca02ffdc 100644 --- a/docs/development/compatibility.md +++ b/docs/development/compatibility.md @@ -40,6 +40,16 @@ as one path per stem, and synthesizes the stems record every 2.2 file carries one stem covering every enabled channel and holding every frame the file plays, which is what the conversion that wrote the file did. +### A version belongs to a release + +The version a format writes moves once per release. Between releases that +version is still being written: every file carrying it was written by a working +tree, so a further change to the stored shape extends the step already pending +rather than adding a second one, and that step widens to carry the whole +distance from the version the last release shipped. What a user's files travel +is therefore one step per release, and `git show :src/sampletones_shared/application.py` +names the version their files stand at. + ### A chain applies whole or not at all An upgrade runs only when the registered steps form a complete path from the @@ -96,6 +106,11 @@ always did. ### Adding an upgrade +Read the format's version constant against the one the last release shipped, and +take whichever route that comparison names. + +**The constant stands where the release left it.** The change opens a new step: + 1. Bump the format's version constant in `sampletones_shared/application.py`. 2. Add the step module named after the new version — e.g. `compatibility/reconstruction/v2_2.py` — with a transform that takes the @@ -105,6 +120,11 @@ always did. `tests/unit/sampletones_core/compatibility/`, and with a loader test that opens a payload written at the previous version. +**The constant already stands ahead of the release.** The pending step is the +one to widen: fold the new transform into the module named after that version, +state the whole step from the shipped version in its docstring, and extend its +tests to cover what was added. The version constant stays where it is. + The engine stamps the new version once the chain runs, so a step module declares only its own transform. diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index d6e86790a..50b18b092 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -19,6 +19,7 @@ These rules govern the Python in this repository. They complement 1. Prefer `pathlib.Path` over `os.path`. 1. Separate function options with `*`, and choose positional arguments intentionally. 1. Change internal APIs, configs, and data shapes freely; preserve backward compatibility only when the user explicitly asks. +1. Move a stored data version once per release. The version a build writes between releases is still being written, so a further change to that format extends the upgrade step already pending — one step carries the whole distance from the version the last release shipped. See `compatibility.md`. 1. Run `pre-commit` on new files after each change. ## Ownership diff --git a/docs/formats/projects.md b/docs/formats/projects.md index 8d513b299..ff9bec8f1 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -17,7 +17,7 @@ A `.stp` file is a zip archive with two kinds of member: Keeping the reconstructions in separate members lets `project.json` stay small while the larger audio data travels alongside it in the same archive. A -[shape](../glossary.md#shape) carries no audio, so the document holds it whole. +[instrument](../glossary.md#instrument) carries no audio, so the document holds it whole. ### `project.json` @@ -39,7 +39,7 @@ else it carries: | `kind` | Contents | | --- | --- | | `sample` | the `reconstruction_id` of its audio member | -| `shape` | its `envelopes` — the `volume`, `arpeggio` and `duty_cycle` values it writes, each a list of one item per tick — and the `root_pitch` and `root_period` those values are measured against | +| `instrument` | its `envelopes` — the `volume`, `arpeggio` and `duty_cycle` values it writes, each a list of one item per tick — and the `root_pitch` and `root_period` those values are measured against | ### `song` @@ -70,7 +70,7 @@ deserialization (see [Data compatibility](../development/compatibility.md)). Unknown or extra fields within a matching version are ignored, which leaves room for the format to grow. -The current format version is 1.2. Version 1.2 gathers `samples` into `voices`, -each record stating its `kind`, and names a row's note command by `voice_id` alone. -Version 1.1 named each channel pool by `name` and a row command's channel by -`channel_name`. +The current format version is 1.1. It gathers the pool under `voices`, each record +stating its `kind`; names each channel pool by `name`; and names a row's note +command by `voice_id` alone. Version 1.0 held the pool under `samples`, named a +channel pool `generator`, and named a note command's channel beside its sample. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 83cbdf156..688c97bc5 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -51,7 +51,7 @@ ) from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.edit import ( - InstrumentEdit, + ChannelEdit, ReconstructionEdit, StemRemoval, ) @@ -157,8 +157,8 @@ from sampletones_core.exports.backend import ExportBackend from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.stage import ExportStage +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -1029,19 +1029,19 @@ def _navigate_to_reconstructions(self) -> None: def _edit_project_voice(self, voice_id: str) -> None: """Opens the voice list's selection on the Reconstructions tab, in the terms of its kind. - A sample opens as the reconstruction behind it, waveform and stems and all; a shape stands + A sample opens as the reconstruction behind it, waveform and stems and all; an instrument stands on no recording, so the tab shows its envelopes alone. Either kind brings that tab to the front, so the voice a reader asked to edit is the one in view. """ match self.project_manager.current.voice(voice_id): case Sample() as sample: - self._reconstructions_tab.release_shape() + self._reconstructions_tab.release_instrument() self.reconstruction_manager.load_reconstruction_object( sample.reconstruction, name=sample.name, ) - case Shape(): - self._reconstructions_tab.edit_shape(voice_id) + case Instrument(): + self._reconstructions_tab.edit_instrument(voice_id) self._navigate_to_reconstructions() case _: logger.warning(f"Cannot edit unknown project voice: {voice_id}") @@ -1111,7 +1111,7 @@ def _on_reconstruction_updated( def _edit_detail(self, voice_id: str, edit: ReconstructionEdit) -> HistoryDetail: """The history line an edit reads as: the feature it moved, or the recording it took out.""" match edit: - case InstrumentEdit(): + case ChannelEdit(): return self._sequencer_tab.reconstruction_edit_detail( voice_id, edit.channel_name, @@ -1453,7 +1453,7 @@ def _persist_application_state(self) -> None: def _save_browser_shapes(self) -> None: """Asks every tab holding a tree to write down which of its rows stand open. - The shape belongs to the browser showing it, and it is read the once here rather than followed + The instrument belongs to the browser showing it, and it is read the once here rather than followed row by row, a pass over the rows running on the tree worker. """ self._main_tab.save_browser_shape() diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 0abb4eda9..d765a2813 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -87,10 +87,10 @@ class SequencerOrderElements(AbstractElement): class SequencerVoicesElements(AbstractElement): VOICES_TEXT = "voices_text" - NEW_SHAPE = "new_shape" + NEW_INSTRUMENT = "new_instrument" ADD_SAMPLE = "add_sample" KIND_SAMPLE = "kind_sample" - KIND_SHAPE = "kind_shape" + KIND_INSTRUMENT = "kind_instrument" COLUMN_KIND = "column_kind" COLUMN_ID = "column_id" COLUMN_NAME = "column_name" diff --git a/src/sampletones_application/constants/instruments.py b/src/sampletones_application/constants/instruments.py index 4b9e3b7f6..ed537443d 100644 --- a/src/sampletones_application/constants/instruments.py +++ b/src/sampletones_application/constants/instruments.py @@ -2,4 +2,4 @@ from sampletones_core.constants.enums import ChannelName -SHAPE_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 23bd31998..62a8813dc 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -8,7 +8,7 @@ ReconstructionTabCoordinator, ) from sampletones_application.logic.reconstruction.edit import ( - InstrumentEdit, + ChannelEdit, ReconstructionEdit, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager @@ -329,7 +329,7 @@ def apply_edit(self, edit: ReconstructionEdit) -> None: def _on_regeneration_result(self, result: RegenerationResult) -> None: match result: case ServiceSuccess(value=outcome): - self.apply_edit(self._instrument_edit(outcome)) + self.apply_edit(self._channel_edit(outcome)) case ServiceError(exception=exception): logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) @@ -339,9 +339,9 @@ def _on_regeneration_result(self, result: RegenerationResult) -> None: self._set_reconstruction_dimmed(self._regeneration_service.is_running()) @staticmethod - def _instrument_edit(outcome: RegeneratedInstrument) -> InstrumentEdit: + def _channel_edit(outcome: RegeneratedInstrument) -> ChannelEdit: """Reads a regeneration result as the edit the project history records.""" - return InstrumentEdit( + return ChannelEdit( reconstruction=outcome.reconstruction, channel_name=outcome.channel_name, feature_key=outcome.feature_key, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index e4151d099..10f142d15 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -299,11 +299,11 @@ def __init__( self._reconstruction_instruments_panel.on_raw_data_changed = ( self._reconstruction_instruments_logic.handle_raw_data_changed ) - self._reconstruction_instruments_panel.on_shape_root_period_changed = ( - self._reconstruction_instruments_logic.handle_shape_root_period_changed + self._reconstruction_instruments_panel.on_instrument_root_period_changed = ( + self._reconstruction_instruments_logic.handle_instrument_root_period_changed ) - self._reconstruction_instruments_panel.on_shape_loop_point_changed = ( - self._reconstruction_instruments_logic.handle_shape_loop_point_changed + self._reconstruction_instruments_panel.on_instrument_loop_point_changed = ( + self._reconstruction_instruments_logic.handle_instrument_loop_point_changed ) def _on_export_result(self, result: ExportResult) -> None: @@ -639,22 +639,22 @@ def repaint_browser_favorites( self._browser_panel.update_favorite_indicators(nodes) def display_reconstruction(self) -> None: - self._instrument_editor.release_shape() + self._instrument_editor.release_instrument() self._reconstruction_panel_logic.display_reconstruction() self._reconstruction_instruments_logic.update_display() - def edit_shape(self, voice_id: str) -> None: - """Puts a shape in front of the tab, closing whatever reconstruction it held. + def edit_instrument(self, voice_id: str) -> None: + """Puts an instrument in front of the tab, closing whatever reconstruction it held. - The tab describes one voice at a time — a shape stands on no recording, so the waveform, + The tab describes one voice at a time — an instrument stands on no recording, so the waveform, the plot and the stems beside the instruments panel have nothing of it to draw. """ - self._instrument_editor.edit_shape(voice_id) + self._instrument_editor.edit_instrument(voice_id) self._reconstruction_instruments_logic.update_display() - def release_shape(self) -> None: - """Lets go of the shape the tab held, which is what opening a reconstruction does.""" - self._instrument_editor.release_shape() + def release_instrument(self) -> None: + """Lets go of the instrument the tab held, which is what opening a reconstruction does.""" + self._instrument_editor.release_instrument() def close_reconstruction(self) -> None: self._reconstruction_panel_logic.close_reconstruction() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index f4e8b7f07..c68f87b89 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -641,24 +641,24 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_voices_logic.duplicate_voice, detail=self._history_detail.duplicate_voice, ) - self._sequencer_voices_panel.on_new_shape_requested = self._add_shape + self._sequencer_voices_panel.on_new_instrument_requested = self._add_instrument self._sequencer_voices_panel.on_add_sample_requested = self._add_sample_from_file - def _add_shape(self) -> None: + def _add_instrument(self) -> None: """Appends a hand-written voice, named for the position it takes in the list. - A shape arrives sustaining at full volume, so it plays as soon as it is placed and the + An instrument arrives sustaining at full volume, so it plays as soon as it is placed and the envelopes stay the reader's to write; naming it by its position gives the list a readable entry until they rename it. """ - name = self._language_manager["sequencer.voices.template.shape_name"].format( + name = self._language_manager["sequencer.voices.template.instrument_name"].format( position=display_id(self._project_controller.voice_count), ) with self._history.transaction( - HistoryAction.ADD_SHAPE, - detail=self._history_detail.add_shape(name), + HistoryAction.ADD_INSTRUMENT, + detail=self._history_detail.add_instrument(name), ): - self._sequencer_voices_logic.add_shape(name) + self._sequencer_voices_logic.add_instrument(name) def _add_sample_from_file(self) -> None: """Brings a reconstruction saved anywhere on disk into the pool as a sample. diff --git a/src/sampletones_application/layout/glyphs/voice.py b/src/sampletones_application/layout/glyphs/voice.py index d6ecfd2e7..c60dbd32b 100644 --- a/src/sampletones_application/layout/glyphs/voice.py +++ b/src/sampletones_application/layout/glyphs/voice.py @@ -3,4 +3,4 @@ class VoiceGlyphs(BaseModel, extra="forbid", frozen=True): sample: str - shape: str + instrument: str diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index d19ef7af7..4ad90b0e6 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -32,8 +32,8 @@ class HistoryAction(AbstractElement): MOVE_SAMPLE = "move_sample" DUPLICATE_SAMPLE = "duplicate_sample" SET_SAMPLE_LOOP = "set_sample_loop" - ADD_SHAPE = "add_shape" - EDIT_SHAPE = "edit_shape" + ADD_INSTRUMENT = "add_instrument" + EDIT_INSTRUMENT = "edit_instrument" SET_TEMPO = "set_tempo" SET_SPEED = "set_speed" SET_NES_FREQUENCY = "set_nes_frequency" diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index 7327017b5..b1a12c2b5 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -2,8 +2,8 @@ from typing import Callable, Dict, Iterable, List, Tuple from sampletones_core.project import Project +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction @@ -35,7 +35,7 @@ def fingerprint_project( match voice: case Sample(): parts.append(reconstruction_hash(voice.reconstruction)) - case Shape(): + case Instrument(): parts.append(voice.model_dump_json()) combined = "|".join(parts) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 24261427a..e629238ed 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -8,8 +8,8 @@ from sampletones_core.project import Project from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_shared.types.callback import VoidCallback @@ -202,60 +202,60 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: self._announce(self.on_voices_changed) return sample - def add_shape(self, shape: Shape) -> Shape: + def add_instrument(self, instrument: Instrument) -> Instrument: """Appends a hand-written voice, which the voice list holds and the tracker can name. - A shape is its own record, so whoever made it — a reader asking for a new one, an + An instrument is its own record, so whoever made it — a reader asking for a new one, an instrument file read from disk, a sample's channel frozen into envelopes — hands the whole voice over and the pool takes it as it stands. """ - self.project.voices.append(shape) + self.project.voices.append(instrument) self._touch() self._announce(self.on_voices_changed) - return shape + return instrument - def set_shape_envelope( + def set_instrument_envelope( self, voice_id: str, feature_key: FeatureKey, items: Tuple[int, ...], ) -> None: - """Writes one dimension of a shape's envelopes, emptying it to leave it to the channel. + """Writes one dimension of an instrument's envelopes, emptying it to leave it to the channel. Raises: TypeError: If ``voice_id`` names a voice that writes no envelopes of its own. """ - shape = self._shape(voice_id) - shape.envelopes = shape.envelopes.with_envelope(feature_key, items) - shape.invalidate() + instrument = self._instrument(voice_id) + instrument.envelopes = instrument.envelopes.with_envelope(feature_key, items) + instrument.invalidate() self._touch() self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def set_shape_root( + def set_instrument_root( self, voice_id: str, *, pitch: int, period: int, ) -> None: - """Moves the roots a shape's arpeggio is measured against, on the tonal channels and on noise. + """Moves the roots an instrument's arpeggio is measured against, on the tonal channels and on noise. Raises: TypeError: If ``voice_id`` names a voice that states no root of its own. """ - shape = self._shape(voice_id) - shape.root_pitch = pitch - shape.root_period = period - shape.invalidate() + instrument = self._instrument(voice_id) + instrument.root_pitch = pitch + instrument.root_period = period + instrument.invalidate() self._touch() self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def _shape(self, voice_id: str) -> Shape: + def _instrument(self, voice_id: str) -> Instrument: voice = self.project.voices[voice_id] - if not isinstance(voice, Shape): - raise TypeError(f"Voice '{voice_id}' is no shape") + if not isinstance(voice, Instrument): + raise TypeError(f"Voice '{voice_id}' is no instrument") return voice diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py index 41c20a1fd..802762777 100644 --- a/src/sampletones_application/logic/reconstruction/edit.py +++ b/src/sampletones_application/logic/reconstruction/edit.py @@ -7,7 +7,7 @@ @dataclass(frozen=True) -class InstrumentEdit: +class ChannelEdit: """A regenerated instrument paired with the channel and feature the reader moved. Carrying the request context alongside the fresh reconstruction lets the project history @@ -35,4 +35,4 @@ def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]: return None -ReconstructionEdit: TypeAlias = Union[InstrumentEdit, StemRemoval] +ReconstructionEdit: TypeAlias = Union[ChannelEdit, StemRemoval] diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py index 74433372a..f31c8d628 100644 --- a/src/sampletones_application/logic/reconstruction/editing.py +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -14,13 +14,13 @@ class ReconstructionEdit: @dataclass(frozen=True) -class ShapeEdit: - """The one envelope set a shape carries, with the roots and the loop point it states. +class InstrumentEdit: + """The one envelope set an instrument carries, with the roots and the loop point it states. Attributes: - voice_id: The shape an edit is written back into. + voice_id: The instrument an edit is written back into. name: The name the panel titles it by. - features: The envelopes, read as the channel offering every dimension a shape writes. + features: The envelopes, read as the channel offering every dimension an instrument writes. root_pitch: The note the tonal channels measure the arpeggio against. root_period: The period the noise channel measures the arpeggio against. loop_point: The tick the envelopes repeat from, or ``None`` where they play once. @@ -34,26 +34,26 @@ class ShapeEdit: loop_point: Optional[int] -EditedInstrument = Union[ReconstructionEdit, ShapeEdit] +EditedVoice = Union[ReconstructionEdit, InstrumentEdit] class InstrumentEditingProtocol(Protocol): - """Where the instruments panel's envelopes come from, and where an edit to a shape goes. + """Where the instruments panel's envelopes come from, and where an edit to an instrument goes. - The panel edits one voice at a time — the channels of a loaded reconstruction, or a shape's + The panel edits one voice at a time — the channels of a loaded reconstruction, or an instrument's own set — so it asks what is in front of it and renders whichever answer comes back. A - reconstruction's envelopes travel back out through the regeneration service; a shape stands on + reconstruction's envelopes travel back out through the regeneration service; an instrument stands on no audio, so its edits are written here. """ - def edited_instrument(self) -> Optional[EditedInstrument]: + def edited_instrument(self) -> Optional[EditedVoice]: """What the panel is editing, or ``None`` while it holds nothing.""" def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: - """Writes one dimension of the shape in front of the panel.""" + """Writes one dimension of the instrument in front of the panel.""" def write_roots(self, *, pitch: int, period: int) -> None: - """Moves the roots the shape in front of the panel is measured against.""" + """Moves the roots the instrument in front of the panel is measured against.""" def write_loop_point(self, loop_point: Optional[int]) -> None: - """Sets the tick the shape in front of the panel repeats from.""" + """Sets the tick the instrument in front of the panel repeats from.""" diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index 43d1fbad3..94f38d0f9 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -2,13 +2,13 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.editing import ( - EditedInstrument, + EditedVoice, + InstrumentEdit, ReconstructionEdit, - ShapeEdit, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.constants.enums import FeatureKey -from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.types.feature import FeatureValue @@ -16,7 +16,7 @@ class InstrumentEditor: """Which voice the Reconstructions tab has in front of it, and where an edit to it goes. The tab shows one voice at a time: a reconstruction, whose waveform and stems the rest of the - tab draws, or a shape, which has none of those and is its envelopes alone. Opening one puts + tab draws, or an instrument, which has none of those and is its envelopes alone. Opening one puts the other away, so the cards beside the instruments panel always describe what it is editing. """ @@ -29,79 +29,79 @@ def __init__( self._controller = project_controller self._voice_id: Optional[str] = None - def edit_shape(self, voice_id: str) -> None: - """Puts a shape in front of the tab, closing whatever reconstruction it held.""" + def edit_instrument(self, voice_id: str) -> None: + """Puts an instrument in front of the tab, closing whatever reconstruction it held.""" self._voice_id = voice_id self._reconstruction_manager.close_reconstruction() - def release_shape(self) -> None: - """Lets go of the shape, which is what opening a reconstruction does.""" + def release_instrument(self) -> None: + """Lets go of the instrument, which is what opening a reconstruction does.""" self._voice_id = None @property - def shape(self) -> Optional[Shape]: - """The shape in front of the tab, or ``None`` where it holds a reconstruction or nothing.""" + def instrument(self) -> Optional[Instrument]: + """The instrument in front of the tab, or ``None`` where it holds a reconstruction or nothing.""" if self._voice_id is None: return None voice = self._controller.project.voices.get(self._voice_id) - return voice if isinstance(voice, Shape) else None + return voice if isinstance(voice, Instrument) else None - def edited_instrument(self) -> Optional[EditedInstrument]: + def edited_instrument(self) -> Optional[EditedVoice]: """What the instruments panel is editing, or ``None`` while it holds nothing.""" - shape = self.shape - if shape is not None: - return ShapeEdit( - voice_id=shape.id, - name=shape.name, - features=shape.instrument_features(), - root_pitch=shape.root_pitch, - root_period=shape.root_period, - loop_point=shape.loop_point, + instrument = self.instrument + if instrument is not None: + return InstrumentEdit( + voice_id=instrument.id, + name=instrument.name, + features=instrument.instrument_features(), + root_pitch=instrument.root_pitch, + root_period=instrument.root_period, + loop_point=instrument.loop_point, ) feature_data = self._reconstruction_manager.current_features return None if feature_data is None else ReconstructionEdit(channels=feature_data.channels) def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: - """Writes one dimension of the shape in front of the tab. + """Writes one dimension of the instrument in front of the tab. Raises: - TypeError: If the tab holds no shape to write into. + TypeError: If the tab holds no instrument to write into. """ - shape = self.shape - if shape is None: - raise TypeError("The tab holds no shape to write an envelope into") + instrument = self.instrument + if instrument is None: + raise TypeError("The tab holds no instrument to write an envelope into") - self._controller.set_shape_envelope(shape.id, feature_key, _items(data)) + self._controller.set_instrument_envelope(instrument.id, feature_key, _items(data)) def write_roots(self, *, pitch: int, period: int) -> None: - """Moves the roots the shape in front of the tab is measured against. + """Moves the roots the instrument in front of the tab is measured against. Raises: - TypeError: If the tab holds no shape to write into. + TypeError: If the tab holds no instrument to write into. """ - shape = self.shape - if shape is None: - raise TypeError("The tab holds no shape to move the roots of") + instrument = self.instrument + if instrument is None: + raise TypeError("The tab holds no instrument to move the roots of") - self._controller.set_shape_root(shape.id, pitch=pitch, period=period) + self._controller.set_instrument_root(instrument.id, pitch=pitch, period=period) def write_loop_point(self, loop_point: Optional[int]) -> None: - """Sets the tick the shape in front of the tab repeats from. + """Sets the tick the instrument in front of the tab repeats from. Raises: - TypeError: If the tab holds no shape to write into. + TypeError: If the tab holds no instrument to write into. """ - shape = self.shape - if shape is None: - raise TypeError("The tab holds no shape to set a loop point on") + instrument = self.instrument + if instrument is None: + raise TypeError("The tab holds no instrument to set a loop point on") - self._controller.set_voice_loop_point(shape.id, loop_point) + self._controller.set_voice_loop_point(instrument.id, loop_point) def _items(data: FeatureValue) -> Tuple[int, ...]: - """The items an envelope edit carries, as the plain tuple a shape stores.""" + """The items an envelope edit carries, as the plain tuple an instrument stores.""" if isinstance(data, int): return (data,) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 0b1b48015..37ca555d0 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -2,19 +2,19 @@ import numpy as np -from sampletones_application.constants.instruments import SHAPE_CHANNEL +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) from sampletones_application.logic.reconstruction.editing import ( + InstrumentEdit, InstrumentEditingProtocol, ReconstructionEdit, - ShapeEdit, ) from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentViewModel, ReconstructionInstrumentsViewModel, - ShapeInstrumentViewModel, ) from sampletones_application.view_model.reconstruction.update import ( ReconstructionUpdate, @@ -54,14 +54,14 @@ def update_display(self) -> None: self.call(self.on_feature_data_changed, self._displayed_features()) def _displayed_features(self) -> Optional[Dict[ChannelName, Features]]: - """The envelopes the panel draws: a reconstruction's channels, or a shape's own set. + """The envelopes the panel draws: a reconstruction's channels, or an instrument's own set. - A shape is drawn on the tab the panel shows it under, which is the channel offering every - dimension a shape writes. + An instrument is drawn on the tab the panel shows it under, which is the channel offering every + dimension an instrument writes. """ - shape = self.shape_edit - if shape is not None: - return {SHAPE_CHANNEL: shape.features} + instrument = self.instrument_edit + if instrument is not None: + return {INSTRUMENT_CHANNEL: instrument.features} return self._current_generators() @@ -83,10 +83,10 @@ def _current_generators(self) -> Optional[Dict[ChannelName, Features]]: return None @property - def shape_edit(self) -> Optional[ShapeEdit]: - """The shape in front of the panel, where one is.""" + def instrument_edit(self) -> Optional[InstrumentEdit]: + """The instrument in front of the panel, where one is.""" match self._editor.edited_instrument(): - case ShapeEdit() as edit: + case InstrumentEdit() as edit: return edit case _: return None @@ -95,19 +95,19 @@ def _build_view_model( self, channels: Optional[Dict[ChannelName, Features]], ) -> ReconstructionInstrumentsViewModel: - shape = self.shape_edit - if shape is not None: + instrument = self.instrument_edit + if instrument is not None: return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - playing_channels=frozenset({SHAPE_CHANNEL}), + playing_channels=frozenset({INSTRUMENT_CHANNEL}), footprint=SampleFootprintViewModel.from_instrument( - features_footprint(shape.features, loop_point=shape.loop_point) + features_footprint(instrument.features, loop_point=instrument.loop_point) ), - shape=ShapeInstrumentViewModel( - name=shape.name, - root_pitch=shape.root_pitch, - root_period=shape.root_period, - loop_point=shape.loop_point, + instrument=InstrumentViewModel( + name=instrument.name, + root_pitch=instrument.root_pitch, + root_period=instrument.root_period, + loop_point=instrument.loop_point, ), ) @@ -151,9 +151,9 @@ def handle_pitch_value_changed( channel_name: ChannelName, value: int, ) -> None: - shape = self.shape_edit - if shape is not None: - self._editor.write_roots(pitch=value, period=shape.root_period) + instrument = self.instrument_edit + if instrument is not None: + self._editor.write_roots(pitch=value, period=instrument.root_period) self.update_display() return @@ -171,7 +171,7 @@ def handle_bar_point_clicked( feature_key: FeatureKey, data: np.ndarray, ) -> None: - if self._write_shape_envelope(feature_key, data): + if self._write_instrument_envelope(feature_key, data): return self._report_edited_size(channel_name, feature_key, data) @@ -189,7 +189,7 @@ def handle_raw_data_changed( feature_key: FeatureKey, data: np.ndarray, ) -> None: - if self._write_shape_envelope(feature_key, data): + if self._write_instrument_envelope(feature_key, data): return self._report_edited_size(channel_name, feature_key, data) @@ -201,34 +201,34 @@ def handle_raw_data_changed( ) ) - def handle_shape_root_period_changed(self, value: int) -> None: - """Moves the period the shape in front of the panel rests at on the noise channel.""" - shape = self.shape_edit - if shape is None: + def handle_instrument_root_period_changed(self, value: int) -> None: + """Moves the period the instrument in front of the panel rests at on the noise channel.""" + instrument = self.instrument_edit + if instrument is None: return - self._editor.write_roots(pitch=shape.root_pitch, period=value) + self._editor.write_roots(pitch=instrument.root_pitch, period=value) self.update_display() - def handle_shape_loop_point_changed(self, loop_point: Optional[int]) -> None: - """Sets the tick the shape in front of the panel repeats from.""" - if self.shape_edit is None: + def handle_instrument_loop_point_changed(self, loop_point: Optional[int]) -> None: + """Sets the tick the instrument in front of the panel repeats from.""" + if self.instrument_edit is None: return self._editor.write_loop_point(loop_point) self.update_display() - def _write_shape_envelope( + def _write_instrument_envelope( self, feature_key: FeatureKey, data: np.ndarray, ) -> bool: - """Writes one dimension of the shape in front of the panel, reporting whether it did. + """Writes one dimension of the instrument in front of the panel, reporting whether it did. - A shape stands on no audio, so an edit reaches it at once rather than through the + An instrument stands on no audio, so an edit reaches it at once rather than through the regeneration a reconstruction's envelopes go back through. """ - if self.shape_edit is None: + if self.instrument_edit is None: return False self._editor.write_envelope(feature_key, data) diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 7680651cb..584a084af 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -246,7 +246,7 @@ def set_master_entry( def add_sample(self, name: str) -> Segments: return (self._name(name),) - def add_shape(self, name: str) -> Segments: + def add_instrument(self, name: str) -> Segments: return (self._name(name),) def remove_voice(self, voice_id: str) -> Segments: diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 70b8fbed1..6cace2cfc 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -14,10 +14,10 @@ from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.project.patterns.pattern import Pattern from sampletones_core.project.patterns.row import NoteCommand, Row +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion, voice_channels, voice_reference from sampletones_core.utils.display import ( display_command, @@ -344,7 +344,7 @@ def set_sample_instrument( cleared so the row reflects exactly that sample. Clearing an empty sample id wipes the whole row. - The column speaks for samples, which carry a slice per channel; a shape carries one + The column speaks for samples, which carry a slice per channel; an instrument carries one instrument the reader places on the channel they want it on, so it is named in a channel column and this one leaves the row as it stands. """ @@ -711,7 +711,7 @@ def _build_cell( ) -> SequencerCellViewModel: """One cell's three readings, the pitch stated in the terms its voice is written in. - A sample was converted at a pitch of its own, so its rows read as steps from it; a shape + A sample was converted at a pitch of its own, so its rows read as steps from it; an instrument was written against a root the reader chose, so its rows read as the notes they sound. """ return SequencerCellViewModel( @@ -730,7 +730,7 @@ def _display_pitch( voice: Optional[VoiceUnion], ) -> str: match voice: - case Shape(): + case Instrument(): return display_note( transpose, channel_name=channel, diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 452fd1814..48e2327be 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -23,10 +23,10 @@ reconstruction_footprints, ) from sampletones_core.generators.render import render_instructions -from sampletones_core.project.voices.creation import new_shape +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import display_voice from sampletones_shared.exceptions import PlaybackError @@ -86,8 +86,8 @@ def push_voices(self) -> None: def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: return self._controller.add_sample(reconstruction, name) - def add_shape(self, name: str) -> Shape: - return self._controller.add_shape(new_shape(name)) + def add_instrument(self, name: str) -> Instrument: + return self._controller.add_instrument(new_instrument(name)) def rename_voice(self, voice_id: str, name: str) -> None: self._controller.rename_voice(voice_id, name) @@ -100,8 +100,9 @@ def build_voice_footprint(self, voice_id: str) -> Optional[SampleFootprintViewMo A voice carries its own loop point, and a looping instrument is compiled to one shared length, so it is measured the way it is placed. A sample yields a figure per channel its - reconstruction covers; a shape yields the one instrument every channel reaches. Measuring - a single voice on demand keeps a pool edit clear of an export it was not asked for. + reconstruction covers; an instrument yields one, since every channel reaches the same + envelopes. Measuring a single voice on demand keeps a pool edit clear of an export it was + not asked for. Args: voice_id: The voice to measure. @@ -115,9 +116,9 @@ def build_voice_footprint(self, voice_id: str) -> Optional[SampleFootprintViewMo return SampleFootprintViewModel.from_footprints( reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) ) - case Shape() as shape: + case Instrument() as instrument: return SampleFootprintViewModel.from_instrument( - features_footprint(shape.instrument_features(), loop_point=shape.loop_point) + features_footprint(instrument.instrument_features(), loop_point=instrument.loop_point) ) case _: return None @@ -183,9 +184,9 @@ def _execute_autoplay(self) -> None: self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW) def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: - """The audio a preview sounds: a sample's approximation, or a shape rendered on the pulse. + """The audio a preview sounds: a sample's approximation, or an instrument rendered on the pulse. - The pulse channel offers every dimension a shape writes, so rendering the preview there + The pulse channel offers every dimension an instrument writes, so rendering the preview there sounds the whole instrument rather than the part another channel would read. Args: @@ -197,8 +198,8 @@ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: match self._controller.project.voices.get(voice_id): case Sample() as sample: return sample.reconstruction.approximation - case Shape() as shape: - instructions = shape.instructions(PREVIEW_CHANNEL) + case Instrument() as instrument: + instructions = instrument.instructions(PREVIEW_CHANNEL) if not instructions: return None diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index ee0d297f8..0453008ba 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -158,11 +158,11 @@ Widget.BUTTON, "export_instrument", ) -TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_SHAPE = TagName( +TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_FIELDS = TagName( Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, Widget.GROUP, - "shape", + "fields", ) TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS = TagName( Page.RECONSTRUCTIONS, diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index 5f0b0b417..9f7499b13 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -152,11 +152,11 @@ Widget.PANEL, "voices", ) -TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE = TagName( +TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT = TagName( Page.SEQUENCER, Panel.VOICES, Widget.BUTTON, - "new_shape", + "new_instrument", ) TAG_SEQUENCER_VOICES_TABLE = TagName( Page.SEQUENCER, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 281dc0dd1..d6a7458aa 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -9,7 +9,7 @@ from sampletones_application.categories.hierarchy import TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.categories.pitch import PitchTooltips -from sampletones_application.constants.instruments import SHAPE_CHANNEL +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag @@ -36,7 +36,7 @@ SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS, - TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_SHAPE, + TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_FIELDS, TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, @@ -69,8 +69,8 @@ from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentViewModel, ReconstructionInstrumentsViewModel, - ShapeInstrumentViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ( @@ -119,7 +119,7 @@ def __init__( self.channel_plots: Dict[ChannelName, Dict[FeatureKey, GUIBarGraph]] = {} self._pitch_steppers: Dict[ChannelName, GUIPitchStepper] = {} - self._shape_root_period: Optional[GUIPitchStepper] = None + self._instrument_root_period: Optional[GUIPitchStepper] = None self._export_buttons: Dict[ChannelName, GUIButton] = {} self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR @@ -127,9 +127,9 @@ def __init__( self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY) self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) - self.shape_group_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_SHAPE - self.shape_loops_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS - self.shape_loop_point_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT + self.instrument_fields_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_FIELDS + self.instrument_loops_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS + self.instrument_loop_point_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT self._graphs: Dict[str, GUIBarGraph] = {} self._sequence_lengths: Dict[Tuple[ChannelName, FeatureKey], int] = {} @@ -151,8 +151,8 @@ def __init__( self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None self.on_bar_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None self.on_raw_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None - self.on_shape_root_period_changed: Optional[Callable[[int], None]] = None - self.on_shape_loop_point_changed: Optional[Callable[[Optional[int]], None]] = None + self.on_instrument_root_period_changed: Optional[Callable[[int], None]] = None + self.on_instrument_loop_point_changed: Optional[Callable[[Optional[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) @@ -363,22 +363,22 @@ def _create_generator_content( window_tag, ) self._create_pitch_stepper(channel_name, initial_pitch, window_tag) - if channel_name is SHAPE_CHANNEL: - self._create_shape_fields(window_tag) + if channel_name is INSTRUMENT_CHANNEL: + self._create_instrument_fields(window_tag) self._create_generator_feature_displays(channel_name, window_tag) - def _create_shape_fields(self, window_tag: str) -> None: - """Draws what a shape states beyond its envelopes: its noise root and its loop point. + def _create_instrument_fields(self, window_tag: str) -> None: + """Draws what an instrument states beyond its envelopes: its noise root and its loop point. - A shape sounds on every channel, so it states a root for the tonal channels — the stepper + An instrument sounds on every channel, so it states a root for the tonal channels — the stepper above these — and one for the noise channel's periods. The loop point is the tick its envelopes repeat from while a note is held. """ - with dpg.group(tag=self.shape_group_tag, parent=window_tag, show=False): - self._shape_root_period = GUIPitchStepper( - tag=self.shape_group_tag, - parent=self.shape_group_tag, + with dpg.group(tag=self.instrument_fields_tag, parent=window_tag, show=False): + self._instrument_root_period = GUIPitchStepper( + tag=self.instrument_fields_tag, + parent=self.instrument_fields_tag, kind=PERIOD_VALUE_KIND, initial_value=RESTING_REFERENCE_PERIOD, label=self._language_manager["reconstructions.instruments.label.root_period"], @@ -389,47 +389,47 @@ def _create_shape_fields(self, window_tag: str) -> None: plus_minus_layout=self._pitch_stepper_style.plus_minus, value_color=self._pitch_stepper_style.value_color, ) - self._shape_root_period.on_value_changed = self._on_shape_root_period_changed + self._instrument_root_period.on_value_changed = self._on_instrument_root_period_changed with labeled_field( self._language_manager["reconstructions.instruments.label.loop_point"], self._pitch_stepper_style.dimensions.label_width, - parent=self.shape_group_tag, + parent=self.instrument_fields_tag, ): dpg.add_checkbox( - tag=self.shape_loops_tag, + tag=self.instrument_loops_tag, default_value=False, - callback=self._on_shape_loops_toggled, + callback=self._on_instrument_loops_toggled, ) dpg.add_input_int( - tag=self.shape_loop_point_tag, + tag=self.instrument_loop_point_tag, default_value=0, min_value=0, min_clamped=True, width=self._pitch_stepper_style.dimensions.value_width, step=1, - callback=self._on_shape_loop_point_typed, + callback=self._on_instrument_loop_point_typed, ) - def _on_shape_root_period_changed(self, value: int) -> None: - self.call(self.on_shape_root_period_changed, value) + def _on_instrument_root_period_changed(self, value: int) -> None: + self.call(self.on_instrument_root_period_changed, value) - def _on_shape_loops_toggled(self, _sender: Sender, app_data: bool) -> None: - point = dpg.get_value(self.shape_loop_point_tag) if app_data else None - self.call(self.on_shape_loop_point_changed, point) + def _on_instrument_loops_toggled(self, _sender: Sender, app_data: bool) -> None: + point = dpg.get_value(self.instrument_loop_point_tag) if app_data else None + self.call(self.on_instrument_loop_point_changed, point) - def _on_shape_loop_point_typed(self, _sender: Sender, app_data: int) -> None: - if dpg.get_value(self.shape_loops_tag): - self.call(self.on_shape_loop_point_changed, max(0, app_data)) + def _on_instrument_loop_point_typed(self, _sender: Sender, app_data: int) -> None: + if dpg.get_value(self.instrument_loops_tag): + self.call(self.on_instrument_loop_point_changed, max(0, app_data)) - def _apply_shape_fields(self, shape: ShapeInstrumentViewModel) -> None: - """Writes what a shape states into the fields that show it.""" - if self._shape_root_period is not None: - self._shape_root_period.set_value(shape.root_period) + def _apply_instrument_fields(self, instrument: InstrumentViewModel) -> None: + """Writes what an instrument states into the fields that show it.""" + if self._instrument_root_period is not None: + self._instrument_root_period.set_value(instrument.root_period) - dpg_set_value(self.shape_loops_tag, shape.loops) - dpg_set_value(self.shape_loop_point_tag, shape.loop_point if shape.loop_point is not None else 0) - dpg_configure_item(self.shape_loop_point_tag, enabled=shape.loops) + dpg_set_value(self.instrument_loops_tag, instrument.loops) + dpg_set_value(self.instrument_loop_point_tag, instrument.loop_point if instrument.loop_point is not None else 0) + dpg_configure_item(self.instrument_loop_point_tag, enabled=instrument.loops) def _default_initial_pitch(self, channel_name: ChannelName) -> int: return resting_reference(channel_name) @@ -525,24 +525,24 @@ def update_view( A reconstruction shows a tab per channel, and every channel is editable for as long as it is open, so writing an envelope into a channel standing by is what puts it in play; a - muted tab label and a withheld export say which channels are there. A shape is one + muted tab label and a withheld export say which channels are there. An instrument is one instrument every channel reads, so it shows a single tab under its own name, carrying the roots and the loop point it states. """ - shape = view_model.shape + instrument = view_model.instrument is_open = view_model.is_open dpg_configure_item(self.no_data_message_tag, show=not is_open) dpg_configure_item(self.tab_bar_tag, show=is_open) dpg_configure_item(self.sample_size_group_tag, show=is_open) - dpg_configure_item(self.shape_group_tag, show=shape is not None) - self._update_sizes(view_model.footprint, shows_one_instrument=shape is not None) + dpg_configure_item(self.instrument_fields_tag, show=instrument is not None) + self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None) for channel_name in ChannelName.items(): tab_tag = self._get_generator_tab_tag(channel_name) - shown = channel_name is SHAPE_CHANNEL if shape is not None else view_model.reconstruction_loaded + shown = channel_name is INSTRUMENT_CHANNEL if instrument is not None else view_model.reconstruction_loaded dpg_configure_item(tab_tag, show=shown) - if shape is not None and channel_name is SHAPE_CHANNEL: - dpg_configure_item(tab_tag, label=shape.name) + if instrument is not None and channel_name is INSTRUMENT_CHANNEL: + dpg_configure_item(tab_tag, label=instrument.name) else: dpg_configure_item(tab_tag, label=self._channel_labels[channel_name]) @@ -551,12 +551,12 @@ def update_view( channel_name in view_model.playing_channels, ) - export_button = self._export_buttons.get(SHAPE_CHANNEL) - if export_button is not None and shape is not None: + export_button = self._export_buttons.get(INSTRUMENT_CHANNEL) + if export_button is not None and instrument is not None: export_button.set_enabled(False) - if shape is not None: - self._apply_shape_fields(shape) + if instrument is not None: + self._apply_instrument_fields(instrument) def _apply_playing_state( self, @@ -584,8 +584,8 @@ def _update_sizes( """Writes the byte figures the voice in front of the panel occupies. A reconstruction states its own total and a figure per channel, and a channel standing by - is written by no export, so it reads as the nothing it costs. A shape is one instrument - every channel reaches, so the tab it is shown under carries the whole figure. + is written by no export, so it reads as the nothing it costs. Every channel reaches the same + instrument, so the tab it is shown under carries the whole figure. """ if footprint is None: return @@ -593,7 +593,7 @@ def _update_sizes( dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes)) for channel_name in ChannelName.items(): instrument_bytes = footprint.bytes_for(channel_name) - if shows_one_instrument and channel_name is SHAPE_CHANNEL: + if shows_one_instrument and channel_name is INSTRUMENT_CHANNEL: instrument_bytes = footprint.total_bytes dpg_set_value( diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices.py index c98eaab32..ab6cd5eae 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -14,7 +14,7 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_HANDLER_LIST, SUF_HANDLER_REGISTRY from sampletones_application.tags.sequencer import ( - TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE, + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, TAG_SEQUENCER_VOICES_INPUT_RENAME, TAG_SEQUENCER_VOICES_PANEL, TAG_SEQUENCER_VOICES_TABLE, @@ -122,9 +122,9 @@ def __init__( self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) - self._tip_new_shape = self._tooltip(language_manager, SequencerVoicesElements.NEW_SHAPE) + self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT) self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) - self._tip_kind_shape = self._tooltip(language_manager, SequencerVoicesElements.KIND_SHAPE) + self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT) self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None @@ -134,7 +134,7 @@ def __init__( self.on_move_requested: Optional[Callable[[str, int], None]] = None self.on_rename_committed: Optional[Callable[[str, str], None]] = None self.on_duplicate_requested: Optional[StringCallback] = None - self.on_new_shape_requested: Optional[VoidCallback] = None + self.on_new_instrument_requested: Optional[VoidCallback] = None self.on_add_sample_requested: Optional[VoidCallback] = None super().__init__( @@ -150,7 +150,7 @@ def create_panel(self, parent: str) -> None: self._label(self._language_manager, SequencerVoicesElements.VOICES_TEXT), glyph=self._glyphs.headers.voices, ): - self._create_new_shape_button() + self._create_new_instrument_button() self._create_voices_table() self._create_row_handlers() @@ -182,16 +182,16 @@ def _create_key_handler(self) -> None: active=self._keys_active, ) - def _create_new_shape_button(self) -> None: + def _create_new_instrument_button(self) -> None: """Offers a hand-written voice, which is the one kind no browser brings in.""" button = dpg.add_button( - tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE, - label=self._label(self._language_manager, SequencerVoicesElements.NEW_SHAPE), + tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + label=self._label(self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT), width=-1, - callback=lambda: self.call(self.on_new_shape_requested), + callback=lambda: self.call(self.on_new_instrument_requested), ) FontRegistry.bind_to_item(button, Font.REGULAR_SMALL) - show_tooltip(button, self._tip_new_shape) + show_tooltip(button, self._tip_new_instrument) def _create_voices_table(self) -> None: with ( @@ -321,15 +321,15 @@ def _kind_glyph(self, kind: VoiceKind) -> str: match kind: case VoiceKind.SAMPLE: return self._glyphs.voices.sample - case VoiceKind.SHAPE: - return self._glyphs.voices.shape + case VoiceKind.INSTRUMENT: + return self._glyphs.voices.instrument def _kind_tooltip(self, kind: VoiceKind) -> str: match kind: case VoiceKind.SAMPLE: return self._tip_kind_sample - case VoiceKind.SHAPE: - return self._tip_kind_shape + case VoiceKind.INSTRUMENT: + return self._tip_kind_instrument def _build_id_cell( self, @@ -634,7 +634,7 @@ def _pointer_within_list(self) -> bool: """ return dpg_pointer_within_window( TAG_SEQUENCER_VOICES_WINDOW, - TAG_SEQUENCER_VOICES_BUTTON_NEW_SHAPE, + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, ) def _show_list_menu(self) -> None: @@ -733,9 +733,9 @@ def add_pool_items(self) -> None: dpg.add_menu_item( label=self._label( self._language_manager, - SequencerVoicesElements.NEW_SHAPE, + SequencerVoicesElements.NEW_INSTRUMENT, ), - callback=lambda: self.call(self.on_new_shape_requested), + callback=lambda: self.call(self.on_new_instrument_requested), ) dpg.add_menu_item( label=self._label( diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index b594aef4f..1334da46b 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -6,10 +6,10 @@ from sampletones_core.constants.enums import ChannelName -class ShapeInstrumentViewModel(BaseModel, frozen=True): - """What the instruments panel shows of a shape: its name and the values it states. +class InstrumentViewModel(BaseModel, frozen=True): + """What the instruments panel shows of an instrument: its name and the values it states. - A shape is its envelopes and the roots they are measured against, so the panel renders one + An instrument is its envelopes and the roots they are measured against, so the panel renders one instrument rather than a tab per channel. """ @@ -20,7 +20,7 @@ class ShapeInstrumentViewModel(BaseModel, frozen=True): @property def loops(self) -> bool: - """Whether the shape repeats its envelopes rather than playing them once.""" + """Whether the instrument repeats its envelopes rather than playing them once.""" return self.loop_point is not None @@ -29,21 +29,21 @@ class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel - reads to mark the standing-by tabs and to offer their export. A shape holds one instrument - every channel reads, so :attr:`shape` is what the panel renders instead. + reads to mark the standing-by tabs and to offer their export. An instrument is one set every + channel reads, so :attr:`instrument` is what the panel renders instead. """ reconstruction_loaded: bool playing_channels: FrozenSet[ChannelName] footprint: Optional[SampleFootprintViewModel] - shape: Optional[ShapeInstrumentViewModel] = None + instrument: Optional[InstrumentViewModel] = None @property - def edits_a_shape(self) -> bool: - """Whether the panel is showing a shape rather than a reconstruction's channels.""" - return self.shape is not None + def edits_an_instrument(self) -> bool: + """Whether the panel is showing an instrument rather than a reconstruction's channels.""" + return self.instrument is not None @property def is_open(self) -> bool: """Whether the panel has a voice in front of it at all.""" - return self.reconstruction_loaded or self.edits_a_shape + return self.reconstruction_loaded or self.edits_an_instrument diff --git a/src/sampletones_application/view_model/sequencer/kind.py b/src/sampletones_application/view_model/sequencer/kind.py index 2176f977d..910357cac 100644 --- a/src/sampletones_application/view_model/sequencer/kind.py +++ b/src/sampletones_application/view_model/sequencer/kind.py @@ -1,6 +1,6 @@ from sampletones_application.view_model.sequencer.voices import VoiceKind +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion @@ -16,5 +16,5 @@ def voice_kind(voice: VoiceUnion) -> VoiceKind: match voice: case Sample(): return VoiceKind.SAMPLE - case Shape(): - return VoiceKind.SHAPE + case Instrument(): + return VoiceKind.INSTRUMENT diff --git a/src/sampletones_application/view_model/sequencer/voices.py b/src/sampletones_application/view_model/sequencer/voices.py index f592942d2..4c34d3d83 100644 --- a/src/sampletones_application/view_model/sequencer/voices.py +++ b/src/sampletones_application/view_model/sequencer/voices.py @@ -9,12 +9,12 @@ class VoiceKind(StrEnum): """Which of the two kinds a voice list entry carries. - A sample stands on a recording it was converted from; a shape was written by hand. The list + A sample stands on a recording it was converted from; an instrument was written by hand. The list marks each so a reader tells them apart, and the gestures a row offers follow from it. """ SAMPLE = "sample" - SHAPE = "shape" + INSTRUMENT = "instrument" class VoiceEntryViewModel(BaseModel, frozen=True): diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index 0c2d8f0b3..c9b2f9e67 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -14,8 +14,8 @@ class InstrumentSizeViewModel(BaseModel, frozen=True): The measurement is carried as it was taken, both regions intact, so a display naming the whole and one naming a region read the same figure. An instrument naming a channel is a - sample's slice of that channel; one naming none is a shape, stored once for every channel - that reaches it. + sample's slice of that channel; one naming none is a voice written by hand, stored once for + every channel that reaches it. """ channel: Optional[ChannelName] @@ -32,8 +32,8 @@ class SampleFootprintViewModel(BaseModel, frozen=True): A sample exports one instrument per channel its reconstruction covers, so a display reads :attr:`total_bytes` for the voice as a whole and :meth:`bytes_for` for a single channel. A - shape exports one instrument every channel reaches, so it carries a single entry and its - whole figure is that instrument's. Both the instruments panel and the voices menu read their + voice written by hand exports one instrument every channel reaches, so it carries a single + entry and its whole figure is that instrument's. Both the instruments panel and the voices menu read their figures from here, so the two name the same size for the same voice. """ @@ -58,7 +58,7 @@ def from_footprints( @classmethod def from_instrument(cls, footprint: InstrumentFootprint) -> Self: - """Carries one instrument every channel reaches, which is what a shape exports.""" + """Carries one instrument every channel reaches, which is what a hand-written voice exports.""" return cls(instruments=(InstrumentSizeViewModel(channel=None, footprint=footprint),)) @property diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 76aba7286..7415bc4cd 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -595,10 +595,10 @@ sequencer.order.tooltip.label_channel: "Click to mute or unmute this channel.\n{ sequencer.order.tooltip.label_master: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions." # ============================================================================= -# Sequencer tab — Instruments +# Sequencer tab — Voices # ============================================================================= sequencer.voices.label.voices_text: "Voices" -sequencer.voices.label.new_shape: "New shape" +sequencer.voices.label.new_instrument: "New instrument" sequencer.voices.label.add_sample: "Add sample from file..." sequencer.voices.label.column_kind: "Kind" sequencer.voices.label.column_id: "ID" @@ -612,11 +612,11 @@ sequencer.voices.label.context_move_up: "Move up" sequencer.voices.label.context_move_down: "Move down" sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" -sequencer.voices.tooltip.new_shape: "Add a hand-written voice, playable on any channel" +sequencer.voices.tooltip.new_instrument: "Add an instrument written by hand, playable on any channel" sequencer.voices.tooltip.kind_sample: "Sample" -sequencer.voices.tooltip.kind_shape: "Shape" +sequencer.voices.tooltip.kind_instrument: "Instrument" sequencer.voices.title.add_sample_dialog: "Add sample" -sequencer.voices.template.shape_name: "Shape {position}" +sequencer.voices.template.instrument_name: "Instrument {position}" sequencer.history.label.history_text: "History" sequencer.history.label.undo: "Undo" @@ -648,8 +648,8 @@ sequencer.history.label.rename_sample: "Rename sample" sequencer.history.label.move_sample: "Move sample" sequencer.history.label.duplicate_sample: "Duplicate sample" sequencer.history.label.set_sample_loop: "Toggle sample loop" -sequencer.history.label.add_shape: "Add shape" -sequencer.history.label.edit_shape: "Edit shape" +sequencer.history.label.add_instrument: "Add instrument" +sequencer.history.label.edit_instrument: "Edit instrument" sequencer.history.label.loop_on: "on" sequencer.history.label.loop_off: "off" sequencer.history.label.set_tempo: "Set tempo" diff --git a/src/sampletones_config/layout/glyphs.yaml b/src/sampletones_config/layout/glyphs.yaml index 468d24326..d1f26de16 100644 --- a/src/sampletones_config/layout/glyphs.yaml +++ b/src/sampletones_config/layout/glyphs.yaml @@ -8,7 +8,7 @@ common: voices: sample: "≈" - shape: "∿" + instrument: "∿" headers: waveform: "∿" diff --git a/src/sampletones_core/compatibility/project/__init__.py b/src/sampletones_core/compatibility/project/__init__.py index 32515b045..ca3fac1d0 100644 --- a/src/sampletones_core/compatibility/project/__init__.py +++ b/src/sampletones_core/compatibility/project/__init__.py @@ -3,6 +3,5 @@ from sampletones_core.compatibility.update import VersionUpdate from .v1_1 import V1_1 -from .v1_2 import V1_2 -UPDATES: Final[Tuple[VersionUpdate, ...]] = (V1_1, V1_2) +UPDATES: Final[Tuple[VersionUpdate, ...]] = (V1_1,) diff --git a/src/sampletones_core/compatibility/project/v1_1.py b/src/sampletones_core/compatibility/project/v1_1.py index 0a3b3771f..4643bd4d7 100644 --- a/src/sampletones_core/compatibility/project/v1_1.py +++ b/src/sampletones_core/compatibility/project/v1_1.py @@ -1,15 +1,19 @@ -from typing import Final +from typing import Final, List from sampletones_core.compatibility.fields import ( - CHANNEL_NAME, CHANNELS, COMMAND, GENERATOR, - GENERATOR_NAME, + KIND, + KIND_SAMPLE, NAME, PATTERNS, ROWS, + SAMPLE_ID, + SAMPLES, SONG, + VOICE_ID, + VOICES, ) from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.compatibility.update import VersionUpdate @@ -18,79 +22,71 @@ def update(data: SerializedData) -> SerializedData: - """Names each channel pool and row command by its channel. + """Gathers a project's samples into its voices, and names each channel once. - Project format 1.0 stored a channel pool's channel under ``generator`` and a - row instrument's channel under ``generator_name``. Project format 1.1 names - them ``name`` and ``channel_name``. + Project format 1.0 held the pool under ``samples``, stored a channel pool's channel under + ``generator``, and wrote a row's note command as a sample id beside the channel slice it named. + Project format 1.1 holds the pool under ``voices``, each record stating the ``kind`` of voice + it carries; a channel pool names its channel under ``name``; and a note command names the voice + alone, since the channel a voice sounds on is the one whose pattern holds the row. """ updated = dict(data) + + samples = data.get(SAMPLES) + if isinstance(samples, list): + updated.pop(SAMPLES, None) + updated[VOICES] = [{KIND: KIND_SAMPLE, **sample} if isinstance(sample, dict) else sample for sample in samples] + song = data.get(SONG) - if not isinstance(song, dict): - return updated + if isinstance(song, dict): + updated[SONG] = _updated_song(song) + + return updated + +def _updated_song(song: SerializedData) -> SerializedData: channels = song.get(CHANNELS) if not isinstance(channels, dict): - return updated - - renamed_channels = { - name: ( - _renamed_pool(channel) - if isinstance( - channel, - dict, - ) - else channel - ) - for name, channel in channels.items() - } - updated["song"] = {**song, CHANNELS: renamed_channels} + return song - return updated + return { + **song, + CHANNELS: { + name: _updated_pool(channel) if isinstance(channel, dict) else channel for name, channel in channels.items() + }, + } -def _renamed_pool(channel: SerializedData) -> SerializedData: - renamed = dict(channel) - if GENERATOR in renamed: - renamed[NAME] = renamed.pop(GENERATOR) +def _updated_pool(channel: SerializedData) -> SerializedData: + updated = dict(channel) + if GENERATOR in updated: + updated[NAME] = updated.pop(GENERATOR) patterns = channel.get(PATTERNS) if isinstance(patterns, dict): - renamed[PATTERNS] = { - index: ( - _renamed_pattern(pattern) - if isinstance( - pattern, - dict, - ) - else pattern - ) + updated[PATTERNS] = { + index: _updated_pattern(pattern) if isinstance(pattern, dict) else pattern for index, pattern in patterns.items() } - return renamed + return updated -def _renamed_pattern(pattern: SerializedData) -> SerializedData: +def _updated_pattern(pattern: SerializedData) -> SerializedData: rows = pattern.get(ROWS) if not isinstance(rows, list): return pattern - return { - **pattern, - ROWS: [_renamed_row(row) if isinstance(row, dict) else row for row in rows], - } + updated_rows: List[SerializedData] = [_updated_row(row) if isinstance(row, dict) else row for row in rows] + return {**pattern, ROWS: updated_rows} -def _renamed_row(row: SerializedData) -> SerializedData: +def _updated_row(row: SerializedData) -> SerializedData: command = row.get(COMMAND) - if not isinstance(command, dict) or GENERATOR_NAME not in command: + if not isinstance(command, dict) or SAMPLE_ID not in command: return row - renamed_command = dict(command) - renamed_command[CHANNEL_NAME] = renamed_command.pop(GENERATOR_NAME) - - return {**row, COMMAND: renamed_command} + return {**row, COMMAND: {VOICE_ID: command[SAMPLE_ID]}} V1_1: Final[VersionUpdate] = VersionUpdate( diff --git a/src/sampletones_core/compatibility/project/v1_2.py b/src/sampletones_core/compatibility/project/v1_2.py deleted file mode 100644 index 8d4589c45..000000000 --- a/src/sampletones_core/compatibility/project/v1_2.py +++ /dev/null @@ -1,92 +0,0 @@ -from typing import Final, List - -from sampletones_core.compatibility.fields import ( - CHANNELS, - COMMAND, - KIND, - KIND_SAMPLE, - PATTERNS, - ROWS, - SAMPLE_ID, - SAMPLES, - SONG, - VOICE_ID, - VOICES, -) -from sampletones_core.compatibility.kind import ObjectKind -from sampletones_core.compatibility.update import VersionUpdate -from sampletones_shared.deployment.version import Version -from sampletones_shared.types.data import SerializedData - - -def update(data: SerializedData) -> SerializedData: - """Gathers a project's samples into its voices, and names each row's voice alone. - - Project format 1.1 held the pool under ``samples`` and a row's note command as a sample id - beside the channel slice it named. Project format 1.2 holds the pool under ``voices``, each - record stating the ``kind`` of voice it carries, and a note command names the voice by id: the - channel a voice sounds on is the one whose pattern holds the row. - """ - updated = dict(data) - samples = data.get(SAMPLES) - if isinstance(samples, list): - updated.pop(SAMPLES, None) - updated[VOICES] = [{KIND: KIND_SAMPLE, **sample} if isinstance(sample, dict) else sample for sample in samples] - - song = data.get(SONG) - if isinstance(song, dict): - updated[SONG] = _updated_song(song) - - return updated - - -def _updated_song(song: SerializedData) -> SerializedData: - channels = song.get(CHANNELS) - if not isinstance(channels, dict): - return song - - return { - **song, - CHANNELS: { - name: _updated_pool(channel) if isinstance(channel, dict) else channel for name, channel in channels.items() - }, - } - - -def _updated_pool(channel: SerializedData) -> SerializedData: - patterns = channel.get(PATTERNS) - if not isinstance(patterns, dict): - return channel - - return { - **channel, - PATTERNS: { - index: _updated_pattern(pattern) if isinstance(pattern, dict) else pattern - for index, pattern in patterns.items() - }, - } - - -def _updated_pattern(pattern: SerializedData) -> SerializedData: - rows = pattern.get(ROWS) - if not isinstance(rows, list): - return pattern - - updated_rows: List[SerializedData] = [_updated_row(row) if isinstance(row, dict) else row for row in rows] - return {**pattern, ROWS: updated_rows} - - -def _updated_row(row: SerializedData) -> SerializedData: - command = row.get(COMMAND) - if not isinstance(command, dict) or SAMPLE_ID not in command: - return row - - return {**row, COMMAND: {VOICE_ID: command[SAMPLE_ID]}} - - -V1_2: Final[VersionUpdate] = VersionUpdate( - kind=ObjectKind.PROJECT, - base=Version.model_validate("1.1"), - target=Version.model_validate("1.2"), - apply=update, -) diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 21758b573..09461b201 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -7,8 +7,8 @@ from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.project.project import Project +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion, voice_channels @@ -53,7 +53,7 @@ class InstrumentEntry: """One instrument an export writes, and the channels whose rows reach it. A sample's channels each carry frames of their own, so each becomes an instrument answering - for that channel alone. A shape carries one set of envelopes every channel reads, so it + for that channel alone. An instrument carries one set of envelopes every channel reads, so it becomes one instrument answering for every channel it sounds on, each against its own root — which is the instrument model FamiTracker itself uses. @@ -77,8 +77,8 @@ class InstrumentEntry: def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: """Walks what every channel of every voice plays, in voice order then channel order. - A voice contributes one slice per channel it sounds on, so a sample yields one to four and a - shape yields one per channel its envelopes make a frame for. Each voice is read once, so a + A voice contributes one slice per channel it sounds on, so a sample yields one to four and an + instrument yields one per channel its envelopes make a frame for. Each voice is read once, so a caller reads a reconstruction's envelopes at a single cost. Args: @@ -95,7 +95,7 @@ def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: features = features_by_channel[channel] if features.has_frames: yield VoiceSlice(voice=voice, channel=channel, features=features) - case Shape(): + case Instrument(): for channel in voice_channels(voice): yield VoiceSlice(voice=voice, channel=channel, features=voice.features(channel)) @@ -128,7 +128,7 @@ def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]: slots={channel: InstrumentSlot(index=index, initial_pitch=features.initial_pitch)}, ) index += 1 - case Shape(): + case Instrument(): channels = voice_channels(voice) if not channels: continue diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 54cd5ebcb..8da304ef0 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -103,8 +103,8 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst """Builds the module's instruments and the table a pattern row resolves through. A sample contributes one instrument for every channel its reconstruction covers, so it yields - one to four; a shape contributes one instrument every channel it sounds on reaches, each - against that channel's own root. Instruments are numbered in voice order, then channel order. + one to four; a hand-written voice contributes one instrument every channel it sounds on + reaches, each against that channel's own root. Instruments are numbered in voice order, then channel order. Raises: ValueError: If the project holds more instruments than FamiTracker has room for. diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index 7e691d1f4..54d5b2521 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -6,8 +6,8 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, ExporterTypeUnion from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion, voice_reference @@ -47,7 +47,7 @@ def read( """The reading one channel plays ``voice`` through. A sample answers with the frames its reconstruction found for this channel and the - reference they were measured against; a shape answers with the frames its envelopes make + reference they were measured against; an instrument answers with the frames its envelopes make of this channel and the root it states. Both kinds therefore reach a channel as one reading. @@ -63,7 +63,7 @@ def read( case Sample(): instructions: Sequence[InstructionUnion] = voice.reconstruction.instructions[channel_name] held_features = voice.reconstruction.held_features[channel_name] - case Shape(): + case Instrument(): instructions = voice.instructions(channel_name) held_features = voice.held_features(channel_name) diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index 434a74ce7..c0a833be4 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -8,9 +8,9 @@ from sampletones_core.compatibility.upgrade import upgrade_json from sampletones_core.project.document import ProjectDocument from sampletones_core.project.project import Project +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.record import SampleRecord, VoiceRecord from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection @@ -140,7 +140,7 @@ def _build_project( @staticmethod def _voice_record(voice: VoiceUnion) -> VoiceRecord: - """The record a voice is written as: a reference for a sample, the shape itself for a shape.""" + """The record a voice is written as: a reference for a sample, the whole of it for an instrument.""" match voice: case Sample(): return SampleRecord( @@ -149,7 +149,7 @@ def _voice_record(voice: VoiceUnion) -> VoiceRecord: reconstruction_id=voice.reconstruction.id, loop_point=voice.loop_point, ) - case Shape(): + case Instrument(): return voice @staticmethod @@ -171,7 +171,7 @@ def _restore_voice( ) sample.id = record.id return sample - case Shape(): + case Instrument(): return record @staticmethod diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py index ac0b43544..7fe43927b 100644 --- a/src/sampletones_core/project/voices/__init__.py +++ b/src/sampletones_core/project/voices/__init__.py @@ -1,24 +1,24 @@ -from .creation import new_shape -from .envelopes import ShapeEnvelopes +from .creation import new_instrument +from .envelopes import InstrumentEnvelopes +from .instrument import Instrument from .loop import WHOLE_LOOP_POINT from .note_off import NoteOff from .note_on import NoteOn from .record import SampleRecord, VoiceRecord from .sample import Sample -from .shape import Shape from .voice import VoiceUnion, samples, voice_channels, voice_reference __all__ = [ "WHOLE_LOOP_POINT", + "Instrument", + "InstrumentEnvelopes", "NoteOff", "NoteOn", "Sample", "SampleRecord", - "Shape", - "ShapeEnvelopes", "VoiceRecord", "VoiceUnion", - "new_shape", + "new_instrument", "samples", "voice_channels", "voice_reference", diff --git a/src/sampletones_core/project/voices/creation.py b/src/sampletones_core/project/voices/creation.py index d024dbbae..bf1521fa3 100644 --- a/src/sampletones_core/project/voices/creation.py +++ b/src/sampletones_core/project/voices/creation.py @@ -1,17 +1,17 @@ from typing import Final from sampletones_core.constants.general import MAX_VOLUME -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT -from sampletones_core.project.voices.shape import Shape -SUSTAINING_ENVELOPES: Final[ShapeEnvelopes] = ShapeEnvelopes(volume=(MAX_VOLUME,)) +SUSTAINING_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes(volume=(MAX_VOLUME,)) -def new_shape(name: str) -> Shape: - """A shape a reader can place and hear straight away, before writing an envelope of its own. +def new_instrument(name: str) -> Instrument: + """An instrument a reader can place and hear straight away, before writing an envelope of its own. - A shape sounds the frames its envelopes describe, so one holding a single full-volume tick + An instrument sounds the frames its envelopes describe, so one holding a single full-volume tick that repeats holds a note for as long as a row asks for it, at the roots a channel added by hand rests on. Arpeggio and duty cycle stay the channel's until the reader writes them. @@ -19,9 +19,9 @@ def new_shape(name: str) -> Shape: name: The name the voice list shows. Returns: - Shape: A voice sustaining at full volume on every channel. + Instrument: A voice sustaining at full volume on every channel. """ - return Shape( + return Instrument( name=name, envelopes=SUSTAINING_ENVELOPES, loop_point=WHOLE_LOOP_POINT, diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py index 2058b0133..47bc206f3 100644 --- a/src/sampletones_core/project/voices/envelopes.py +++ b/src/sampletones_core/project/voices/envelopes.py @@ -16,8 +16,8 @@ DutyCycleItem = Annotated[int, Field(ge=0, le=MAX_DUTY_CYCLE)] -class ShapeEnvelopes(BaseModel): - """The per-tick envelopes a shape writes, in the terms every channel reads them in. +class InstrumentEnvelopes(BaseModel): + """The per-tick envelopes an instrument writes, in the terms every channel reads them in. Each dimension carries the widest range the four channels offer, and a channel takes what it reads: an arpeggio item is a semitone offset on the tonal channels and a period offset on @@ -27,7 +27,7 @@ class ShapeEnvelopes(BaseModel): Attributes: volume: Output level per tick. - arpeggio: Offset from the shape's root per tick. + arpeggio: Offset from the instrument's root per tick. duty_cycle: Pulse waveform, or noise mode, per tick. """ @@ -46,7 +46,7 @@ def envelope_map(self) -> Dict[FeatureKey, Tuple[int, ...]]: } def envelope(self, feature_key: FeatureKey) -> Tuple[int, ...]: - """The items one dimension carries, empty where the shape leaves it to the channel. + """The items one dimension carries, empty where the instrument leaves it to the channel. Args: feature_key: The dimension read. @@ -55,11 +55,11 @@ def envelope(self, feature_key: FeatureKey) -> Tuple[int, ...]: Tuple[int, ...]: That dimension's items. Raises: - KeyError: If ``feature_key`` names a dimension a shape does not write. + KeyError: If ``feature_key`` names a dimension an instrument does not write. """ return self.envelope_map[feature_key] - def with_envelope(self, feature_key: FeatureKey, items: Tuple[int, ...]) -> "ShapeEnvelopes": + def with_envelope(self, feature_key: FeatureKey, items: Tuple[int, ...]) -> "InstrumentEnvelopes": """The envelopes with one dimension replaced. Args: @@ -67,10 +67,10 @@ def with_envelope(self, feature_key: FeatureKey, items: Tuple[int, ...]) -> "Sha items: What that dimension now carries; empty leaves it to the channel. Returns: - ShapeEnvelopes: The envelopes carrying ``items`` for ``feature_key``. + InstrumentEnvelopes: The envelopes carrying ``items`` for ``feature_key``. Raises: - KeyError: If ``feature_key`` names a dimension a shape does not write. + KeyError: If ``feature_key`` names a dimension an instrument does not write. """ if feature_key not in self.envelope_map: raise KeyError(feature_key) diff --git a/src/sampletones_core/project/voices/shape.py b/src/sampletones_core/project/voices/instrument.py similarity index 76% rename from src/sampletones_core/project/voices/shape.py rename to src/sampletones_core/project/voices/instrument.py index cd2cdc4b4..24599fe00 100644 --- a/src/sampletones_core/project/voices/shape.py +++ b/src/sampletones_core/project/voices/instrument.py @@ -22,23 +22,23 @@ supports, ) from sampletones_core.instructions import InstructionUnion -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes -def _new_shape_id() -> str: +def _new_instrument_id() -> str: return uuid4().hex -class Shape(BaseModel): +class Instrument(BaseModel): """A hand-written voice: envelopes with no recording behind them, playable on any channel. - Where a sample carries the frames a conversion found for each channel, a shape carries one set - of envelopes and every channel reads what it can of them — the dimensions its generator offers, - measured against the root the shape states. That is the FamiTracker instrument model, so a - shape reaches a tracker as one instrument and sounds here as the frames each channel makes of - it. + Where a sample carries the frames a conversion found for each channel, an instrument carries + one set of envelopes, and every channel reads what it can of them — the dimensions its + generator offers, measured against the root the instrument states. That is the model + FamiTracker itself holds, so one reaches a tracker as it stands and sounds here as the frames + each channel makes of it. - A shape carries no payload beyond what it states, so a project stores it whole rather than + An instrument carries no payload beyond what it states, so a project stores it whole rather than beside itself: this is both the voice a song plays and the record a ``project.json`` holds. Attributes: @@ -52,10 +52,10 @@ class Shape(BaseModel): model_config = ConfigDict(extra="forbid") - kind: Literal["shape"] = "shape" - id: str = Field(default_factory=_new_shape_id, description="Stable shape id.") - name: str = Field(..., description="Shape name.") - envelopes: ShapeEnvelopes = Field(default_factory=ShapeEnvelopes) + kind: Literal["instrument"] = "instrument" + id: str = Field(default_factory=_new_instrument_id, description="Stable instrument id.") + name: str = Field(..., description="Instrument name.") + envelopes: InstrumentEnvelopes = Field(default_factory=InstrumentEnvelopes) root_pitch: int = Field( default=RESTING_REFERENCE_PITCH, ge=MIN_PITCH, @@ -76,7 +76,7 @@ class Shape(BaseModel): @property def loops(self) -> bool: - """Whether the shape repeats its envelopes rather than playing them once.""" + """Whether the instrument repeats its envelopes rather than playing them once.""" return self.loop_point is not None def reference(self, channel_name: ChannelName) -> int: @@ -88,7 +88,7 @@ def reference(self, channel_name: ChannelName) -> int: ) def held_features(self, channel_name: ChannelName) -> Tuple[FeatureKey, ...]: - """The dimensions this channel governs: those it offers and the shape leaves empty.""" + """The dimensions this channel governs: those it offers and the instrument leaves empty.""" kind = CHANNEL_GENERATOR_KIND[channel_name] return tuple( feature_key @@ -97,13 +97,13 @@ def held_features(self, channel_name: ChannelName) -> Tuple[FeatureKey, ...]: ) def features(self, channel_name: ChannelName) -> Features: - """The envelopes as this channel reads them, measured against the shape's root. + """The envelopes as this channel reads them, measured against the instrument's root. A channel takes the dimensions its generator offers and leaves the rest absent, which is what makes one set of envelopes serve every channel. Args: - channel_name: The channel reading the shape. + channel_name: The channel reading the instrument. Returns: Features: The per-dimension envelopes for that channel. @@ -120,13 +120,13 @@ def features(self, channel_name: ChannelName) -> Features: ) def instrument_features(self) -> Features: - """The envelopes as a tracker instrument holds them: every dimension the shape writes. + """The envelopes as a tracker instrument holds them: every dimension the instrument writes. A tracker instrument is one set of sequences whatever channel plays it, and each channel - reads what it can of them — which is why a shape reaches a tracker as a single instrument. + reads what it can of them, so this is the whole of what a tracker export writes. Returns: - Features: The envelopes, measured against the shape's tonal root. + Features: The envelopes, measured against the instrument's tonal root. """ length = self.envelopes.frame_count return Features( @@ -149,10 +149,10 @@ def instructions(self, channel_name: ChannelName) -> List[InstructionUnion]: """The frames this channel plays, one per tick of the envelopes. Args: - channel_name: The channel sounding the shape. + channel_name: The channel sounding the instrument. Returns: - List[InstructionUnion]: The frames, empty where the shape writes no envelope. + List[InstructionUnion]: The frames, empty where the instrument writes no envelope. """ return self._instructions[channel_name] @@ -174,23 +174,23 @@ def __hash__(self) -> int: return hash(self.id) def __eq__(self, other: object) -> bool: - return isinstance(other, Shape) and self.id == other.id + return isinstance(other, Instrument) and self.id == other.id def __repr__(self) -> str: - return f"Shape(id={self.id!r}, name={self.name!r})" + return f"Instrument(id={self.id!r}, name={self.name!r})" def _items(envelope: Tuple[int, ...], length: int) -> np.ndarray: - """One dimension brought to the length the shape's longest runs, holding its final value. + """One dimension brought to the length the instrument's longest runs, holding its final value. A tracker advances each sequence on a counter of its own, so a dimension shorter than the rest - would circle at its own pace once the shape repeats. Running every written dimension the same - length keeps a tracker sounding the shape the way the engine here plays it, where a dimension + would circle at its own pace once the instrument repeats. Running every written dimension the same + length keeps a tracker sounding the instrument the way the engine here plays it, where a dimension holds its final value for as long as the note lasts. Args: envelope: The items the dimension states, empty where the channel governs it. - length: The ticks the shape's longest dimension runs. + length: The ticks the instrument's longest dimension runs. Returns: np.ndarray: The dimension's items, empty where the channel governs it. diff --git a/src/sampletones_core/project/voices/note_off.py b/src/sampletones_core/project/voices/note_off.py index eb46b75d2..eda84944b 100644 --- a/src/sampletones_core/project/voices/note_off.py +++ b/src/sampletones_core/project/voices/note_off.py @@ -7,7 +7,7 @@ class NoteOff(BaseModel): Distinct from an empty cell (no command at all): an empty cell lets a sustaining or looped voice keep playing, whereas a note-off explicitly cuts it. Carries no data — its presence is the command — and is rendered as ``--``. ``extra="forbid"`` keeps it disjoint from - :class:`Instrument` so the note-column union round-trips unambiguously. + :class:`NoteOn` so the note-column union round-trips unambiguously. """ model_config = ConfigDict(frozen=True, extra="forbid") diff --git a/src/sampletones_core/project/voices/record.py b/src/sampletones_core/project/voices/record.py index 83158938e..d6adb06cb 100644 --- a/src/sampletones_core/project/voices/record.py +++ b/src/sampletones_core/project/voices/record.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field -from sampletones_core.project.voices.shape import Shape +from sampletones_core.project.voices.instrument import Instrument class SampleRecord(BaseModel): @@ -23,9 +23,9 @@ class SampleRecord(BaseModel): ) -VoiceRecord = Annotated[Union[SampleRecord, Shape], Field(discriminator="kind")] +VoiceRecord = Annotated[Union[SampleRecord, Instrument], Field(discriminator="kind")] """The on-disk form of one voice, told apart by its ``kind``. -A sample is written as a reference to the reconstruction stored beside the document, while a shape +A sample is written as a reference to the reconstruction stored beside the document, while an instrument carries only what it states and is written whole. """ diff --git a/src/sampletones_core/project/voices/voice.py b/src/sampletones_core/project/voices/voice.py index ec1c04e0a..be442d89a 100644 --- a/src/sampletones_core/project/voices/voice.py +++ b/src/sampletones_core/project/voices/voice.py @@ -1,10 +1,10 @@ from typing import Iterable, Iterator, Tuple, Union from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape -VoiceUnion = Union[Sample, Shape] +VoiceUnion = Union[Sample, Instrument] def samples(voices: Iterable[VoiceUnion]) -> Iterator[Sample]: @@ -25,7 +25,7 @@ def samples(voices: Iterable[VoiceUnion]) -> Iterator[Sample]: def voice_channels(voice: VoiceUnion) -> Tuple[ChannelName, ...]: """The channels a voice sounds on. - A sample sounds on the channels its reconstruction found frames for; a shape sounds wherever + A sample sounds on the channels its reconstruction found frames for; an instrument sounds wherever its envelopes make a frame, which is every channel once it writes one. Args: @@ -37,14 +37,14 @@ def voice_channels(voice: VoiceUnion) -> Tuple[ChannelName, ...]: match voice: case Sample(): return voice.reconstruction.playing_channels - case Shape(): + case Instrument(): return tuple(channel for channel in ChannelName.items() if voice.instructions(channel)) def voice_reference(voice: VoiceUnion, channel_name: ChannelName) -> int: """The value a voice's arpeggio is measured against on one channel. - A sample carries the reference its conversion chose for that channel; a shape states the root + A sample carries the reference its conversion chose for that channel; an instrument states the root the reader gave it. A row's transpose is the step from this, whichever kind it names. Args: @@ -57,5 +57,5 @@ def voice_reference(voice: VoiceUnion, channel_name: ChannelName) -> int: match voice: case Sample(): return voice.reconstruction.initial_pitches[channel_name] - case Shape(): + case Instrument(): return voice.reference(channel_name) diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index 2b1c72d75..e6eb24b80 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -8,7 +8,7 @@ SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.0" SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2" -SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.2" +SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1" SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" SAMPLETONES_AUTHOR: Final[str] = "Jakim" diff --git a/tests/suite/performance.py b/tests/suite/performance.py index 52a7ebb28..36b100a6e 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -14,10 +14,10 @@ from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from tests.suite.stems import single_entry_stems_data @@ -130,15 +130,15 @@ def project_with_sample( return project, sample -def project_with_shape( - shape: Shape, +def project_with_instrument( + instrument: Instrument, *, rows_per_pattern: int, settings: Optional[ProjectSettings] = None, ) -> Project: - """A one-shape project, so a case can place a hand-written voice on any channel it likes.""" + """A one-instrument project, so a case can place a hand-written voice on any channel it likes.""" project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings) - project.voices.append(shape) + project.voices.append(instrument) return project diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 2bdce3e4e..21885c538 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -10,10 +10,10 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer -from sampletones_core.project.voices.creation import new_shape +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.shape import Shape from sampletones_core.reconstructions import Reconstruction @@ -503,48 +503,48 @@ def test_set_sample_loop_toggles_loop_flag( assert controller.project.voice(sample.id).loop_point == WHOLE_LOOP_POINT -class TestShapes: - def test_add_shape_appends_the_voice_it_is_given(self) -> None: +class TestInstruments: + def test_add_instrument_appends_the_voice_it_is_given(self) -> None: controller = _controller() - shape = new_shape("lead") + instrument = new_instrument("lead") - added = controller.add_shape(shape) + added = controller.add_instrument(instrument) - assert added is shape - assert controller.project.voice(shape.id) is shape + assert added is instrument + assert controller.project.voice(instrument.id) is instrument - def test_writing_an_envelope_reaches_the_frames_the_shape_sounds(self) -> None: + def test_writing_an_envelope_reaches_the_frames_the_instrument_sounds(self) -> None: controller = _controller() - shape = controller.add_shape(new_shape("lead")) + instrument = controller.add_instrument(new_instrument("lead")) - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 10)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 10)) - assert shape.envelopes.volume == (15, 10) - assert len(shape.instructions(ChannelName.PULSE1)) == 2 + assert instrument.envelopes.volume == (15, 10) + assert len(instrument.instructions(ChannelName.PULSE1)) == 2 def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None: controller = _controller() - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15,)) - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, ()) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, ()) - assert FeatureKey.VOLUME in shape.held_features(ChannelName.PULSE1) + assert FeatureKey.VOLUME in instrument.held_features(ChannelName.PULSE1) def test_moving_the_roots_reaches_the_frames(self) -> None: controller = _controller() - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15,)) - controller.set_shape_root(shape.id, pitch=48, period=3) + controller.set_instrument_root(instrument.id, pitch=48, period=3) - assert shape.reference(ChannelName.PULSE1) == 48 - assert shape.reference(ChannelName.NOISE) == 3 - first = shape.instructions(ChannelName.PULSE1)[0] + assert instrument.reference(ChannelName.PULSE1) == 48 + assert instrument.reference(ChannelName.NOISE) == 3 + first = instrument.instructions(ChannelName.PULSE1)[0] assert isinstance(first, PulseInstruction) assert first.pitch == 48 - def test_a_sample_takes_no_shape_edit( + def test_a_sample_takes_no_instrument_edit( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: @@ -552,25 +552,25 @@ def test_a_sample_takes_no_shape_edit( sample = controller.add_sample(reconstruction_factory(), name="bass") with pytest.raises(TypeError): - controller.set_shape_envelope(sample.id, FeatureKey.VOLUME, (15,)) + controller.set_instrument_envelope(sample.id, FeatureKey.VOLUME, (15,)) - def test_a_shape_takes_no_reconstruction(self) -> None: + def test_an_instrument_takes_no_reconstruction(self) -> None: controller = _controller() - shape = controller.add_shape(new_shape("lead")) + instrument = controller.add_instrument(new_instrument("lead")) with pytest.raises(TypeError): - controller.replace_sample_reconstruction(shape.id, Mock()) + controller.replace_sample_reconstruction(instrument.id, Mock()) - def test_a_shape_duplicates_into_a_voice_of_its_own(self) -> None: + def test_an_instrument_duplicates_into_a_voice_of_its_own(self) -> None: controller = _controller() - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15,)) + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15,)) - clone = controller.duplicate_voice(shape.id) + clone = controller.duplicate_voice(instrument.id) - assert clone.id != shape.id - assert isinstance(clone, Shape) - assert clone.envelopes == shape.envelopes + assert clone.id != instrument.id + assert isinstance(clone, Instrument) + assert clone.envelopes == instrument.envelopes class TestPatternManagement: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index 718e915a1..833fce232 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -6,12 +6,12 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.reconstruction.editing import ReconstructionEdit, ShapeEdit +from sampletones_application.logic.reconstruction.editing import InstrumentEdit, ReconstructionEdit from sampletones_application.logic.reconstruction.editor import InstrumentEditor from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features -from sampletones_core.project.voices.creation import new_shape +from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT ROOT_PITCH: Final[int] = 55 @@ -63,98 +63,98 @@ def test_a_loaded_reconstruction_answers_with_its_channels( assert isinstance(edit, ReconstructionEdit) assert list(edit.channels) == [ChannelName.PULSE1] - def test_a_shape_answers_with_what_it_states( + def test_an_instrument_answers_with_what_it_states( self, editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) - editor.edit_shape(shape.id) + editor.edit_instrument(instrument.id) edit = editor.edited_instrument() - assert isinstance(edit, ShapeEdit) - assert (edit.voice_id, edit.name) == (shape.id, "lead") + assert isinstance(edit, InstrumentEdit) + assert (edit.voice_id, edit.name) == (instrument.id, "lead") assert (edit.root_pitch, edit.root_period) == (ROOT_PITCH, ROOT_PERIOD) - def test_opening_a_shape_closes_the_reconstruction_the_tab_held( + def test_opening_an_instrument_closes_the_reconstruction_the_tab_held( self, editor: InstrumentEditor, controller: ProjectController, reconstruction_manager: MagicMock, ) -> None: """The tab describes one voice, so its waveform and stems follow what is in front of it.""" - shape = controller.add_shape(new_shape("lead")) + instrument = controller.add_instrument(new_instrument("lead")) - editor.edit_shape(shape.id) + editor.edit_instrument(instrument.id) reconstruction_manager.close_reconstruction.assert_called_once_with() - def test_letting_go_of_a_shape_hands_the_tab_back( + def test_letting_go_of_an_instrument_hands_the_tab_back( self, editor: InstrumentEditor, controller: ProjectController, reconstruction_manager: MagicMock, ) -> None: - shape = controller.add_shape(new_shape("lead")) - editor.edit_shape(shape.id) + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) reconstruction_manager.current_features = MagicMock(channels={ChannelName.PULSE1: _features()}) - editor.release_shape() + editor.release_instrument() assert isinstance(editor.edited_instrument(), ReconstructionEdit) - def test_a_shape_removed_from_the_project_leaves_the_tab_holding_nothing( + def test_an_instrument_removed_from_the_project_leaves_the_tab_holding_nothing( self, editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape(new_shape("lead")) - editor.edit_shape(shape.id) + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) - controller.remove_voice(shape.id) + controller.remove_voice(instrument.id) assert editor.edited_instrument() is None -class TestWritingIntoTheShape: - def test_an_envelope_reaches_the_shape( +class TestWritingIntoTheInstrument: + def test_an_envelope_reaches_the_instrument( self, editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape(new_shape("lead")) - editor.edit_shape(shape.id) + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) - assert shape.envelopes.volume == VOLUME + assert instrument.envelopes.volume == VOLUME - def test_the_roots_reach_the_shape( + def test_the_roots_reach_the_instrument( self, editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape(new_shape("lead")) - editor.edit_shape(shape.id) + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) editor.write_roots(pitch=ROOT_PITCH, period=ROOT_PERIOD) - assert (shape.root_pitch, shape.root_period) == (ROOT_PITCH, ROOT_PERIOD) + assert (instrument.root_pitch, instrument.root_period) == (ROOT_PITCH, ROOT_PERIOD) - def test_the_loop_point_reaches_the_shape( + def test_the_loop_point_reaches_the_instrument( self, editor: InstrumentEditor, controller: ProjectController, ) -> None: - shape = controller.add_shape(new_shape("lead")) - editor.edit_shape(shape.id) + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) editor.write_loop_point(WHOLE_LOOP_POINT) - assert shape.loop_point == WHOLE_LOOP_POINT + assert instrument.loop_point == WHOLE_LOOP_POINT - def test_a_write_with_no_shape_in_front_is_refused(self, editor: InstrumentEditor) -> None: + def test_a_write_with_no_instrument_in_front_is_refused(self, editor: InstrumentEditor) -> None: with pytest.raises(TypeError): editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 9c63e4069..e2e710667 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from sampletones_application.constants.instruments import SHAPE_CHANNEL +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager @@ -23,7 +23,7 @@ features_footprint, total_footprint, ) -from sampletones_core.project.voices.creation import new_shape +from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction @@ -316,15 +316,15 @@ def test_no_pending_update_is_a_no_op( callback.assert_not_called() -class TestTheInstrumentsPanelShowsAShape: - """A shape stands on no audio, so the panel shows one instrument and writes edits at once.""" +class TestTheInstrumentsPanelShowsAnInstrument: + """An instrument stands on no audio, so the panel shows one instrument and writes edits at once.""" @pytest.fixture def project_controller(self) -> ProjectController: return ProjectController(ProjectManager()) @pytest.fixture - def shape_logic( + def instrument_logic( self, mock_reconstruction_manager: MagicMock, project_controller: ProjectController, @@ -332,88 +332,88 @@ def shape_logic( ) -> ReconstructionInstrumentsLogic: mock_reconstruction_manager.current_features = None editor = InstrumentEditor(mock_reconstruction_manager, project_controller) - shape = project_controller.add_shape(new_shape("lead")) - project_controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12)) - editor.edit_shape(shape.id) + instrument = project_controller.add_instrument(new_instrument("lead")) + project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12)) + editor.edit_instrument(instrument.id) return ReconstructionInstrumentsLogic(editor, scheduling=scheduling) - def test_the_view_names_the_shape_it_shows( + def test_the_view_names_the_instrument_it_shows( self, - shape_logic: ReconstructionInstrumentsLogic, + instrument_logic: ReconstructionInstrumentsLogic, ) -> None: received: List[ReconstructionInstrumentsViewModel] = [] - shape_logic.on_view_changed = received.append + instrument_logic.on_view_changed = received.append - shape_logic.update_display() + instrument_logic.update_display() - assert received[-1].edits_a_shape is True - assert received[-1].shape is not None - assert received[-1].shape.name == "lead" + assert received[-1].edits_an_instrument is True + assert received[-1].instrument is not None + assert received[-1].instrument.name == "lead" def test_the_envelopes_are_drawn_under_the_channel_that_reads_them_all( self, - shape_logic: ReconstructionInstrumentsLogic, + instrument_logic: ReconstructionInstrumentsLogic, ) -> None: received: List[Optional[Dict[ChannelName, Features]]] = [] - shape_logic.on_feature_data_changed = received.append + instrument_logic.on_feature_data_changed = received.append - shape_logic.update_display() + instrument_logic.update_display() assert received[-1] is not None - assert list(received[-1]) == [SHAPE_CHANNEL] + assert list(received[-1]) == [INSTRUMENT_CHANNEL] - def test_an_envelope_edit_reaches_the_shape_without_a_regeneration( + def test_an_envelope_edit_reaches_the_instrument_without_a_regeneration( self, - shape_logic: ReconstructionInstrumentsLogic, + instrument_logic: ReconstructionInstrumentsLogic, project_controller: ProjectController, ) -> None: regenerated: List[object] = [] - shape_logic.on_reconstruction_instrument_updated = lambda *args: regenerated.append(args) + instrument_logic.on_reconstruction_instrument_updated = lambda *args: regenerated.append(args) - shape_logic.handle_raw_data_changed( - SHAPE_CHANNEL, + instrument_logic.handle_raw_data_changed( + INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO, np.array([0, 7], dtype=np.int8), ) - shape = project_controller.project.voices[project_controller.project.voices[0].id] - assert shape.envelopes.arpeggio == (0, 7) + instrument = project_controller.project.voices[project_controller.project.voices[0].id] + assert instrument.envelopes.arpeggio == (0, 7) assert regenerated == [] - def test_the_pitch_stepper_moves_the_shapes_tonal_root( + def test_the_pitch_stepper_moves_the_instruments_tonal_root( self, - shape_logic: ReconstructionInstrumentsLogic, + instrument_logic: ReconstructionInstrumentsLogic, project_controller: ProjectController, ) -> None: - shape_logic.handle_pitch_value_changed(SHAPE_CHANNEL, 48) + instrument_logic.handle_pitch_value_changed(INSTRUMENT_CHANNEL, 48) assert project_controller.project.voices[0].root_pitch == 48 - def test_the_loop_point_reaches_the_shape( + def test_the_loop_point_reaches_the_instrument( self, - shape_logic: ReconstructionInstrumentsLogic, + instrument_logic: ReconstructionInstrumentsLogic, project_controller: ProjectController, ) -> None: - shape_logic.handle_shape_loop_point_changed(WHOLE_LOOP_POINT) + instrument_logic.handle_instrument_loop_point_changed(WHOLE_LOOP_POINT) assert project_controller.project.voices[0].loop_point == WHOLE_LOOP_POINT def test_the_figure_measures_the_one_instrument_it_exports( self, - shape_logic: ReconstructionInstrumentsLogic, + instrument_logic: ReconstructionInstrumentsLogic, project_controller: ProjectController, ) -> None: received: List[ReconstructionInstrumentsViewModel] = [] - shape_logic.on_view_changed = received.append + instrument_logic.on_view_changed = received.append - shape_logic.update_display() + instrument_logic.update_display() - shape = project_controller.project.voices[0] + instrument = project_controller.project.voices[0] assert received[-1].footprint is not None assert ( received[-1].footprint.total_bytes == features_footprint( - shape.instrument_features(), - loop_point=shape.loop_point, + instrument.instrument_features(), + loop_point=instrument.loop_point, ).total_bytes ) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 0d6637169..3a84ac6a8 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -14,9 +14,9 @@ features_footprint, reconstruction_footprints, ) +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.shape import Shape from sampletones_core.reconstructions import Reconstruction from tests.suite.sequencer import sample_reconstruction @@ -315,55 +315,55 @@ def test_request_edit_cancels_pending_preview( audio_device_manager.play.assert_not_called() -class TestShapesInTheVoiceList: +class TestInstrumentsInTheVoiceList: """A hand-written voice sits in the same list as a converted one, marked by its kind.""" - def test_a_shape_is_listed_beside_the_samples_that_were_added( + def test_an_instrument_is_listed_beside_the_samples_that_were_added( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="bass") - shape = logic.add_shape("lead") + instrument = logic.add_instrument("lead") entries = logic.build_voices().voices assert [(entry.voice_id, entry.kind) for entry in entries] == [ (sample.id, VoiceKind.SAMPLE), - (shape.id, VoiceKind.SHAPE), + (instrument.id, VoiceKind.INSTRUMENT), ] - def test_a_shape_is_measured_as_the_one_instrument_it_exports(self) -> None: + def test_an_instrument_is_measured_as_the_one_export_it_writes(self) -> None: controller, logic = _logic() - shape = logic.add_shape("lead") - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12, 9)) + instrument = logic.add_instrument("lead") + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12, 9)) - footprint = logic.build_voice_footprint(shape.id) + footprint = logic.build_voice_footprint(instrument.id) assert footprint is not None assert ( footprint.total_bytes == features_footprint( - shape.instrument_features(), - loop_point=shape.loop_point, + instrument.instrument_features(), + loop_point=instrument.loop_point, ).total_bytes ) assert [instrument.channel for instrument in footprint.instruments] == [None] - def test_a_shape_previews_through_the_pulse_channel(self) -> None: + def test_an_instrument_previews_through_the_pulse_channel(self) -> None: controller, logic, session_manager, audio_device_manager = _logic_with_mocks() - shape = logic.add_shape("lead") - controller.set_shape_envelope(shape.id, FeatureKey.VOLUME, (15, 12)) + instrument = logic.add_instrument("lead") + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12)) - logic.play_voice(shape.id) + logic.play_voice(instrument.id) played = audio_device_manager.play.call_args.args[0] assert played.size > 0 - def test_a_shape_writing_nothing_sounds_no_preview(self) -> None: + def test_an_instrument_writing_nothing_sounds_no_preview(self) -> None: controller, logic, _, audio_device_manager = _logic_with_mocks() - shape = controller.add_shape(Shape(name="lead")) + instrument = controller.add_instrument(Instrument(name="lead")) - logic.play_voice(shape.id) + logic.play_voice(instrument.id) audio_device_manager.play.assert_not_called() diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py index d6c4854ef..aa287d0ed 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -4,11 +4,11 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.voices.creation import new_shape -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.shape import Shape from sampletones_core.utils.display import NOTE_BLANK, display_transpose from sampletones_core.utils.frequencies import period_to_name, pitch_to_name from tests.suite.sequencer import sample_reconstruction @@ -24,12 +24,12 @@ def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]: return controller, SequencerTrackerLogic(controller) -def _shape(controller: ProjectController) -> Shape: - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) - shape.envelopes = ShapeEnvelopes(volume=(15,)) - shape.invalidate() - return shape +def _instrument(controller: ProjectController) -> Instrument: + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) + instrument.envelopes = InstrumentEnvelopes(volume=(15,)) + instrument.invalidate() + return instrument def _write( @@ -62,34 +62,34 @@ def test_a_sample_reads_as_a_step_from_its_own_pitch(self) -> None: assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(TRANSPOSE) - def test_a_shape_reads_as_the_note_it_sounds(self) -> None: + def test_an_instrument_reads_as_the_note_it_sounds(self) -> None: controller, logic = _logic() - shape = _shape(controller) - _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=TRANSPOSE) + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=TRANSPOSE) assert _pitch_cell(logic, ChannelName.PULSE1, 0) == pitch_to_name(ROOT_PITCH + TRANSPOSE) - def test_a_shape_on_noise_names_its_period(self) -> None: + def test_an_instrument_on_noise_names_its_period(self) -> None: controller, logic = _logic() - shape = _shape(controller) - _write(controller, ChannelName.NOISE, 0, command=NoteOn(voice_id=shape.id), transpose=TRANSPOSE) + instrument = _instrument(controller) + _write(controller, ChannelName.NOISE, 0, command=NoteOn(voice_id=instrument.id), transpose=TRANSPOSE) assert _pitch_cell(logic, ChannelName.NOISE, 0) == period_to_name(ROOT_PERIOD + TRANSPOSE) def test_an_empty_cell_reads_blank_whichever_voice_is_carried(self) -> None: controller, logic = _logic() - shape = _shape(controller) - _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=TRANSPOSE) + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=TRANSPOSE) assert _pitch_cell(logic, ChannelName.PULSE1, 1) == NOTE_BLANK class TestTheFaceFollowsTheVoiceTheChannelCarries: - def test_a_bend_below_a_shape_still_reads_as_a_note(self) -> None: + def test_a_bend_below_an_instrument_still_reads_as_a_note(self) -> None: """A row bending a note it did not start reads in the terms of the voice in force.""" controller, logic = _logic() - shape = _shape(controller) - _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=0) + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0) _write(controller, ChannelName.PULSE1, 1, transpose=BEND) assert _pitch_cell(logic, ChannelName.PULSE1, 1) == pitch_to_name(ROOT_PITCH + BEND) @@ -104,8 +104,8 @@ def test_a_bend_below_a_sample_still_reads_as_a_step(self) -> None: def test_a_note_off_hands_the_column_back_to_the_neutral_face(self) -> None: controller, logic = _logic() - shape = _shape(controller) - _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=0) + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0) _write(controller, ChannelName.PULSE1, 1, command=NoteOff()) _write(controller, ChannelName.PULSE1, 2, transpose=BEND) @@ -119,19 +119,19 @@ def test_a_frame_naming_no_voice_reads_as_a_step(self) -> None: class TestTheSampleColumnSpeaksForSamples: - def test_it_declines_a_shape(self) -> None: + def test_it_declines_an_instrument(self) -> None: controller, logic = _logic() - shape = _shape(controller) + instrument = _instrument(controller) - logic.set_sample_instrument(0, shape.id) + logic.set_sample_instrument(0, instrument.id) assert all(logic.row(channel, 0) is None or logic.row(channel, 0).is_empty() for channel in ChannelName.items()) def test_it_reads_mixed_where_the_channels_disagree_on_the_face(self) -> None: controller, logic = _logic() - shape = _shape(controller) + instrument = _instrument(controller) sample = controller.add_sample(sample_reconstruction(list(ChannelName.items())), name="bass") - _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=shape.id), transpose=0) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0) _write(controller, ChannelName.PULSE2, 0, command=NoteOn(voice_id=sample.id), transpose=0) assert logic.build_grid().rows[0].sample_transpose == "?" diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py index 6d92d4ea7..c4f604035 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py @@ -4,8 +4,8 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.voices.creation import new_shape -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.voice import voice_reference @@ -36,13 +36,13 @@ def _transpose(logic: SequencerTrackerLogic, channel: ChannelName, row_index: in class TestATypedNoteIsStatedAsAStepFromTheVoice: - def test_a_shape_takes_the_step_that_reaches_the_note(self) -> None: + def test_an_instrument_takes_the_step_that_reaches_the_note(self) -> None: controller, logic = _logic() - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=8) - shape.envelopes = ShapeEnvelopes(volume=(15,)) - shape.invalidate() - _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=8) + instrument.envelopes = InstrumentEnvelopes(volume=(15,)) + instrument.invalidate() + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH) @@ -60,9 +60,9 @@ def test_a_sample_takes_the_step_from_its_own_pitch(self) -> None: def test_a_row_below_the_note_is_measured_against_the_voice_it_carries(self) -> None: controller, logic = _logic() - shape = controller.add_shape(new_shape("lead")) - controller.set_shape_root(shape.id, pitch=ROOT_PITCH, period=8) - _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) + instrument = controller.add_instrument(new_instrument("lead")) + controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=8) + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH) @@ -77,8 +77,8 @@ def test_a_row_carrying_no_voice_is_left_as_it_stands(self) -> None: def test_a_row_past_a_note_off_carries_no_voice(self) -> None: controller, logic = _logic() - shape = controller.add_shape(new_shape("lead")) - _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=shape.id)) + instrument = controller.add_instrument(new_instrument("lead")) + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) _write(controller, ChannelName.PULSE1, 1, NoteOff()) logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index 01a3eb846..98a6b4413 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -143,7 +143,7 @@ def _panel( panel.on_duplicate_requested = requests.duplicated.append panel.on_remove_requested = requests.removed.append panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target)) - panel.on_new_shape_requested = lambda: requests.pool.append(SequencerVoicesElements.NEW_SHAPE.value) + panel.on_new_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.NEW_INSTRUMENT.value) panel.on_add_sample_requested = lambda: requests.pool.append(SequencerVoicesElements.ADD_SAMPLE.value) monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) return VoicesPanelFixture(panel=panel, requests=requests) @@ -422,7 +422,7 @@ def test_the_list_menu_prints_the_ways_a_voice_comes_in( fixture.panel._show_list_menu() assert [widget.text for widget in build_recorder.widgets] == [ - SequencerVoicesElements.NEW_SHAPE.value, + SequencerVoicesElements.NEW_INSTRUMENT.value, SequencerVoicesElements.ADD_SAMPLE.value, ] @@ -437,7 +437,7 @@ def test_a_row_menu_carries_the_pool_section_below_the_voice_actions( items = [widget.text for widget in build_recorder.widgets if widget.kind == "item"] assert items[-2:] == [ - SequencerVoicesElements.NEW_SHAPE.value, + SequencerVoicesElements.NEW_INSTRUMENT.value, SequencerVoicesElements.ADD_SAMPLE.value, ] assert SequencerVoicesElements.CONTEXT_EDIT.value in items @@ -454,7 +454,7 @@ def test_the_items_ask_for_a_written_voice_and_for_a_located_one( item.callback() assert fixture.requests.pool == [ - SequencerVoicesElements.NEW_SHAPE.value, + SequencerVoicesElements.NEW_INSTRUMENT.value, SequencerVoicesElements.ADD_SAMPLE.value, ] diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py index 274c735b7..265dccfea 100644 --- a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py +++ b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py @@ -1,6 +1,13 @@ from typing import Any, Dict -from sampletones_core.compatibility.fields import CHANNEL_NAME, NAME +from sampletones_core.compatibility.fields import ( + KIND, + KIND_SAMPLE, + NAME, + SAMPLES, + VOICE_ID, + VOICES, +) from sampletones_core.compatibility.project.v1_1 import update @@ -12,12 +19,24 @@ def _pool_with_row(command: Dict[str, Any]) -> Dict[str, Any]: return {"generator": "pulse1", "patterns": {"0": {"rows": [{"command": command}]}}} +def _song(command: Dict[str, Any]) -> Dict[str, Any]: + return {"song": {"channels": {"pulse1": _pool_with_row(command)}}} + + def _first_command(data: Dict[str, Any]) -> Dict[str, Any]: command: Dict[str, Any] = data["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"][0]["command"] return command class TestProjectV1_1: + def test_gathers_samples_into_voices(self) -> None: + data = {SAMPLES: [{"id": "a", "name": "Lead", "reconstruction_id": "r"}]} + + upgraded = update(data) + + assert SAMPLES not in upgraded + assert upgraded[VOICES] == [{KIND: KIND_SAMPLE, "id": "a", "name": "Lead", "reconstruction_id": "r"}] + def test_renames_channel_pool_field(self) -> None: data = {"song": {"channels": {"pulse1": _pool({})}}} @@ -26,30 +45,28 @@ def test_renames_channel_pool_field(self) -> None: assert upgraded["song"]["channels"]["pulse1"][NAME] == "pulse1" assert "generator" not in upgraded["song"]["channels"]["pulse1"] - def test_renames_instrument_command_channel(self) -> None: - data = {"song": {"channels": {"pulse1": _pool_with_row({"sample_id": "s", "generator_name": "pulse1"})}}} + def test_names_the_voice_alone(self) -> None: + data = _song({"sample_id": "a", "generator_name": "pulse1"}) upgraded = update(data) - command = _first_command(upgraded) - assert command[CHANNEL_NAME] == "pulse1" - assert "generator_name" not in command + assert _first_command(upgraded) == {VOICE_ID: "a"} def test_leaves_note_off_commands_untouched(self) -> None: - data = {"song": {"channels": {"pulse1": _pool_with_row({})}}} - - upgraded = update(data) + data = _song({}) - assert _first_command(upgraded) == {} + assert _first_command(update(data)) == {} def test_leaves_the_input_untouched(self) -> None: - data = {"song": {"channels": {"pulse1": _pool({})}}} + data = {SAMPLES: [{"id": "a", "name": "Lead"}], **_song({"sample_id": "a", "generator_name": "pulse1"})} update(data) + assert SAMPLES in data assert data["song"]["channels"]["pulse1"]["generator"] == "pulse1" + assert _first_command(data) == {"sample_id": "a", "generator_name": "pulse1"} - def test_document_without_a_song_stays_the_same_shape(self) -> None: + def test_document_without_samples_or_a_song_stays_the_same_shape(self) -> None: data = {"format_version": "1.0"} assert update(data) == data diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_2.py b/tests/unit/sampletones_core/compatibility/project/test_v1_2.py deleted file mode 100644 index ccc03196f..000000000 --- a/tests/unit/sampletones_core/compatibility/project/test_v1_2.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Any, Dict - -from sampletones_core.compatibility.fields import KIND, KIND_SAMPLE, SAMPLES, VOICES -from sampletones_core.compatibility.project.v1_2 import update - - -def _pool_with_row(command: Dict[str, Any]) -> Dict[str, Any]: - return {"name": "pulse1", "patterns": {"0": {"rows": [{"command": command}]}}} - - -def _first_command(data: Dict[str, Any]) -> Dict[str, Any]: - command: Dict[str, Any] = data["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"][0]["command"] - return command - - -class TestProjectV1_2: - def test_gathers_samples_into_voices(self) -> None: - data = {SAMPLES: [{"id": "a", "name": "Lead", "reconstruction_id": "r"}]} - - upgraded = update(data) - - assert SAMPLES not in upgraded - assert upgraded[VOICES] == [{KIND: KIND_SAMPLE, "id": "a", "name": "Lead", "reconstruction_id": "r"}] - - def test_names_the_voice_alone(self) -> None: - data = {"song": {"channels": {"pulse1": _pool_with_row({"sample_id": "a", "channel_name": "pulse1"})}}} - - upgraded = update(data) - - assert _first_command(upgraded) == {"voice_id": "a"} - - def test_leaves_note_off_commands_untouched(self) -> None: - data = {"song": {"channels": {"pulse1": _pool_with_row({})}}} - - assert _first_command(update(data)) == {} - - def test_leaves_the_input_untouched(self) -> None: - data = {SAMPLES: [{"id": "a", "name": "Lead", "reconstruction_id": "r"}]} - - update(data) - - assert SAMPLES in data - - def test_document_without_samples_or_a_song_stays_the_same_shape(self) -> None: - data = {"format_version": "1.1"} - - assert update(data) == data diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index a80c7093c..3ff6977b3 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -9,9 +9,9 @@ ) from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.structures import IdentifiedCollection from tests.suite.sequencer import sample_reconstruction @@ -31,8 +31,8 @@ def _sample(name: str, channels: Sequence[ChannelName]) -> Sample: return Sample(name=name, reconstruction=sample_reconstruction(list(channels))) -def _shape(name: str) -> Shape: - return Shape(name=name, envelopes=ShapeEnvelopes(volume=(15, 10), arpeggio=(0, 5))) +def _instrument(name: str) -> Instrument: + return Instrument(name=name, envelopes=InstrumentEnvelopes(volume=(15, 10), arpeggio=(0, 5))) def _stand_by(sample: Sample, channel: ChannelName) -> None: @@ -67,22 +67,22 @@ def test_a_channel_standing_by_takes_no_slice(self) -> None: assert [voice_slice.channel for voice_slice in slices] == [ChannelName.PULSE2] - def test_a_shape_contributes_a_slice_for_every_channel_it_sounds_on(self) -> None: - project = _project([_shape("lead")]) + def test_an_instrument_contributes_a_slice_for_every_channel_it_sounds_on(self) -> None: + project = _project([_instrument("lead")]) slices = list(iterate_voice_slices(project)) assert [voice_slice.channel for voice_slice in slices] == ChannelName.items() - def test_each_of_a_shapes_slices_is_measured_against_that_channels_root(self) -> None: - shape = _shape("lead") - project = _project([shape]) + def test_each_of_an_instruments_slices_is_measured_against_that_channels_root(self) -> None: + instrument = _instrument("lead") + project = _project([instrument]) for voice_slice in iterate_voice_slices(project): - assert voice_slice.features.initial_pitch == shape.reference(voice_slice.channel) + assert voice_slice.features.initial_pitch == instrument.reference(voice_slice.channel) - def test_a_shape_writing_nothing_contributes_no_slice(self) -> None: - project = _project([Shape(name="empty")]) + def test_an_instrument_writing_nothing_contributes_no_slice(self) -> None: + project = _project([Instrument(name="empty")]) assert list(iterate_voice_slices(project)) == [] @@ -98,8 +98,8 @@ def test_a_sample_yields_one_instrument_per_playing_channel(self) -> None: assert [entry.index for entry in entries] == [0, 1] assert [list(entry.slots) for entry in entries] == [[ChannelName.PULSE1], [ChannelName.NOISE]] - def test_a_shape_yields_one_instrument_every_channel_reaches(self) -> None: - project = _project([_shape("lead")]) + def test_an_instrument_takes_one_table_entry_every_channel_reaches(self) -> None: + project = _project([_instrument("lead")]) entries = list(iterate_instrument_entries(project)) @@ -107,17 +107,17 @@ def test_a_shape_yields_one_instrument_every_channel_reaches(self) -> None: assert list(entries[0].slots) == ChannelName.items() assert {slot.index for slot in entries[0].slots.values()} == {0} - def test_a_shapes_slots_each_carry_that_channels_root(self) -> None: - shape = _shape("lead") - project = _project([shape]) + def test_an_instruments_slots_each_carry_that_channels_root(self) -> None: + instrument = _instrument("lead") + project = _project([instrument]) entry = next(iter(iterate_instrument_entries(project))) for channel, slot in entry.slots.items(): - assert slot.initial_pitch == shape.reference(channel) + assert slot.initial_pitch == instrument.reference(channel) - def test_a_shape_is_named_by_itself_and_a_sample_slice_by_its_channel(self) -> None: - project = _project([_shape("lead"), _sample("pad", [ChannelName.TRIANGLE])]) + def test_an_instrument_is_named_by_itself_and_a_sample_slice_by_its_channel(self) -> None: + project = _project([_instrument("lead"), _sample("pad", [ChannelName.TRIANGLE])]) entries = list(iterate_instrument_entries(project)) @@ -128,7 +128,7 @@ def test_instruments_are_numbered_across_the_voices_in_order(self) -> None: project = _project( [ _sample("lead", [ChannelName.PULSE1]), - _shape("hand"), + _instrument("hand"), _sample("pad", [ChannelName.TRIANGLE, ChannelName.NOISE]), ] ) @@ -146,8 +146,8 @@ def test_a_channel_standing_by_shifts_no_index_behind_it(self) -> None: assert [(entry.index, list(entry.slots)) for entry in entries] == [(0, [ChannelName.PULSE2])] - def test_a_shape_writing_nothing_takes_no_place_in_the_table(self) -> None: - project = _project([Shape(name="empty"), _sample("pad", [ChannelName.TRIANGLE])]) + def test_an_instrument_writing_nothing_takes_no_place_in_the_table(self) -> None: + project = _project([Instrument(name="empty"), _sample("pad", [ChannelName.TRIANGLE])]) entries = list(iterate_instrument_entries(project)) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_shape_document.py b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py similarity index 80% rename from tests/unit/sampletones_core/formats/bitphase/test_shape_document.py rename to tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py index ed24e2fb7..3329b2923 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_shape_document.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py @@ -5,32 +5,32 @@ from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.shape import Shape ROWS_PER_PATTERN: Final[int] = 4 VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) ARPEGGIO: Final[Tuple[int, ...]] = (0, 4, 7) -def _project(*channels: ChannelName, loop_point: int | None = None) -> Tuple[Project, Shape]: - shape = Shape( +def _project(*channels: ChannelName, loop_point: int | None = None) -> Tuple[Project, Instrument]: + instrument = Instrument( name="Lead", - envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), + envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), loop_point=loop_point, ) project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) - project.voices.append(shape) + project.voices.append(instrument) for channel in channels: pattern = project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN) - pattern.rows[0] = Row(command=NoteOn(voice_id=shape.id)) + pattern.rows[0] = Row(command=NoteOn(voice_id=instrument.id)) project.song.set_order_entry(0, channel, 0) - return project, shape + return project, instrument -class TestAShapeReachesTheDocument: +class TestAnInstrumentReachesTheDocument: def test_each_channel_it_sounds_on_takes_an_instrument_of_its_own(self) -> None: """Bitphase bakes registers per tick, so a channel's rows carry that channel's reading.""" project, _ = _project(ChannelName.PULSE1, ChannelName.NOISE) @@ -46,7 +46,7 @@ def test_every_instrument_runs_the_ticks_its_envelopes_describe(self) -> None: assert all(len(instrument.rows) == len(VOLUME) for instrument in document.instruments) - def test_a_looping_shape_returns_to_its_loop_point(self) -> None: + def test_a_looping_instrument_returns_to_its_loop_point(self) -> None: project, _ = _project(ChannelName.PULSE1, loop_point=1) document = project_to_bitphase(project) @@ -60,7 +60,7 @@ def test_a_one_shot_rests_on_its_final_row(self) -> None: assert all(instrument.loop == len(instrument.rows) - 1 for instrument in document.instruments) - def test_the_table_carries_the_shapes_contour(self) -> None: + def test_the_table_carries_the_instruments_contour(self) -> None: project, _ = _project(ChannelName.PULSE1) document = project_to_bitphase(project) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_shape_module.py b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py similarity index 62% rename from tests/unit/sampletones_core/formats/famitracker/test_shape_module.py rename to tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py index f2d22b547..b0f390a11 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_shape_module.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py @@ -14,9 +14,9 @@ ) from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.shape import Shape ROWS_PER_PATTERN: Final[int] = 4 VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) @@ -26,20 +26,20 @@ TRANSPOSE: Final[int] = 5 -def _shape(loop_point: int | None = None) -> Shape: - return Shape( +def _instrument(loop_point: int | None = None) -> Instrument: + return Instrument( name="Lead", - envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), + envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), loop_point=loop_point, ) -def _project(shape: Shape, *channels: ChannelName, transpose: int = 0) -> Project: +def _project(instrument: Instrument, *channels: ChannelName, transpose: int = 0) -> Project: project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) - project.voices.append(shape) + project.voices.append(instrument) for channel in channels: pattern = project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN) - pattern.rows[0] = Row(command=NoteOn(voice_id=shape.id), transpose=transpose) + pattern.rows[0] = Row(command=NoteOn(voice_id=instrument.id), transpose=transpose) project.song.set_order_entry(0, channel, 0) return project @@ -49,19 +49,19 @@ def _rows(patterns: List[PatternData], channel: ChannelName) -> List[object]: return [row for pattern in patterns if pattern.channel == CHANNEL_TO_ID[channel] for row in pattern.rows] -class TestAShapeReachesTheModule: - def test_a_shape_used_on_several_channels_is_written_once(self) -> None: - shape = _shape() - project = _project(shape, ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.NOISE) +class TestAnInstrumentReachesTheModule: + def test_an_instrument_used_on_several_channels_is_written_once(self) -> None: + instrument = _instrument() + project = _project(instrument, ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.NOISE) instruments, slots = build_instrument_table(project) assert len(instruments) == 1 assert instruments[0].name == "Lead" - assert {slots[(shape.id, channel)].index for channel in ChannelName.items()} == {0} + assert {slots[(instrument.id, channel)].index for channel in ChannelName.items()} == {0} - def test_the_instrument_carries_every_dimension_the_shape_writes(self) -> None: - instruments, _ = build_instrument_table(_project(_shape(), ChannelName.PULSE1)) + def test_the_table_entry_carries_every_dimension_the_instrument_writes(self) -> None: + instruments, _ = build_instrument_table(_project(_instrument(), ChannelName.PULSE1)) sequences = instruments[0].sequences assert sequences[SequenceKind.VOLUME].items == VOLUME @@ -69,60 +69,60 @@ def test_the_instrument_carries_every_dimension_the_shape_writes(self) -> None: assert sequences[SequenceKind.DUTY].items == DUTY_CYCLE * len(VOLUME) def test_a_one_shot_leaves_every_loop_point_unset(self) -> None: - instruments, _ = build_instrument_table(_project(_shape(), ChannelName.PULSE1)) + instruments, _ = build_instrument_table(_project(_instrument(), ChannelName.PULSE1)) assert all(sequence.loop_point == NO_LOOP_POINT for sequence in instruments[0].sequences.values()) def test_a_loop_point_reaches_every_populated_sequence(self) -> None: - instruments, _ = build_instrument_table(_project(_shape(TAIL_LOOP_POINT), ChannelName.PULSE1)) + instruments, _ = build_instrument_table(_project(_instrument(TAIL_LOOP_POINT), ChannelName.PULSE1)) populated = [sequence for sequence in instruments[0].sequences.values() if sequence.items] assert [sequence.loop_point for sequence in populated] == [TAIL_LOOP_POINT] * len(populated) def test_a_shorter_dimension_runs_the_length_of_the_longest(self) -> None: """A tracker advances each sequence on its own counter, so they must share a length.""" - shape = Shape( + instrument = Instrument( name="Lead", - envelopes=ShapeEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE), + envelopes=InstrumentEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE), loop_point=TAIL_LOOP_POINT, ) - instruments, _ = build_instrument_table(_project(shape, ChannelName.PULSE1)) + instruments, _ = build_instrument_table(_project(instrument, ChannelName.PULSE1)) duty = instruments[0].sequences[SequenceKind.DUTY] assert duty.items == DUTY_CYCLE * len(VOLUME) assert duty.loop_point == TAIL_LOOP_POINT - def test_a_looping_shape_still_repeats_from_the_start(self) -> None: - instruments, _ = build_instrument_table(_project(_shape(LOOP_FROM_START), ChannelName.PULSE1)) + def test_a_looping_instrument_still_repeats_from_the_start(self) -> None: + instruments, _ = build_instrument_table(_project(_instrument(LOOP_FROM_START), ChannelName.PULSE1)) populated = [sequence for sequence in instruments[0].sequences.values() if sequence.items] assert all(sequence.loop_point == LOOP_FROM_START for sequence in populated) -class TestTheRowsNameTheShapesRoot: +class TestTheRowsNameTheInstrumentsRoot: @pytest.mark.parametrize( "channel", [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE], ) def test_a_tonal_row_states_the_root_moved_by_its_transpose(self, channel: ChannelName) -> None: - shape = _shape() - module = project_to_module(_project(shape, channel, transpose=TRANSPOSE)) + instrument = _instrument() + module = project_to_module(_project(instrument, channel, transpose=TRANSPOSE)) - cell = pitch_to_note_cell(shape.root_pitch + TRANSPOSE) + cell = pitch_to_note_cell(instrument.root_pitch + TRANSPOSE) row = _rows(list(module.track.patterns), channel)[0] assert (row.note, row.octave) == (cell.note, cell.octave) def test_a_noise_row_states_the_period_root_moved_by_its_transpose(self) -> None: - shape = _shape() - module = project_to_module(_project(shape, ChannelName.NOISE, transpose=TRANSPOSE)) + instrument = _instrument() + module = project_to_module(_project(instrument, ChannelName.NOISE, transpose=TRANSPOSE)) - cell = period_to_note_cell(shape.root_period + TRANSPOSE) + cell = period_to_note_cell(instrument.root_period + TRANSPOSE) row = _rows(list(module.track.patterns), ChannelName.NOISE)[0] assert (row.note, row.octave) == (cell.note, cell.octave) def test_every_channel_names_the_one_instrument(self) -> None: - shape = _shape() - module = project_to_module(_project(shape, *ChannelName.items())) + instrument = _instrument() + module = project_to_module(_project(instrument, *ChannelName.items())) for channel in ChannelName.items(): assert _rows(list(module.track.patterns), channel)[0].instrument == 0 diff --git a/tests/unit/sampletones_core/performance/test_shape_walk.py b/tests/unit/sampletones_core/performance/test_instrument_walk.py similarity index 66% rename from tests/unit/sampletones_core/performance/test_shape_walk.py rename to tests/unit/sampletones_core/performance/test_instrument_walk.py index 9d1851c8e..0f96f1908 100644 --- a/tests/unit/sampletones_core/performance/test_shape_walk.py +++ b/tests/unit/sampletones_core/performance/test_instrument_walk.py @@ -7,22 +7,22 @@ from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP from sampletones_core.instructions import PulseInstruction from sampletones_core.performance import song_instructions -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT -from sampletones_core.project.voices.shape import Shape from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase -from tests.suite.performance import place_instrument, project_with_shape +from tests.suite.performance import place_instrument, project_with_instrument ROWS_PER_PATTERN: int = 4 VOLUME: Tuple[int, ...] = (15, 10) ARPEGGIO: Tuple[int, ...] = (0, 5) -def _shape(loop: bool = False) -> Shape: - return Shape( +def _instrument(loop: bool = False) -> Instrument: + return Instrument( name="lead", - envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), + envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), loop_point=WHOLE_LOOP_POINT if loop else None, ) @@ -31,7 +31,7 @@ def _resting(channel_name: ChannelName) -> object: return CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction() -class TestAShapeSoundsOnEveryChannel(BaseTestSuite): +class TestAnInstrumentSoundsOnEveryChannel(BaseTestSuite): """A hand-written voice is placed on any channel, and the walk sounds the frames it makes there.""" @dataclass(frozen=True, kw_only=True) @@ -46,29 +46,29 @@ class TestCase(BaseRegularTestCase): ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) - def test_the_walk_sounds_the_shape_where_it_was_placed(self, test_case: TestCase) -> None: - shape = _shape() - project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + def test_the_walk_sounds_the_instrument_where_it_was_placed(self, test_case: TestCase) -> None: + instrument = _instrument() + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) place_instrument( project, channel_name=test_case.channel_name, row_index=0, - sample=shape, + sample=instrument, ) streams = song_instructions(project) - assert streams[test_case.channel_name][: len(VOLUME)] == shape.instructions(test_case.channel_name) + assert streams[test_case.channel_name][: len(VOLUME)] == instrument.instructions(test_case.channel_name) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_channels_it_was_not_placed_on_rest(self, test_case: TestCase) -> None: - shape = _shape() - project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + instrument = _instrument() + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) place_instrument( project, channel_name=test_case.channel_name, row_index=0, - sample=shape, + sample=instrument, ) streams = song_instructions(project) @@ -80,37 +80,37 @@ def test_the_channels_it_was_not_placed_on_rest(self, test_case: TestCase) -> No assert set(streams[channel_name]) == {_resting(channel_name)} -class TestAShapeInASong: +class TestAnInstrumentInASong: def test_a_one_shot_falls_silent_past_its_envelopes(self) -> None: - shape = _shape() - project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) - place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=shape) + instrument = _instrument() + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument) stream = song_instructions(project)[ChannelName.PULSE1] assert stream[len(VOLUME) :] == [_resting(ChannelName.PULSE1)] * (len(stream) - len(VOLUME)) - def test_a_looping_shape_keeps_sounding(self) -> None: - shape = _shape(loop=True) - project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) - place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=shape) + def test_a_looping_instrument_keeps_sounding(self) -> None: + instrument = _instrument(loop=True) + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument) stream = song_instructions(project)[ChannelName.PULSE1] assert _resting(ChannelName.PULSE1) not in stream - def test_a_rows_transpose_bends_the_shape_off_its_root(self) -> None: - shape = _shape() - project = project_with_shape(shape, rows_per_pattern=ROWS_PER_PATTERN) + def test_a_rows_transpose_bends_the_instrument_off_its_root(self) -> None: + instrument = _instrument() + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) place_instrument( project, channel_name=ChannelName.PULSE1, row_index=0, - sample=shape, + sample=instrument, transpose=7, ) first = song_instructions(project)[ChannelName.PULSE1][0] assert isinstance(first, PulseInstruction) - assert first.pitch == shape.root_pitch + ARPEGGIO[0] + 7 + assert first.pitch == instrument.root_pitch + ARPEGGIO[0] + 7 diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index ee9252714..13c30775b 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -1,6 +1,7 @@ import json import zipfile from pathlib import Path +from typing import Any, Callable, Dict, Final, Tuple from unittest.mock import patch import pytest @@ -10,11 +11,11 @@ from sampletones_core.project.container import ProjectContainer from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.shape import Shape from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from sampletones_shared.constants.project import ( PROJECT_DOCUMENT_NAME, @@ -31,6 +32,11 @@ from tests.conftest import ReconstructionFactory from tests.suite.errors import DIRECTORY_READ_ERRORS +Document = Dict[str, Any] +DocumentRewrite = Callable[[Document], Document] + +FORMAT_1_0_SAMPLE_FIELDS: Final[Tuple[str, ...]] = ("id", "name", "reconstruction_id") + def _rewrite_format_version(source: Path, target: Path, *, format_version: str) -> None: with zipfile.ZipFile(source, "r") as archive: @@ -45,6 +51,64 @@ def _rewrite_format_version(source: Path, target: Path, *, format_version: str) archive.writestr(name, data) +def _rewrite_document(source: Path, target: Path, rewrite: DocumentRewrite) -> None: + with zipfile.ZipFile(source, "r") as archive: + members = {name: archive.read(name) for name in archive.namelist()} + + document = json.loads(members[PROJECT_DOCUMENT_NAME].decode("utf-8")) + members[PROJECT_DOCUMENT_NAME] = json.dumps(rewrite(document)).encode("utf-8") + + with zipfile.ZipFile(target, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + + +def _as_format_1_0(document: Document) -> Document: + """The document as project format 1.0 wrote it, the shape the upgrade chain reads. + + Format 1.0 held the pool under ``samples``, each record naming an id, a name and the + reconstruction it references; named a channel pool's channel ``generator``; and wrote a note + command as a sample id beside the channel slice it named. + """ + samples = [{field: voice[field] for field in FORMAT_1_0_SAMPLE_FIELDS} for voice in document["voices"]] + channels = {name: _pool_as_format_1_0(name, pool) for name, pool in document["song"]["channels"].items()} + downgraded = {key: value for key, value in document.items() if key != "voices"} + return { + **downgraded, + "format_version": "1.0", + "samples": samples, + "song": {**document["song"], "channels": channels}, + } + + +def _pool_as_format_1_0(channel_name: str, pool: Document) -> Document: + patterns = {index: _pattern_as_format_1_0(channel_name, pattern) for index, pattern in pool["patterns"].items()} + return { + "generator": pool["name"], + **{key: value for key, value in pool.items() if key != "name"}, + "patterns": patterns, + } + + +def _pattern_as_format_1_0(channel_name: str, pattern: Document) -> Document: + rows = [_row_as_format_1_0(channel_name, row) for row in pattern["rows"]] + return {**pattern, "rows": rows} + + +def _row_as_format_1_0(channel_name: str, row: Document) -> Document: + command = row.get("command") + if not isinstance(command, dict) or "voice_id" not in command: + return row + + return { + **row, + "command": { + "sample_id": command["voice_id"], + "generator_name": channel_name, + }, + } + + def _populated_project( reconstruction_factory: ReconstructionFactory, shared: bool = False, @@ -125,35 +189,35 @@ def test_references_resolve_after_load( assert channel.pattern(index_at_0) is channel.pattern(index_at_2) -class TestShapesRoundTrip: - """A shape carries no payload beside itself, so a project holds it whole in its document.""" +class TestInstrumentsRoundTrip: + """An instrument carries no payload beside itself, so a project holds it whole in its document.""" - def test_a_shape_survives_a_round_trip(self, tmp_path: Path) -> None: + def test_an_instrument_survives_a_round_trip(self, tmp_path: Path) -> None: project = Project.create(title="Demo") - shape = Shape( + instrument = Instrument( name="lead", - envelopes=ShapeEnvelopes(volume=(15, 12), arpeggio=(0, 7), duty_cycle=(2,)), + envelopes=InstrumentEnvelopes(volume=(15, 12), arpeggio=(0, 7), duty_cycle=(2,)), root_pitch=55, root_period=3, loop_point=WHOLE_LOOP_POINT, ) - project.voices.append(shape) + project.voices.append(instrument) path = tmp_path / "demo.stp" ProjectContainer.save(project, path) loaded = ProjectContainer.load(path) - assert loaded.voices[0] == shape - restored = loaded.voice(shape.id) - assert isinstance(restored, Shape) - assert restored.envelopes == shape.envelopes - assert restored.root_pitch == shape.root_pitch - assert restored.root_period == shape.root_period - assert restored.loop_point == shape.loop_point + assert loaded.voices[0] == instrument + restored = loaded.voice(instrument.id) + assert isinstance(restored, Instrument) + assert restored.envelopes == instrument.envelopes + assert restored.root_pitch == instrument.root_pitch + assert restored.root_period == instrument.root_period + assert restored.loop_point == instrument.loop_point - def test_a_shape_leaves_no_reconstruction_in_the_archive(self, tmp_path: Path) -> None: + def test_an_instrument_leaves_no_reconstruction_in_the_archive(self, tmp_path: Path) -> None: project = Project.create(title="Demo") - project.voices.append(Shape(name="lead")) + project.voices.append(Instrument(name="lead")) path = tmp_path / "demo.stp" ProjectContainer.save(project, path) @@ -168,25 +232,25 @@ def test_both_kinds_share_one_pool_in_their_written_order( ) -> None: project = Project.create(title="Demo") sample = Sample(name="bass", reconstruction=reconstruction_factory()) - shape = Shape(name="lead") - project.voices.extend([sample, shape]) + instrument = Instrument(name="lead") + project.voices.extend([sample, instrument]) path = tmp_path / "demo.stp" ProjectContainer.save(project, path) loaded = ProjectContainer.load(path) - assert [voice.id for voice in loaded.voices] == [sample.id, shape.id] + assert [voice.id for voice in loaded.voices] == [sample.id, instrument.id] assert isinstance(loaded.voices[0], Sample) - assert isinstance(loaded.voices[1], Shape) + assert isinstance(loaded.voices[1], Instrument) - def test_a_row_naming_a_shape_still_names_it_after_a_round_trip( + def test_a_row_naming_an_instrument_still_names_it_after_a_round_trip( self, tmp_path: Path, ) -> None: project = Project.create(title="Demo") - shape = Shape(name="lead", envelopes=ShapeEnvelopes(volume=(15,))) - project.voices.append(shape) - project.song[ChannelName.PULSE1].patterns[0].rows[0] = Row(command=NoteOn(voice_id=shape.id)) + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(volume=(15,))) + project.voices.append(instrument) + project.song[ChannelName.PULSE1].patterns[0].rows[0] = Row(command=NoteOn(voice_id=instrument.id)) path = tmp_path / "demo.stp" ProjectContainer.save(project, path) @@ -358,6 +422,27 @@ def test_unexpected_error_wrapped_as_unhandled( class TestVersionCompatibility: + def test_project_written_at_format_1_0_loads( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + project = _populated_project(reconstruction_factory) + original = tmp_path / "demo.stp" + ProjectContainer.save(project, original) + legacy = tmp_path / "legacy.stp" + _rewrite_document(original, legacy, _as_format_1_0) + + loaded = ProjectContainer.load(legacy) + + assert [voice.id for voice in loaded.voices] == [voice.id for voice in project.voices] + assert [voice.name for voice in loaded.voices] == [voice.name for voice in project.voices] + assert set(loaded.song.channels) == set(project.song.channels) + + pattern = loaded.song.pattern(ChannelName.PULSE1, loaded.song.order[0][ChannelName.PULSE1]) + row = pattern.rows[0] + assert row.command == NoteOn(voice_id=project.voices[0].id) + def test_incompatible_format_version_raises( self, tmp_path: Path, diff --git a/tests/unit/sampletones_core/project/voices/test_creation.py b/tests/unit/sampletones_core/project/voices/test_creation.py index 987d246ab..0a6fbc43b 100644 --- a/tests/unit/sampletones_core/project/voices/test_creation.py +++ b/tests/unit/sampletones_core/project/voices/test_creation.py @@ -1,36 +1,36 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH -from sampletones_core.project.voices.creation import new_shape +from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT -class TestNewShape: - def test_a_new_shape_sounds_a_frame_on_every_channel(self) -> None: - shape = new_shape("lead") +class TestNewInstrument: + def test_a_new_instrument_sounds_a_frame_on_every_channel(self) -> None: + instrument = new_instrument("lead") for channel_name in ChannelName.items(): - assert shape.instructions(channel_name) + assert instrument.instructions(channel_name) - def test_a_new_shape_sounds_at_full_volume(self) -> None: - shape = new_shape("lead") + def test_a_new_instrument_sounds_at_full_volume(self) -> None: + instrument = new_instrument("lead") - assert shape.envelopes.volume == (MAX_VOLUME,) + assert instrument.envelopes.volume == (MAX_VOLUME,) - def test_a_new_shape_repeats_its_envelopes_while_the_note_is_held(self) -> None: - assert new_shape("lead").loop_point == WHOLE_LOOP_POINT + def test_a_new_instrument_repeats_its_envelopes_while_the_note_is_held(self) -> None: + assert new_instrument("lead").loop_point == WHOLE_LOOP_POINT - def test_a_new_shape_leaves_the_arpeggio_and_the_duty_cycle_to_the_channel(self) -> None: - shape = new_shape("lead") + def test_a_new_instrument_leaves_the_arpeggio_and_the_duty_cycle_to_the_channel(self) -> None: + instrument = new_instrument("lead") - assert shape.envelopes.arpeggio == () - assert shape.envelopes.duty_cycle == () + assert instrument.envelopes.arpeggio == () + assert instrument.envelopes.duty_cycle == () - def test_a_new_shape_rests_where_a_channel_added_by_hand_rests(self) -> None: - shape = new_shape("lead") + def test_a_new_instrument_rests_where_a_channel_added_by_hand_rests(self) -> None: + instrument = new_instrument("lead") - assert shape.root_pitch == RESTING_REFERENCE_PITCH - assert shape.root_period == RESTING_REFERENCE_PERIOD + assert instrument.root_pitch == RESTING_REFERENCE_PITCH + assert instrument.root_period == RESTING_REFERENCE_PERIOD - def test_each_new_shape_is_a_voice_of_its_own(self) -> None: - assert new_shape("lead").id != new_shape("lead").id + def test_each_new_instrument_is_a_voice_of_its_own(self) -> None: + assert new_instrument("lead").id != new_instrument("lead").id diff --git a/tests/unit/sampletones_core/project/voices/test_shape.py b/tests/unit/sampletones_core/project/voices/test_instrument.py similarity index 55% rename from tests/unit/sampletones_core/project/voices/test_shape.py rename to tests/unit/sampletones_core/project/voices/test_instrument.py index fef759dc2..3e5fb1e4d 100644 --- a/tests/unit/sampletones_core/project/voices/test_shape.py +++ b/tests/unit/sampletones_core/project/voices/test_instrument.py @@ -14,9 +14,9 @@ ) from sampletones_core.features.spec import CHANNEL_GENERATOR_KIND from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT -from sampletones_core.project.voices.shape import Shape from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -25,48 +25,48 @@ DUTY_CYCLE: Tuple[int, ...] = (2,) -def _shape(**overrides: object) -> Shape: +def _instrument(**overrides: object) -> Instrument: fields: dict = { "name": "lead", - "envelopes": ShapeEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), + "envelopes": InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), } fields.update(overrides) - return Shape(**fields) + return Instrument(**fields) -class TestShapeIdentity: - def test_each_shape_gets_its_own_id(self) -> None: - assert _shape().id != _shape().id +class TestInstrumentIdentity: + def test_each_instrument_gets_its_own_id(self) -> None: + assert _instrument().id != _instrument().id def test_clone_gets_a_fresh_id_and_carries_the_rest(self) -> None: - shape = _shape(root_pitch=48, root_period=3, loop_point=WHOLE_LOOP_POINT) - clone = shape.clone() + instrument = _instrument(root_pitch=48, root_period=3, loop_point=WHOLE_LOOP_POINT) + clone = instrument.clone() - assert clone.id != shape.id - assert clone.name == shape.name - assert clone.envelopes == shape.envelopes - assert clone.root_pitch == shape.root_pitch - assert clone.root_period == shape.root_period - assert clone.loop_point == shape.loop_point + assert clone.id != instrument.id + assert clone.name == instrument.name + assert clone.envelopes == instrument.envelopes + assert clone.root_pitch == instrument.root_pitch + assert clone.root_period == instrument.root_period + assert clone.loop_point == instrument.loop_point -class TestShapeRoots: - def test_a_shape_rests_where_a_channel_added_by_hand_rests(self) -> None: - shape = Shape(name="lead") +class TestInstrumentRoots: + def test_an_instrument_rests_where_a_channel_added_by_hand_rests(self) -> None: + instrument = Instrument(name="lead") - assert shape.root_pitch == RESTING_REFERENCE_PITCH - assert shape.root_period == RESTING_REFERENCE_PERIOD + assert instrument.root_pitch == RESTING_REFERENCE_PITCH + assert instrument.root_period == RESTING_REFERENCE_PERIOD def test_the_tonal_channels_read_the_pitch_and_noise_reads_the_period(self) -> None: - shape = _shape(root_pitch=55, root_period=3) + instrument = _instrument(root_pitch=55, root_period=3) - assert shape.reference(ChannelName.PULSE1) == 55 - assert shape.reference(ChannelName.PULSE2) == 55 - assert shape.reference(ChannelName.TRIANGLE) == 55 - assert shape.reference(ChannelName.NOISE) == 3 + assert instrument.reference(ChannelName.PULSE1) == 55 + assert instrument.reference(ChannelName.PULSE2) == 55 + assert instrument.reference(ChannelName.TRIANGLE) == 55 + assert instrument.reference(ChannelName.NOISE) == 3 -class TestShapeFeatures(BaseTestSuite): +class TestInstrumentFeatures(BaseTestSuite): """One set of envelopes, read on every channel in the dimensions that channel offers.""" @dataclass(frozen=True, kw_only=True) @@ -82,7 +82,7 @@ class TestCase(BaseRegularTestCase): @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_channel_reads_the_dimensions_it_offers(self, test_case: TestCase) -> None: - features = _shape().features(test_case.channel_name) + features = _instrument().features(test_case.channel_name) kind = CHANNEL_GENERATOR_KIND[test_case.channel_name] assert set(features.keys()) >= set(supported_features(kind)) @@ -90,20 +90,20 @@ def test_the_channel_reads_the_dimensions_it_offers(self, test_case: TestCase) - @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_arpeggio_is_measured_against_the_channels_root(self, test_case: TestCase) -> None: - shape = _shape() + instrument = _instrument() - assert shape.features(test_case.channel_name).initial_pitch == shape.reference(test_case.channel_name) + assert instrument.features(test_case.channel_name).initial_pitch == instrument.reference(test_case.channel_name) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_every_channel_sounds_one_frame_per_tick(self, test_case: TestCase) -> None: - shape = _shape() + instrument = _instrument() - assert len(shape.instructions(test_case.channel_name)) == shape.envelopes.frame_count + assert len(instrument.instructions(test_case.channel_name)) == instrument.envelopes.frame_count -class TestShapeInstructions: +class TestInstrumentInstructions: def test_a_pulse_frame_carries_the_volume_duty_and_root(self) -> None: - first = _shape().instructions(ChannelName.PULSE1)[0] + first = _instrument().instructions(ChannelName.PULSE1)[0] assert first == PulseInstruction( on=True, @@ -113,19 +113,19 @@ def test_a_pulse_frame_carries_the_volume_duty_and_root(self) -> None: ) def test_the_arpeggio_moves_the_frame_off_the_root(self) -> None: - instructions = _shape().instructions(ChannelName.PULSE1) + instructions = _instrument().instructions(ChannelName.PULSE1) third = instructions[2] assert isinstance(third, PulseInstruction) assert third.pitch == RESTING_REFERENCE_PITCH + ARPEGGIO[2] def test_a_triangle_frame_sounds_at_the_root(self) -> None: - first = _shape().instructions(ChannelName.TRIANGLE)[0] + first = _instrument().instructions(ChannelName.TRIANGLE)[0] assert first == TriangleInstruction(on=True, pitch=RESTING_REFERENCE_PITCH) def test_a_noise_frame_takes_the_period_root_and_the_short_mode(self) -> None: - first = _shape().instructions(ChannelName.NOISE)[0] + first = _instrument().instructions(ChannelName.NOISE)[0] assert first == NoiseInstruction( on=True, @@ -134,19 +134,19 @@ def test_a_noise_frame_takes_the_period_root_and_the_short_mode(self) -> None: short=True, ) - def test_a_shape_writing_nothing_sounds_on_no_channel(self) -> None: - shape = Shape(name="empty") + def test_an_instrument_writing_nothing_sounds_on_no_channel(self) -> None: + instrument = Instrument(name="empty") - assert all(not shape.instructions(channel_name) for channel_name in ChannelName.items()) + assert all(not instrument.instructions(channel_name) for channel_name in ChannelName.items()) def test_an_edit_reaches_the_frames(self) -> None: - shape = _shape() - before = shape.instructions(ChannelName.PULSE1) + instrument = _instrument() + before = instrument.instructions(ChannelName.PULSE1) - shape.envelopes = shape.envelopes.with_envelope(FeatureKey.ARPEGGIO, (7,)) - shape.invalidate() + instrument.envelopes = instrument.envelopes.with_envelope(FeatureKey.ARPEGGIO, (7,)) + instrument.invalidate() - after = shape.instructions(ChannelName.PULSE1) + after = instrument.instructions(ChannelName.PULSE1) assert after != before assert isinstance(after[0], PulseInstruction) assert after[0].pitch == RESTING_REFERENCE_PITCH + 7 @@ -154,30 +154,30 @@ def test_an_edit_reaches_the_frames(self) -> None: class TestHeldDimensions: def test_an_empty_envelope_is_left_to_the_channel(self) -> None: - shape = Shape(name="lead", envelopes=ShapeEnvelopes(arpeggio=ARPEGGIO)) + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=ARPEGGIO)) - held = shape.held_features(ChannelName.PULSE1) + held = instrument.held_features(ChannelName.PULSE1) assert FeatureKey.VOLUME in held assert FeatureKey.DUTY_CYCLE in held assert FeatureKey.ARPEGGIO not in held def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None: - shape = Shape(name="lead", envelopes=ShapeEnvelopes(arpeggio=ARPEGGIO)) + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=ARPEGGIO)) - assert FeatureKey.DUTY_CYCLE not in shape.held_features(ChannelName.TRIANGLE) + assert FeatureKey.DUTY_CYCLE not in instrument.held_features(ChannelName.TRIANGLE) class TestEnvelopeBounds: def test_a_volume_past_the_range_is_refused(self) -> None: with pytest.raises(ValidationError): - ShapeEnvelopes(volume=(MAX_VOLUME + 1,)) + InstrumentEnvelopes(volume=(MAX_VOLUME + 1,)) - def test_a_dimension_a_shape_writes_none_of_is_refused(self) -> None: + def test_a_dimension_an_instrument_writes_none_of_is_refused(self) -> None: with pytest.raises(KeyError): - ShapeEnvelopes().with_envelope(FeatureKey.PITCH, (1,)) + InstrumentEnvelopes().with_envelope(FeatureKey.PITCH, (1,)) def test_the_frame_count_is_the_longest_dimension(self) -> None: - envelopes = ShapeEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE) + envelopes = InstrumentEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE) assert envelopes.frame_count == len(VOLUME) diff --git a/tests/unit/sampletones_player/test_shape_song.py b/tests/unit/sampletones_player/test_instrument_song.py similarity index 64% rename from tests/unit/sampletones_player/test_shape_song.py rename to tests/unit/sampletones_player/test_instrument_song.py index e696ce419..bc94b45e3 100644 --- a/tests/unit/sampletones_player/test_shape_song.py +++ b/tests/unit/sampletones_player/test_instrument_song.py @@ -3,9 +3,9 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project -from sampletones_core.project.voices.envelopes import ShapeEnvelopes +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.project.voices.shape import Shape from sampletones_player.builder import song_from_project ROWS_PER_PATTERN: Final[int] = 4 @@ -14,27 +14,27 @@ def _project() -> Project: - shape = Shape( + instrument = Instrument( name="Lead", - envelopes=ShapeEnvelopes(volume=VOLUME, arpeggio=(0, 4, 7), duty_cycle=(1,)), + envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=(0, 4, 7), duty_cycle=(1,)), ) project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) - project.voices.append(shape) + project.voices.append(instrument) pattern = project.song[ChannelName.PULSE1].ensure_pattern(0, ROWS_PER_PATTERN) - pattern.rows[0] = Row(command=NoteOn(voice_id=shape.id)) + pattern.rows[0] = Row(command=NoteOn(voice_id=instrument.id)) project.song.set_order_entry(0, ChannelName.PULSE1, 0) return project -class TestAShapeReachesTheConsole: - """The player reads the same walk the sequencer plays, so a shape needs nothing of its own.""" +class TestAnInstrumentReachesTheConsole: + """The player reads the same walk the sequencer plays, so an instrument needs nothing of its own.""" - def test_a_project_holding_a_shape_compiles(self) -> None: + def test_a_project_holding_an_instrument_compiles(self) -> None: song = song_from_project(_project(), loop_tick=None) assert song.planes.ticks > 0 - def test_the_compiled_song_sounds_the_shape_on_the_channel_it_was_placed_on(self) -> None: + def test_the_compiled_song_sounds_the_instrument_on_the_channel_it_was_placed_on(self) -> None: song = song_from_project(_project(), loop_tick=None) levels = [registers.control & VOLUME_NIBBLE for registers in song.streams.pulse1[: len(VOLUME)]] From 42fd99b568b809ee16d2233b853eaea63a9b9db7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 15:20:28 +0200 Subject: [PATCH 084/142] Renamed: the tracker's sample column into the voice column --- docs/development/bugs-and-todos.md | 15 ++++++++- .../categories/elements/sequencer.py | 8 ++--- .../categories/elements/settings.py | 16 +++++----- .../logic/sequencer/tracker/tracker.py | 17 +++++----- .../ui/panels/sequencer/columns.py | 8 ++--- .../ui/panels/sequencer/tracker.py | 24 +++++++------- .../ui/panels/sequencer/voices.py | 20 ++++++------ .../utils/gui/shortcuts/ids.py | 16 +++++----- .../view_model/sequencer/tracker.py | 22 ++++++------- .../keybindings/default.yaml | 16 +++++----- src/sampletones_config/keybindings/macos.yaml | 16 +++++----- src/sampletones_config/lang/en.yaml | 24 +++++++------- .../logic/project/test_controller.py | 16 +++++----- .../sequencer/tracker/test_pitch_faces.py | 4 +-- .../logic/sequencer/tracker/test_tracker.py | 32 +++++++++---------- .../ui/panels/sequencer/test_block_menu.py | 4 +-- .../ui/panels/sequencer/test_columns.py | 6 ++-- .../sequencer/test_tracker_context_menu.py | 4 +-- .../sequencer/test_tracker_header_menu.py | 4 +-- .../ui/panels/sequencer/test_voices_menu.py | 4 +-- .../utils/gui/shortcuts/test_draft.py | 4 +-- .../utils/gui/shortcuts/test_scheme.py | 10 +++--- .../utils/gui/shortcuts/test_source.py | 6 ++-- .../view_model/sequencer/test_tracker.py | 6 ++-- 24 files changed, 157 insertions(+), 145 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index b7cdc9f1d..4d4d88db4 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -27,6 +27,11 @@ * A sample's loop point is offered as a switch in the voice list, though the model carries the point for both kinds of voice. * Exporting a shape as an instrument file from the Reconstructions tab. +* `SubColumn.INSTRUMENT` names the first slot of both tracker column kinds, and the two hold + different things: the voice id under the Voice column, and the note on a channel column. One + name for both is wrong half the time, and splitting it reaches the layout keys + (`sequencer/colors.yaml`, `sequencer/tracker.yaml`) and their DTOs, so it is a question of its + own rather than part of naming a voice. ### Workflow @@ -43,7 +48,15 @@ * API documentation * Code documentation -* Backward compatibility: library/reconstruction upgrade scheme +* A backward-compatibility corpus of files older builds actually wrote. Every upgrade step is + exercised against a payload the test builds itself — hand-written mappings for the step, and, + for projects, a current document rewritten backwards into the older shape — so a step is held + only to the fields it names. One archived `.stn`, `.ins` and `.stp` per shipped version, each + written by that version and exercising every feature it could store, would hold the whole + document to the chain and would catch a field that changed shape while no step named it. + Configuration and session state carry no version at all, so the same corpus would state what a + build is expected to make of a `state.yaml` an older one left behind. Reaches + `tests/unit/sampletones_core/compatibility/` and each format's load tests. * Respecting FamiTracker limitations * Per-tab undo routing * In-application console diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index d765a2813..a468d8740 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -22,21 +22,21 @@ class SequencerTrackerElements(AbstractElement): TRACKER_TEXT = "tracker_text" OCTAVE = "octave" COLUMN_ROW = "column_row" - COLUMN_SAMPLE = "column_sample" + COLUMN_VOICE = "column_voice" COLUMN_PULSE_1 = "column_pulse_1" COLUMN_PULSE_2 = "column_pulse_2" COLUMN_TRIANGLE = "column_triangle" COLUMN_NOISE = "column_noise" HEADER_CHANNEL = "header_channel" - HEADER_SAMPLE = "header_sample" + HEADER_VOICE = "header_voice" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" CONTEXT_SELECT_ALL = "context_select_all" CONTEXT_SELECT_COLUMN = "context_select_column" CONTEXT_SELECT_SUBCOLUMN = "context_select_subcolumn" CONTEXT_NOTE_OFF = "context_note_off" - CONTEXT_SET_INSTRUMENT = "context_set_instrument" - CONTEXT_NO_SAMPLES = "context_no_samples" + CONTEXT_SET_VOICE = "context_set_voice" + CONTEXT_NO_VOICES = "context_no_voices" CONTEXT_CLEAR_SUBCOLUMN = "context_clear_subcolumn" CONTEXT_CLEAR_CELL = "context_clear_cell" CONTEXT_CLEAR_ROW = "context_clear_row" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 1a4e451b7..a5b9e53e3 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -157,13 +157,13 @@ class KeybindingActionElements(AbstractElement): TRACKER_CANCEL_ENTRY = "tracker_cancel_entry" TRACKER_PLAY_FROM_ROW = "tracker_play_from_row" - SAMPLES_RENAME_SAMPLE = "samples_rename_sample" - SAMPLES_REMOVE_SAMPLE = "samples_remove_sample" - SAMPLES_MOVE_SAMPLE_UP = "samples_move_sample_up" - SAMPLES_MOVE_SAMPLE_DOWN = "samples_move_sample_down" - SAMPLES_MOVE_SAMPLE_TO_TOP = "samples_move_sample_to_top" - SAMPLES_MOVE_SAMPLE_TO_BOTTOM = "samples_move_sample_to_bottom" - SAMPLES_CANCEL_RENAME = "samples_cancel_rename" + VOICES_RENAME_VOICE = "voices_rename_voice" + VOICES_REMOVE_VOICE = "voices_remove_voice" + VOICES_MOVE_VOICE_UP = "voices_move_voice_up" + VOICES_MOVE_VOICE_DOWN = "voices_move_voice_down" + VOICES_MOVE_VOICE_TO_TOP = "voices_move_voice_to_top" + VOICES_MOVE_VOICE_TO_BOTTOM = "voices_move_voice_to_bottom" + VOICES_CANCEL_RENAME = "voices_cancel_rename" class KeybindingCategoryElements(AbstractElement): @@ -172,7 +172,7 @@ class KeybindingCategoryElements(AbstractElement): APPLICATION = "application" ORDER = "order" TRACKER = "tracker" - SAMPLES = "samples" + VOICES = "voices" class KeybindingsElements(AbstractElement): diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 6cace2cfc..444e7b269 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -217,7 +217,7 @@ def place_note( voice_id: str, ) -> None: if channel is None: - self.set_sample_instrument(row_index, voice_id) + self.set_row_voice(row_index, voice_id) else: self.set_row( channel, @@ -332,21 +332,20 @@ def clear_subcolumn_all_generators( volume=volume, ) - def set_sample_instrument( + def set_row_voice( self, row_index: int, voice_id: Optional[str], ) -> None: """Places a sample across the channels its reconstruction uses. - The sample column is authoritative: the instrument is written to every - channel the sample covers, and the remaining channels on that row are - cleared so the row reflects exactly that sample. Clearing an empty sample - id wipes the whole row. + The voice column is authoritative: the sample is written to every channel it covers, and + the remaining channels on that row are cleared so the row plays exactly that sample. + An empty voice id wipes the whole row. - The column speaks for samples, which carry a slice per channel; an instrument carries one - instrument the reader places on the channel they want it on, so it is named in a channel - column and this one leaves the row as it stands. + The column speaks for samples, which carry a slice per channel. A hand-written voice sounds + on whichever channel the reader names it in, so it is placed in a channel column and this + one leaves the row as it stands. """ if voice_id is None: self.clear_all_channels(row_index) diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 11e403fed..d37cca6b2 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -5,8 +5,8 @@ from sampletones_core.constants.enums import ChannelName _LEADING_TABLE_COLUMNS: Final[int] = 2 -SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS -DIVIDER_TABLE_COLUMN: Final[int] = SAMPLE_TABLE_COLUMN + 1 +VOICE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS +DIVIDER_TABLE_COLUMN: Final[int] = VOICE_TABLE_COLUMN + 1 _FIRST_CHANNEL_TABLE_COLUMN: Final[int] = DIVIDER_TABLE_COLUMN + 1 _TRAILING_TABLE_COLUMNS: Final[int] = 1 TRACKER_TABLE_COLUMNS: Final[int] = _FIRST_CHANNEL_TABLE_COLUMN + len(ChannelName.items()) + _TRAILING_TABLE_COLUMNS @@ -30,13 +30,13 @@ def channel_color(colors: ChannelColors, channel: ChannelName) -> BaseColor: def tracker_table_column(channel: Optional[ChannelName]) -> int: """Maps a logical column to its DPG table column index. - The visual divider between the sample column and the channels occupies a table + The visual divider between the voice column and the channels occupies a table column of its own, so the channels sit one slot further right than their logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers only the cursor-addressable columns. """ if channel is None: - return SAMPLE_TABLE_COLUMN + return VOICE_TABLE_COLUMN return _FIRST_CHANNEL_TABLE_COLUMN + ChannelName.items().index(channel) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 73e6820ff..e2c232523 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -43,8 +43,8 @@ DIVIDER_TABLE_COLUMN, HEADER_TABLE_ROW, HEADER_TABLE_ROWS, - SAMPLE_TABLE_COLUMN, TRACKER_TABLE_COLUMNS, + VOICE_TABLE_COLUMN, channel_color, tracker_table_column, tracker_table_row, @@ -326,7 +326,7 @@ def _load_column_labels(self, language_manager: LanguageManager) -> None: """Reads the name each column carries, which its header label and its menu title show.""" self._lbl_col_row = self._label(language_manager, SequencerTrackerElements.COLUMN_ROW) self._column_labels: Dict[Optional[ChannelName], str] = { - None: self._label(language_manager, SequencerTrackerElements.COLUMN_SAMPLE), + None: self._label(language_manager, SequencerTrackerElements.COLUMN_VOICE), ChannelName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1), ChannelName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2), ChannelName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE), @@ -355,8 +355,8 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_select_column = label(SequencerTrackerElements.CONTEXT_SELECT_COLUMN) self._lbl_context_select_subcolumn = label(SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN) self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) - self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT) - self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES) + self._lbl_context_set_voice = label(SequencerTrackerElements.CONTEXT_SET_VOICE) + self._lbl_context_no_voices = label(SequencerTrackerElements.CONTEXT_NO_VOICES) self._lbl_context_clear_subcolumn = label(SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN) self._lbl_context_clear_cell = label(SequencerTrackerElements.CONTEXT_CLEAR_CELL) self._lbl_context_clear_row = label(SequencerTrackerElements.CONTEXT_CLEAR_ROW) @@ -371,7 +371,7 @@ def tooltip(element: SequencerTrackerElements) -> str: return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.TOOLTIP, element] self._tooltip_header_channel = channel_tooltip(tooltip(SequencerTrackerElements.HEADER_CHANNEL)) - self._tooltip_header_sample = tooltip(SequencerTrackerElements.HEADER_SAMPLE) + self._tooltip_header_voice = tooltip(SequencerTrackerElements.HEADER_VOICE) def _create_channel_switch(self, language_manager: LanguageManager) -> None: """Builds the switch a column header's click and menu act through. @@ -715,7 +715,7 @@ def _highlight_sample_column(self) -> None: """ dpg.highlight_table_column( TAG_SEQUENCER_TRACKER_TABLE, - SAMPLE_TABLE_COLUMN, + VOICE_TABLE_COLUMN, self._layout.colors.sample.column.rgba, ) dpg.highlight_table_column( @@ -770,9 +770,9 @@ def _compute_cell_values( ) -> CellValues: cell_values: CellValues = {} for row in view_model.rows: - cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample_instrument - cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.sample_transpose - cell_values[(row.index, None, SubColumn.VOLUME)] = row.sample_volume + cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.voice + cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.transpose + cell_values[(row.index, None, SubColumn.VOLUME)] = row.volume for channel in ChannelName.items(): cell = row.cells[channel] for subcolumn in SubColumn: @@ -849,7 +849,7 @@ def _add_header_selectable( dpg.bind_item_handler_registry(selectable, self._header_handler_tag) show_tooltip( selectable, - self._tooltip_header_sample if channel is None else self._tooltip_header_channel, + self._tooltip_header_voice if channel is None else self._tooltip_header_channel, ) self._header_columns[selectable] = channel @@ -1443,11 +1443,11 @@ def _add_select_items(self, cell: TrackerCursor) -> None: ) def _add_instrument_submenu(self, cell: TrackerCursor) -> None: - with dpg.menu(label=self._lbl_context_set_instrument): + with dpg.menu(label=self._lbl_context_set_voice): samples = self._current_samples.voices if self._current_samples is not None else () if not samples: dpg.add_menu_item( - label=self._lbl_context_no_samples, + label=self._lbl_context_no_voices, enabled=False, ) return diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices.py index ab6cd5eae..a3cac5550 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -70,22 +70,22 @@ class SampleMove: VOICE_MOVES: Final[Tuple[SampleMove, ...]] = ( SampleMove( element=SequencerVoicesElements.CONTEXT_MOVE_UP, - shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_UP, + shortcut=ShortcutId.VOICES_MOVE_VOICE_UP, direction=MoveDirection.PREVIOUS, ), SampleMove( element=SequencerVoicesElements.CONTEXT_MOVE_DOWN, - shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN, + shortcut=ShortcutId.VOICES_MOVE_VOICE_DOWN, direction=MoveDirection.NEXT, ), SampleMove( element=SequencerVoicesElements.CONTEXT_MOVE_TOP, - shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP, + shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_TOP, direction=MoveDirection.FIRST, ), SampleMove( element=SequencerVoicesElements.CONTEXT_MOVE_BOTTOM, - shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM, + shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_BOTTOM, direction=MoveDirection.LAST, ), ) @@ -483,7 +483,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: The scheme says which press each samples action answers to; a press the samples category leaves unnamed goes to the application's global shortcuts. """ - shortcut_id = self._shortcuts.action(ShortcutCategory.SAMPLES, event) + shortcut_id = self._shortcuts.action(ShortcutCategory.VOICES, event) if self._editing_voice_id is not None: return self._cancel_edit(shortcut_id) @@ -495,9 +495,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: return True match shortcut_id: - case ShortcutId.SAMPLES_REMOVE_SAMPLE: + case ShortcutId.VOICES_REMOVE_VOICE: self.call(self.on_remove_requested, voice_id) - case ShortcutId.SAMPLES_RENAME_SAMPLE: + case ShortcutId.VOICES_RENAME_VOICE: self._start_rename(voice_id) case _: return False @@ -510,7 +510,7 @@ def _cancel_edit(self, shortcut_id: Optional[ShortcutId]) -> bool: A rename in progress keeps every other key for the input, so typing a name reaches the field rather than the panel. """ - if shortcut_id is not ShortcutId.SAMPLES_CANCEL_RENAME: + if shortcut_id is not ShortcutId.VOICES_CANCEL_RENAME: return False self._cancel_rename() @@ -764,7 +764,7 @@ def add_action_items(self, target: VoiceSelection) -> None: self._language_manager, SequencerVoicesElements.CONTEXT_RENAME, ), - shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), + shortcut=self._shortcuts.display(ShortcutId.VOICES_RENAME_VOICE), callback=lambda: self._start_rename(target.voice_id), ) dpg.add_menu_item( @@ -780,7 +780,7 @@ def add_action_items(self, target: VoiceSelection) -> None: self._language_manager, SequencerVoicesElements.CONTEXT_REMOVE, ), - shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), + shortcut=self._shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE), callback=lambda: self.call(self.on_remove_requested, target.voice_id), ) dpg.add_separator() diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 62fcf8968..1553db7e3 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -20,7 +20,7 @@ class ShortcutCategory(StrEnum): APPLICATION = "application" ORDER = "order" TRACKER = "tracker" - SAMPLES = "samples" + VOICES = "voices" DIALOG = "dialog" @@ -182,13 +182,13 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_CANCEL_ENTRY = ("TrackerCancelEntry", ShortcutCategory.TRACKER) TRACKER_PLAY_FROM_ROW = ("TrackerPlayFromRow", ShortcutCategory.TRACKER) - SAMPLES_RENAME_SAMPLE = ("SamplesRenameSample", ShortcutCategory.SAMPLES) - SAMPLES_REMOVE_SAMPLE = ("SamplesRemoveSample", ShortcutCategory.SAMPLES) - SAMPLES_MOVE_SAMPLE_UP = ("SamplesMoveSampleUp", ShortcutCategory.SAMPLES) - SAMPLES_MOVE_SAMPLE_DOWN = ("SamplesMoveSampleDown", ShortcutCategory.SAMPLES) - SAMPLES_MOVE_SAMPLE_TO_TOP = ("SamplesMoveSampleToTop", ShortcutCategory.SAMPLES) - SAMPLES_MOVE_SAMPLE_TO_BOTTOM = ("SamplesMoveSampleToBottom", ShortcutCategory.SAMPLES) - SAMPLES_CANCEL_RENAME = ("SamplesCancelRename", ShortcutCategory.SAMPLES) + VOICES_RENAME_VOICE = ("VoicesRenameVoice", ShortcutCategory.VOICES) + VOICES_REMOVE_VOICE = ("VoicesRemoveVoice", ShortcutCategory.VOICES) + VOICES_MOVE_VOICE_UP = ("VoicesMoveVoiceUp", ShortcutCategory.VOICES) + VOICES_MOVE_VOICE_DOWN = ("VoicesMoveVoiceDown", ShortcutCategory.VOICES) + VOICES_MOVE_VOICE_TO_TOP = ("VoicesMoveVoiceToTop", ShortcutCategory.VOICES) + VOICES_MOVE_VOICE_TO_BOTTOM = ("VoicesMoveVoiceToBottom", ShortcutCategory.VOICES) + VOICES_CANCEL_RENAME = ("VoicesCancelRename", ShortcutCategory.VOICES) DIALOG_NEXT_CONTROL = ("DialogNextControl", ShortcutCategory.DIALOG) DIALOG_PREVIOUS_CONTROL = ("DialogPreviousControl", ShortcutCategory.DIALOG) diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index ae4b457e7..94877f02d 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -15,7 +15,7 @@ class SequencerCellViewModel(BaseModel, frozen=True): """One channel cell on one tracker row, pre-formatted for display. The columns are produced by :mod:`sampletones_core.utils.display`, the single - source of tracker cell formatting (sample position, transpose, volume). The + source of tracker cell formatting (voice position, transpose, volume). The tracker grid renders :attr:`label`, the combined cell text. """ @@ -32,32 +32,32 @@ class SequencerRowViewModel(BaseModel, frozen=True): index: int cells: Dict[ChannelName, SequencerCellViewModel] relevant_channels: FrozenSet[ChannelName] - """Channels the row's sample(s) span — the union of their reconstructions' channels. + """Channels the row's voices span — the union of their reconstructions' channels. - The sample column summarises a subcolumn only across these channels, so a + The voice column summarises a subcolumn only across these channels, so a sample that spans more channels than it currently occupies reads as mixed. """ @property def subcolumn_channels(self) -> FrozenSet[ChannelName]: - """Channels every sample column summary spans. + """Channels every voice column summary spans. A sample governs the channels its reconstruction covers, so its subcolumns - summarise exactly those. Transpose and volume exist independently of an - instrument, so a row with no sample spans every channel. + summarise exactly those. Transpose and volume stand on their own, so a row + naming no voice spans every channel. """ return self.relevant_channels or frozenset(self.cells) @property - def sample_instrument(self) -> str: + def voice(self) -> str: return self._aggregate(lambda cell: cell.instrument, display_id(None)) @property - def sample_transpose(self) -> str: + def transpose(self) -> str: return self._aggregate(lambda cell: cell.transpose, display_transpose(None)) @property - def sample_volume(self) -> str: + def volume(self) -> str: return self._aggregate(lambda cell: cell.volume, display_volume(None)) def _aggregate( @@ -65,10 +65,10 @@ def _aggregate( select: Callable[[SequencerCellViewModel], str], default: str, ) -> str: - """Summarise one subcolumn across the channels the sample column spans. + """Summarise one subcolumn across the channels the voice column spans. The summary holds a value only where every channel agrees on it, so - :data:`MIXED` marks each way they can differ: a sample missing from one of + :data:`MIXED` marks each way they can differ: a voice missing from one of its channels, a transpose set on some of them, or a row cut on some and blank on the rest. A row with no cells at all shows the empty default. """ diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index fae874fcf..2672b2502 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -135,14 +135,14 @@ bindings: TrackerCancelEntry: {combination: "Esc"} TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"} - # samples - SamplesRenameSample: {combination: "F2"} - SamplesRemoveSample: {combination: "Del"} - SamplesMoveSampleUp: {combination: "Alt+Up"} - SamplesMoveSampleDown: {combination: "Alt+Down"} - SamplesMoveSampleToTop: {combination: "Alt+Home"} - SamplesMoveSampleToBottom: {combination: "Alt+End"} - SamplesCancelRename: {combination: "Esc"} + # voices + VoicesRenameVoice: {combination: "F2"} + VoicesRemoveVoice: {combination: "Del"} + VoicesMoveVoiceUp: {combination: "Alt+Up"} + VoicesMoveVoiceDown: {combination: "Alt+Down"} + VoicesMoveVoiceToTop: {combination: "Alt+Home"} + VoicesMoveVoiceToBottom: {combination: "Alt+End"} + VoicesCancelRename: {combination: "Esc"} # dialogs DialogNextControl: {combination: "Tab"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index fabfb0303..340b76d73 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -135,14 +135,14 @@ bindings: TrackerCancelEntry: {combination: "Esc"} TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"} - # samples - SamplesRenameSample: {combination: "F2"} - SamplesRemoveSample: {combination: "Del", aliases: ["Cmd+Backspace"]} - SamplesMoveSampleUp: {combination: "Alt+Up"} - SamplesMoveSampleDown: {combination: "Alt+Down"} - SamplesMoveSampleToTop: {combination: "Alt+Home", aliases: ["Cmd+Alt+Up"]} - SamplesMoveSampleToBottom: {combination: "Alt+End", aliases: ["Cmd+Alt+Down"]} - SamplesCancelRename: {combination: "Esc"} + # voices + VoicesRenameVoice: {combination: "F2"} + VoicesRemoveVoice: {combination: "Del", aliases: ["Cmd+Backspace"]} + VoicesMoveVoiceUp: {combination: "Alt+Up"} + VoicesMoveVoiceDown: {combination: "Alt+Down"} + VoicesMoveVoiceToTop: {combination: "Alt+Home", aliases: ["Cmd+Alt+Up"]} + VoicesMoveVoiceToBottom: {combination: "Alt+End", aliases: ["Cmd+Alt+Down"]} + VoicesCancelRename: {combination: "Esc"} # dialogs DialogNextControl: {combination: "Tab"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 7415bc4cd..8c208ee80 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -531,7 +531,7 @@ sequencer.tracker.label.tracker_text: "Tracker" sequencer.tracker.label.octave: "Octave" sequencer.tracker.tooltip.octave: "The octave a note key types at" sequencer.tracker.label.column_row: "Row" -sequencer.tracker.label.column_sample: "Sample" +sequencer.tracker.label.column_voice: "Voice" sequencer.tracker.label.column_pulse_1: "Pulse 1" sequencer.tracker.label.column_pulse_2: "Pulse 2" sequencer.tracker.label.column_triangle: "Triangle" @@ -542,8 +542,8 @@ sequencer.tracker.label.context_select_all: "Select all" sequencer.tracker.label.context_select_column: "Select column" sequencer.tracker.label.context_select_subcolumn: "Select subcolumn" sequencer.tracker.label.context_note_off: "Note off" -sequencer.tracker.label.context_set_instrument: "Set instrument" -sequencer.tracker.label.context_no_samples: "No samples" +sequencer.tracker.label.context_set_voice: "Set voice" +sequencer.tracker.label.context_no_voices: "No voices" sequencer.tracker.label.context_clear_subcolumn: "Clear subcolumn" sequencer.tracker.label.context_clear_cell: "Clear cell" sequencer.tracker.label.context_clear_row: "Clear row" @@ -562,7 +562,7 @@ sequencer.tracker.label.context_unsolo: "Unsolo" sequencer.tracker.label.context_mute_all: "Mute all channels" sequencer.tracker.label.context_unmute_all: "Unmute all channels" sequencer.tracker.tooltip.header_channel: "Click to mute or unmute this channel.\n{modifier}+click to solo it, right-click for channel actions." -sequencer.tracker.tooltip.header_sample: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions." +sequencer.tracker.tooltip.header_voice: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions." # ============================================================================= # Sequencer tab — Order @@ -811,7 +811,7 @@ settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" settings.keybindings.title.tracker: "Tracker" -settings.keybindings.title.samples: "Samples" +settings.keybindings.title.voices: "Voices" settings.keybindings.title.reassign_confirmation: "Combination in use" settings.keybindings.title.reset_confirmation: "Restore the shipped keys" settings.keybindings.title.discard_confirmation: "Discard keyboard shortcuts" @@ -950,13 +950,13 @@ settings.keybindings.label.tracker_clear_row: "Clear row" settings.keybindings.label.tracker_clear_previous_row: "Clear the previous row" settings.keybindings.label.tracker_cancel_entry: "Cancel entry" settings.keybindings.label.tracker_play_from_row: "Play from the current row" -settings.keybindings.label.samples_rename_sample: "Rename sample" -settings.keybindings.label.samples_remove_sample: "Remove sample" -settings.keybindings.label.samples_move_sample_up: "Move sample up" -settings.keybindings.label.samples_move_sample_down: "Move sample down" -settings.keybindings.label.samples_move_sample_to_top: "Move sample to the top" -settings.keybindings.label.samples_move_sample_to_bottom: "Move sample to the bottom" -settings.keybindings.label.samples_cancel_rename: "Cancel renaming" +settings.keybindings.label.voices_rename_voice: "Rename voice" +settings.keybindings.label.voices_remove_voice: "Remove voice" +settings.keybindings.label.voices_move_voice_up: "Move voice up" +settings.keybindings.label.voices_move_voice_down: "Move voice down" +settings.keybindings.label.voices_move_voice_to_top: "Move voice to the top" +settings.keybindings.label.voices_move_voice_to_bottom: "Move voice to the bottom" +settings.keybindings.label.voices_cancel_rename: "Cancel renaming" settings.properties.title.window_title: "Project properties" settings.properties.label.title: "Title" settings.properties.label.author: "Author" diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 21885c538..6e9d18d14 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -63,13 +63,13 @@ def test_add_sample_appends_and_emits( ) -> None: controller = _controller() emitted: List[str] = [] - controller.on_voices_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("voices") sample = controller.add_sample(reconstruction_factory(), name="lead") assert list(controller.project.voices) == [sample] assert controller.project.voice(sample.id) is sample - assert emitted == ["samples"] + assert emitted == ["voices"] def test_add_sample_detaches_source_but_keeps_object_identity( self, @@ -171,12 +171,12 @@ def test_move_sample_emits_samples_and_song_changes( sample = controller.add_sample(reconstruction_factory(), name="lead") controller.add_sample(reconstruction_factory(), name="pad") emitted: List[str] = [] - controller.on_voices_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("voices") controller.on_song_changed = lambda: emitted.append("song") controller.move_voice(sample.id, 1) - assert "samples" in emitted + assert "voices" in emitted assert "song" in emitted def test_duplicate_sample_appends_independent_copy( @@ -203,11 +203,11 @@ def test_duplicate_sample_emits_samples_change( controller = _controller() source = controller.add_sample(reconstruction_factory(), name="lead") emitted: List[str] = [] - controller.on_voices_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("voices") controller.duplicate_voice(source.id) - assert emitted == ["samples"] + assert emitted == ["voices"] def test_replace_sample_reconstruction_swaps_content_and_keeps_identity( self, @@ -267,7 +267,7 @@ def test_replace_sample_reconstruction_emits_samples_and_song_changes( controller = _controller() sample = controller.add_sample(reconstruction_factory(), name="lead") emitted: List[str] = [] - controller.on_voices_changed = lambda: emitted.append("samples") + controller.on_voices_changed = lambda: emitted.append("voices") controller.on_song_changed = lambda: emitted.append("song") controller.replace_sample_reconstruction( @@ -275,7 +275,7 @@ def test_replace_sample_reconstruction_emits_samples_and_song_changes( reconstruction_factory(), ) - assert "samples" in emitted + assert "voices" in emitted assert "song" in emitted diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py index aa287d0ed..d5a6cc6dc 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -123,7 +123,7 @@ def test_it_declines_an_instrument(self) -> None: controller, logic = _logic() instrument = _instrument(controller) - logic.set_sample_instrument(0, instrument.id) + logic.set_row_voice(0, instrument.id) assert all(logic.row(channel, 0) is None or logic.row(channel, 0).is_empty() for channel in ChannelName.items()) @@ -134,4 +134,4 @@ def test_it_reads_mixed_where_the_channels_disagree_on_the_face(self) -> None: _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0) _write(controller, ChannelName.PULSE2, 0, command=NoteOn(voice_id=sample.id), transpose=0) - assert logic.build_grid().rows[0].sample_transpose == "?" + assert logic.build_grid().rows[0].transpose == "?" diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 1948b96d6..9d660c848 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -81,7 +81,7 @@ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) logic.set_note_off(ChannelName.NOISE, 0) logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT) @@ -96,7 +96,7 @@ def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> No sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) for channel in ChannelName.items(): logic.set_row(channel, 0, transpose=5) @@ -294,7 +294,7 @@ def test_fills_only_used_generators(self) -> None: name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): command = _row(controller, channel).command @@ -324,7 +324,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: sample_reconstruction([ChannelName.PULSE1]), name="lead", ) - logic.set_sample_instrument(0, lead.id) + logic.set_row_voice(0, lead.id) assert _row(controller, ChannelName.PULSE1).command is not None cleared = _row(controller, ChannelName.PULSE2) @@ -338,9 +338,9 @@ def test_none_sample_clears_the_whole_row(self) -> None: sample_reconstruction([ChannelName.PULSE1]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) - logic.set_sample_instrument(0, None) + logic.set_row_voice(0, None) for channel in ChannelName.items(): assert _row(controller, channel).command is None @@ -397,7 +397,7 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) logic.set_sample_subcolumn(0, transpose=5) logic.set_sample_subcolumn(0, volume=10) @@ -491,7 +491,7 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: row = logic.build_grid().rows[0] - assert row.sample_instrument == MIXED + assert row.voice == MIXED def test_full_placement_reads_as_the_sample(self) -> None: controller = _controller() @@ -500,12 +500,12 @@ def test_full_placement_reads_as_the_sample(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) row = logic.build_grid().rows[0] - assert row.sample_instrument == row.cells[ChannelName.PULSE1].instrument - assert row.sample_instrument != MIXED + assert row.voice == row.cells[ChannelName.PULSE1].instrument + assert row.voice != MIXED def test_diverging_transpose_renders_as_mixed(self) -> None: controller = _controller() @@ -514,12 +514,12 @@ def test_diverging_transpose_renders_as_mixed(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) logic.set_row(ChannelName.PULSE1, 0, transpose=5) row = logic.build_grid().rows[0] - assert row.sample_transpose == MIXED + assert row.transpose == MIXED def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: controller = _controller() @@ -528,13 +528,13 @@ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_sample_instrument(0, sample.id) + logic.set_row_voice(0, sample.id) logic.set_sample_subcolumn(0, transpose=5) row = logic.build_grid().rows[0] - assert row.sample_transpose == row.cells[ChannelName.PULSE1].transpose - assert row.sample_transpose != MIXED + assert row.transpose == row.cells[ChannelName.PULSE1].transpose + assert row.transpose != MIXED class TestEmptyFrameAutoCreate: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 201dec768..782832471 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -97,8 +97,8 @@ def add_menu_item(self, **kwargs: Any) -> int: "select_column", "select_subcolumn", "note_off", - "set_instrument", - "no_samples", + "set_voice", + "no_voices", "clear_subcolumn", "clear_cell", "clear_row", diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py index 91629fd8a..2fd4842e1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py @@ -4,8 +4,8 @@ DIVIDER_TABLE_COLUMN, HEADER_TABLE_ROW, HEADER_TABLE_ROWS, - SAMPLE_TABLE_COLUMN, TRACKER_TABLE_COLUMNS, + VOICE_TABLE_COLUMN, tracker_table_column, tracker_table_row, ) @@ -22,8 +22,8 @@ def test_sample_column_directly_precedes_the_divider() -> None: - assert tracker_table_column(None) == SAMPLE_TABLE_COLUMN == 2 - assert DIVIDER_TABLE_COLUMN == SAMPLE_TABLE_COLUMN + 1 + assert tracker_table_column(None) == VOICE_TABLE_COLUMN == 2 + assert DIVIDER_TABLE_COLUMN == VOICE_TABLE_COLUMN + 1 @pytest.mark.parametrize("channel, expected_column", _CHANNEL_COLUMNS) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index d097957e6..5c63c6bdf 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -23,8 +23,8 @@ _CONTEXT_LABELS = ( - "_lbl_context_set_instrument", - "_lbl_context_no_samples", + "_lbl_context_set_voice", + "_lbl_context_no_voices", ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py index 1aa7c74f1..af6638f29 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py @@ -393,5 +393,5 @@ def test_the_channel_tooltip_leaves_no_placeholder_behind(self, panel: GUISequen def test_both_headers_explain_their_click(self, panel: GUISequencerTrackerPanel) -> None: assert panel._tooltip_header_channel - assert panel._tooltip_header_sample - assert panel._tooltip_header_channel != panel._tooltip_header_sample + assert panel._tooltip_header_voice + assert panel._tooltip_header_channel != panel._tooltip_header_voice diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index 98a6b4413..5d762e417 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -264,8 +264,8 @@ def test_the_items_print_the_keys_the_panel_answers_to( shortcuts = shipped_source() _panel(monkeypatch).panel.build_edit_actions() - assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE) - assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE) + assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.VOICES_RENAME_VOICE) + assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE) assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [ shortcuts.display(move.shortcut) for move in VOICE_MOVES ] diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py index 183e31beb..565893c34 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -145,9 +145,9 @@ class TestCase(BaseRegularTestCase): ), TestCase( label="a combination held in another category too", - shortcut_id=ShortcutId.SAMPLES_MOVE_SAMPLE_UP, + shortcut_id=ShortcutId.VOICES_MOVE_VOICE_UP, written="F2", - holder=ShortcutId.SAMPLES_RENAME_SAMPLE, + holder=ShortcutId.VOICES_RENAME_VOICE, ), ) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 868689bf6..5efd6a592 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -109,10 +109,10 @@ def test_an_alias_claiming_another_action_s_combination_raises(self, rebound: Re def test_one_combination_serves_a_category_of_its_own(self, rebound: RebindScheme) -> None: """Tab moves between dialog controls and between tracker columns, each in its own scope.""" - scheme = rebound({ShortcutId.SAMPLES_RENAME_SAMPLE: WrittenShortcut(combination="Tab")}) + scheme = rebound({ShortcutId.VOICES_RENAME_VOICE: WrittenShortcut(combination="Tab")}) assert scheme.shortcut(ShortcutId.TRACKER_NEXT_COLUMN).display() == "Tab" - assert scheme.shortcut(ShortcutId.SAMPLES_RENAME_SAMPLE).display() == "Tab" + assert scheme.shortcut(ShortcutId.VOICES_RENAME_VOICE).display() == "Tab" def test_a_combination_naming_no_key_raises(self, rebound: RebindScheme) -> None: with pytest.raises(KeyError): @@ -127,7 +127,7 @@ def test_an_alias_resolves_to_the_action_it_extends(self, shipped: ShortcutSchem assert shipped.action(ShortcutCategory.ORDER, _press("Num+")) is ShortcutId.ORDER_INSERT_FRAME def test_a_press_the_category_leaves_unnamed_resolves_to_nothing(self, shipped: ShortcutScheme) -> None: - assert shipped.action(ShortcutCategory.SAMPLES, _press("Ctrl+S")) is None + assert shipped.action(ShortcutCategory.VOICES, _press("Ctrl+S")) is None def test_each_category_answers_a_shared_combination_with_its_own_action( self, @@ -160,7 +160,7 @@ def test_an_alias_reads_as_the_action_it_extends(self, shipped: ShortcutScheme) assert claimant is ShortcutId.REDO def test_a_combination_the_category_leaves_unclaimed_reads_as_nothing(self, shipped: ShortcutScheme) -> None: - assert shipped.claimant(ShortcutCategory.SAMPLES, KeyCombination.parse("Ctrl+Z")) is None + assert shipped.claimant(ShortcutCategory.VOICES, KeyCombination.parse("Ctrl+Z")) is None def test_each_category_answers_a_shared_combination_with_its_own_action(self, shipped: ShortcutScheme) -> None: escape = KeyCombination.parse("Esc") @@ -272,7 +272,7 @@ def test_an_override_taking_a_combination_another_category_holds_stands(self, sh scheme = shipped.with_overrides({"AboutDialog": TABLE_COMBINATION}) assert scheme.action(ShortcutCategory.APPLICATION, _press(TABLE_COMBINATION)) is ShortcutId.ABOUT_DIALOG - assert scheme.action(ShortcutCategory.SAMPLES, _press(TABLE_COMBINATION)) is ShortcutId.SAMPLES_REMOVE_SAMPLE + assert scheme.action(ShortcutCategory.VOICES, _press(TABLE_COMBINATION)) is ShortcutId.VOICES_REMOVE_VOICE def test_an_override_stating_no_combination_leaves_the_action_unbound(self, shipped: ShortcutScheme) -> None: scheme = shipped.with_overrides({"Undo": None}) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py index 939a3532c..f1ef55ff2 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py @@ -45,10 +45,10 @@ def test_a_rebind_changes_what_a_scope_makes_of_a_press( source: ShortcutSource, rebound: RebindScheme, ) -> None: - source.activate(rebound({ShortcutId.SAMPLES_RENAME_SAMPLE: WrittenShortcut(combination="F6")})) + source.activate(rebound({ShortcutId.VOICES_RENAME_VOICE: WrittenShortcut(combination="F6")})) - assert source.action(ShortcutCategory.SAMPLES, _press("F6")) is ShortcutId.SAMPLES_RENAME_SAMPLE - assert source.action(ShortcutCategory.SAMPLES, _press("F2")) is None + assert source.action(ShortcutCategory.VOICES, _press("F6")) is ShortcutId.VOICES_RENAME_VOICE + assert source.action(ShortcutCategory.VOICES, _press("F2")) is None def test_activating_announces_the_scheme_now_in_place( self, diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index 7f4acc2f3..c3909e929 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -182,6 +182,6 @@ def test_sample_column_aggregates_over_relevant_channels( relevant_channels=case.relevant_channels, ) - assert row.sample_instrument == case.expected_instrument - assert row.sample_transpose == case.expected_transpose - assert row.sample_volume == case.expected_volume + assert row.voice == case.expected_instrument + assert row.transpose == case.expected_transpose + assert row.volume == case.expected_volume From e6198431196836b3d1ac7e69e63c2112036f46c0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 16:26:01 +0200 Subject: [PATCH 085/142] Cleanups --- docs/concepts/project.md | 6 +- docs/development/bugs-and-todos.md | 8 +- docs/development/packages.md | 2 +- docs/development/sequencer-blocks.md | 6 +- docs/formats/bitphase.md | 10 +- docs/formats/famitracker.md | 19 +-- docs/glossary.md | 30 +++-- docs/guide/getting-started.md | 4 +- docs/guide/interface.md | 8 +- docs/guide/sequencer.md | 30 ++--- docs/index.md | 2 +- .../categories/elements/global_.py | 96 --------------- .../categories/elements/instructions.py | 33 ------ .../categories/elements/main.py | 111 ------------------ .../categories/elements/reconstructions.py | 82 ------------- .../categories/elements/sequencer.py | 17 --- .../categories/elements/settings.py | 14 --- 17 files changed, 65 insertions(+), 413 deletions(-) delete mode 100644 src/sampletones_application/categories/elements/reconstructions.py diff --git a/docs/concepts/project.md b/docs/concepts/project.md index 843b68f7c..3a403a66b 100644 --- a/docs/concepts/project.md +++ b/docs/concepts/project.md @@ -8,8 +8,8 @@ plays them all, so an entire piece lives as one file. ## What a project brings together -- the **voices** — the reconstructions you have imported and the shapes you have - written by hand, each a playable instrument in the song; +- the **voices** — the reconstructions you have imported and the instruments you have + written by hand, each of them something a row can play; - the **song** — the arrangement itself: the patterns written for each channel and the order they play in; - the **timing and details** — the tempo, speed, and NES frequency the song plays @@ -21,7 +21,7 @@ export the finished piece as a FamiTracker [module](../formats/famitracker.md). ## Self-contained and portable A project embeds the reconstructions it uses rather than pointing at them elsewhere -on disk, and writes each shape into the document itself, so moving or sharing the +on disk, and writes each hand-written instrument into the document itself, so moving or sharing the file carries the whole composition — the arrangement and every sound it needs. The embedded reconstructions are [detached](../formats/reconstructions.md#detached-reconstructions) from their diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4d4d88db4..dd9ccbdf4 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -26,7 +26,7 @@ * A loop point per envelope: a voice states one point, applied to every populated sequence. * A sample's loop point is offered as a switch in the voice list, though the model carries the point for both kinds of voice. -* Exporting a shape as an instrument file from the Reconstructions tab. +* Exporting a hand-written instrument as an instrument file from the Reconstructions tab. * `SubColumn.INSTRUMENT` names the first slot of both tracker column kinds, and the two hold different things: the voice id under the Voice column, and the note on a channel column. One name for both is wrong half the time, and splitting it reaches the layout keys @@ -57,6 +57,12 @@ Configuration and session state carry no version at all, so the same corpus would state what a build is expected to make of a `state.yaml` an older one left behind. Reaches `tests/unit/sampletones_core/compatibility/` and each format's load tests. +* The element enums that outlived their keys. A lookup states its key literally, so an element + enum is named only where a `_label(element)` helper takes one — `ui/menu.py`, + `coordinators/project.py`, `coordinators/keybindings.py`, `ui/panels/dialogs/project_properties.py` + and the panels beside them. The language-keys check expands such a helper over the whole enum, so + a member no call names is reached all the same and stands unnoticed. Spelling those keys literally + at the call site would make each entry exactly checkable and retire the enums that remain. * Respecting FamiTracker limitations * Per-tab undo routing * In-application console diff --git a/docs/development/packages.md b/docs/development/packages.md index 7a9ffa863..260751b3b 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -62,7 +62,7 @@ instruction each channel sounds on each engine tick — the order walked frame b column starting a voice, its transpose and volume bending what that voice carries, a voice with a loop point circling where one without falls silent — is `sampletones_core/performance/`. One reading answers for both kinds of voice: a sample plays the frames its conversion found for the -channel, a shape the frames its envelopes make of it. The sequencer renders +channel, a hand-written instrument the frames its envelopes make of it. The sequencer renders those instructions to audio and the player encodes them into register values, so what a listener hears and what the console plays are the same walk read two ways rather than two implementations of one rule. diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 3748d40e1..d6f0284f7 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -26,7 +26,7 @@ coordinates, which is what lets it land anywhere it is anchored. Two axes underpin both grids: - **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + ChannelName.items()`. Index 0 - is the aggregate column (the tracker's **Sample**, the order's **Master**) and 1 to 4 + is the aggregate column (the tracker's **Voice**, the order's **Master**) and 1 to 4 are the channels. Both grids lay out along it, so a row index means the same thing in either. - **`view_model/sequencer/slot.py::TrackerSlot`** — a column paired with a subcolumn, @@ -75,7 +75,7 @@ Two consequences follow from the order the writes are taken in: channel cell in the same block overwrites what the aggregate settled. The more specific write wins. - In the tracker, notes land before the transposes and volumes sharing their row, because - placing a sample through the **Sample** column clears the channels of that row. + placing a sample through the **Voice** column clears the channels of that row. ## The order grows to what a paste reaches @@ -208,7 +208,7 @@ far corner. The whole frame, a column and a subcolumn are therefore three naming rectangle, as the whole order and a channel row are of the other, and a grid laying out nothing keeps the selection it had. -The aggregate is an ordinary member of the axis here: selecting the **Sample** column selects a +The aggregate is an ordinary member of the axis here: selecting the **Voice** column selects a column the way selecting a channel does, and the **Master** row a row. A press names its shape from the cell the cursor stands on, which is the cell the context menu's diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 4c977b945..8bdef7469 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -84,11 +84,11 @@ sets `loop = len - 1` and rests on the level that row carries — silence where envelope ends on a note-off item, the channel's own level where the slice holds its volume. A voice's loop point drives this, the same point the FamiTracker exporter reads. -**A shape's slices.** Bitphase bakes a channel's registers tick by tick, so a -[shape](../glossary.md#shape) reaches a document as a slice per channel it sounds on, -each reading the dimensions that channel offers and moving around the root it states. -The envelopes are one set whatever the channel, so the slices differ only in what each -channel reads of them. +**A hand-written instrument's slices.** Bitphase bakes a channel's registers tick by +tick, so an [instrument](../glossary.md#instrument) written by hand reaches a document +as a slice per channel it sounds on, each reading the dimensions that channel offers +and moving around the root it states. The envelopes are one set whatever the channel, +so the slices differ only in what each channel reads of them. **A held volume.** A slice whose volume envelope carries no item leaves its level to the channel, so the exporter writes a full `volumeOrRate` for every frame the slice diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index a92e7a65e..d1af203c0 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -177,18 +177,19 @@ and triggering the instrument at `initial_pitch` replays that contour. Volume, d (or noise mode) and any pitch sequences carry across directly. The DPCM key-assignment table is empty by design. -A [shape](../glossary.md#shape) is one set of envelopes every channel reads, which is -the instrument model FamiTracker itself uses, so it becomes a single instrument -however many channels play it. Its dimensions are written at one length, each holding -its final value where it is the shorter, so a tracker advancing every sequence on a -counter of its own sounds the shape the way the engine here plays it. Every channel -that names the shape reaches that one instrument, each against the root it reads — -the shape's note on the tonal channels, its period on noise. +An [instrument](../glossary.md#instrument) written by hand is one set of envelopes +every channel reads, which is the instrument model FamiTracker itself uses, so it +becomes a single instrument however many channels play it. Its dimensions are written +at one length, each holding its final value where it is the shorter, so a tracker +advancing every sequence on a counter of its own sounds it the way the engine here +plays it. Every channel that names it reaches that one instrument, each against the +root it reads — its note on the tonal channels, its period on noise. **Where a row's note comes from.** A voice states where its zero is and a row states the step from it, so a pattern cell holds `reference + transpose`, held inside the range a tonal channel plays and wrapped into the sixteen periods on noise. A sample's -reference is the offset origin its conversion chose; a shape's is the root it states. +reference is the offset origin its conversion chose; a hand-written instrument's is the +root it states. That origin is chosen once, when the reconstruction is built, and stored with it as that channel's reference pitch (see [Reconstructions](reconstructions.md#contents)). @@ -210,7 +211,7 @@ checklist. | Quantity | FamiTracker limit | Project bound today | Exporter behaviour | | --- | --- | --- | --- | -| Instruments | 64 total | unbounded (1–4 per sample, one per shape) | raises when the instruments exceed 64 | +| Instruments | 64 total | unbounded (1–4 per sample, one per hand-written instrument) | raises when the instruments exceed 64 | | Sequences per kind | 128 | unbounded | raises when a kind's pool exceeds 128 | | Items per sequence | 252 | one item per reconstruction frame, unbounded | keeps the opening 252 items and logs a warning | | Patterns per channel | 128 (indices 0–127) | pool keyed by arbitrary ints | raises when a pattern index exceeds 127 | diff --git a/docs/glossary.md b/docs/glossary.md index f7fa783c3..60719f206 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -220,7 +220,7 @@ channel, which carries the pitch contour a FamiTracker arpeggio sequence would. ### Voice -Anything a tracker row can name: a **sample** or a **shape**. A project holds its +Anything a tracker row can name: a **sample** or an **instrument**. A project holds its voices in one list, and a row states which one to start and the step it plays at. ### Sample (sequencer) @@ -228,31 +228,28 @@ voices in one list, and a row states which one to start and the step it plays at A reconstruction added to the sequencer as a playable voice, carrying the instruction stream its conversion found for each channel. -### Shape +### Instrument -A voice written by hand: envelopes with no recording behind them. One shape is one -instrument every channel can read, so it is placed on whichever channel suits it — -the way a FamiTracker instrument is. See [The sequencer](guide/sequencer.md). +One set of envelopes a channel reads while a note sounds, saved as an `.fti` file. A +voice written by hand is a single instrument, placed on whichever channel suits it — +the way a FamiTracker instrument is; a sample carries one instrument per channel it +plays. See [The sequencer](guide/sequencer.md) and +[FamiTracker export](formats/famitracker.md). Bitphase takes the same envelopes as a +`.json` instrument preset. See [Bitphase export](formats/bitphase.md). ### Root -The note a shape's arpeggio is measured against, which a row's step moves it from. -A shape states one for the tonal channels and one for the noise channel's periods, -so the same envelopes sound on any of the four. The matching value on a sample is -its per-channel [reference pitch](formats/reconstructions.md#contents). +The note an instrument's arpeggio is measured against, which a row's step moves it +from. An instrument written by hand states one for the tonal channels and one for the +noise channel's periods, so the same envelopes sound on any of the four. The matching +value on a sample is its per-channel +[reference pitch](formats/reconstructions.md#contents). ### Loop point The tick a voice's envelopes repeat from while a note is held, which lets an attack be followed by a sustained tail. A voice without one plays its envelopes once. -### Instrument - -A single FamiTracker instrument, saved as an `.fti` file. A sample exports one per -channel it plays; a shape exports one that every channel reaches. See -[FamiTracker export](formats/famitracker.md). Bitphase takes the same envelopes as -a `.json` instrument preset. See [Bitphase export](formats/bitphase.md). - ## File types | Extension | Contents | @@ -263,4 +260,5 @@ a `.json` instrument preset. See [Bitphase export](formats/bitphase.md). | `.fti` | FamiTracker instrument ([export](formats/famitracker.md)). | | `.ftm` | FamiTracker module ([export](formats/famitracker.md)). | | `.btp` | Bitphase document ([export](formats/bitphase.md)). | +| `.nsf` | [NSF program](formats/nsf.md) — a song and the driver that plays it. | | `.json` | Bitphase instrument preset ([export](formats/bitphase.md)), or the [configuration file](formats/configuration.md). | diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 6a01cc940..b9b23eeb6 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -35,11 +35,11 @@ tabs in full. left, right-click a reconstruction and choose **Add to Sequencer**. If its NES frequency differs from the project's, confirm with **Add anyway**. 4. In the **Tracker** grid, click a cell and type notes on your keyboard; assign a - sample to a channel with the cell's right-click **Set instrument**. + sample to a channel with the cell's right-click **Set voice**. 5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**, and **NES frequency** under **Module options**. 6. Choose **File ▸ Export ▸ FamiTracker module...** and pick a path for the `.ftm` file. **Bitphase project...** beside it writes the same song as a `.btp`. -The [sequencer guide](sequencer.md) covers the tracker grid, the order, samples, +The [sequencer guide](sequencer.md) covers the tracker grid, the order, voices, and undo history in full. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index f85997075..91dc1d3b4 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -148,14 +148,14 @@ down. **Export instrument...** writes the channel you are looking at, in whichever tracker format you pick in the save dialog — see [where your files live](files.md#exported-files). -A **shape** — a voice you wrote by hand rather than converted, see the -[sequencer guide](sequencer.md#voices-samples-and-shapes) — opens here too, from +An **instrument** — a voice you wrote by hand rather than converted, see the +[sequencer guide](sequencer.md#voices-samples-and-instruments) — opens here too, from the **Voices** list's right-click ▸ **Edit**. It stands on no recording, so the tab -shows its envelopes alone: one instrument every channel reads, under the shape's own +shows its envelopes alone: one set every channel reads, under the instrument's own name. **Root pitch** is the note its arpeggio is measured against on the melodic channels and **Root period** the one on **Noise**, and **Loop point** is the tick its envelopes repeat from while a note is held — an attack followed by a sustained tail. -Editing a shape puts away whatever reconstruction the tab held. +Editing an instrument puts away whatever reconstruction the tab held. ## Instructions diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 6e78fba0c..c7f35aefd 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -8,11 +8,11 @@ open an existing `.stp`). The pattern grid and order sit in the centre, a browse for pulling in reconstructions on the left, and the module settings, voice list, and undo history on the right. -## Voices: samples and shapes +## Voices: samples and instruments A song is built from **voices**, and there are two kinds. A **sample** is a -reconstruction imported as a playable instrument. A **shape** is written by hand — -envelopes with no recording behind them — for the melodies and basses you write +reconstruction brought in as something a row can play. An **instrument** is written by +hand — envelopes with no recording behind them — for the melodies and basses you write yourself. Both sit in the **Voices** list on the right, numbered together, and a mark at the front of each row says which kind it is. @@ -22,42 +22,42 @@ reconstruction was made at a different NES frequency than the project and the project already has voices, _SampleToNES_ warns with **Different NES frequency**; **Add anyway** adds it regardless. -Add a shape with **New shape** at the top of the list. It starts out holding a note at -full volume, so you can place it and hear it straight away; shape it into the sound you +Add an instrument with **New instrument** at the top of the list. It starts out holding a +note at full volume, so you can place it and hear it straight away; give it the sound you want on the **Reconstructions** tab (right-click ▸ **Edit**). See [editing instruments](interface.md#editing-instruments). Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop** flag. The **Edit** menu carries the same actions for the voice you have picked. The right-click menu also names how much room the -voice takes on the NES — a sample's total and then each channel it plays, a shape's -one instrument — measured as its **Loop** flag has it. The figures are in bytes, and +voice takes on the NES — a sample's total and then each channel it plays, and an +instrument's single figure — measured as its **Loop** flag has it. The figures are in bytes, and they count what a FamiTracker export saves. Removing a voice that patterns still use asks first, because it clears every row that references it. ## Writing a pattern The **Tracker** grid is the pattern editor. Each row is one step in time; the -columns are the **Sample** and the four channels — **Pulse 1**, **Pulse 2**, +columns are the **Voice** and the four channels — **Pulse 1**, **Pulse 2**, **Triangle**, **Noise** — each carrying a voice, a pitch, and a volume. Click a cell and type its value. Right-clicking a cell opens the rest of the operations — **Set -instrument**, **Note off**, **Clear cell** and **Clear row**, transpose and volume +voice**, **Note off**, **Clear cell** and **Clear row**, transpose and volume adjustments, **Play from here** to audition from the cursor row, and **Play from this frame** to start at the top of the shown frame. -The **Sample** column places a sample across every channel its reconstruction -covers. A shape is one instrument for one channel at a time, so name it in the +The **Voice** column places a sample across every channel its reconstruction +covers. An instrument sounds on one channel at a time, so name it in the channel column you want it on. ## Reading and typing a pitch A pitch cell holds one number, and it reads in the terms of the voice the channel is carrying. A sample was converted at a pitch of its own, so its cells read as steps -from it — `+00` plays it as recorded, `+0C` an octave up. A shape was written +from it — `+00` plays it as recorded, `+0C` an octave up. An instrument was written against a root you chose, so its cells read as the notes they sound — `C-4`, `A#3`. A row that only bends a note reads the same way as the row that started it. -Type a note into a shape's cell piano-style: the bottom two rows of the keyboard are +Type a note into an instrument's cell piano-style: the bottom two rows of the keyboard are one octave (`Z` `S` `X` `D` `C` …) and the two above them the next (`Q` `2` `W` `3` `E` …). **Octave** above the grid says where the bottom row opens. The keys work on a sample's cell too, writing the step that reaches the note you pressed. The noise @@ -109,7 +109,7 @@ down and to the right of it. In the **Tracker**, a block keeps the kinds of the cells it came from — a transpose lands in a transpose, a volume in a volume, whichever column you paste onto — and whatever reaches past the last row or the last column is left out. A cell reading -`?`, where the **Sample** column's channels disagree, passes over its target and +`?`, where the **Voice** column's channels disagree, passes over its target and leaves what was there; an empty cell empties it. In the **Order**, a block pasted past the last frame grows the song to hold it, and @@ -192,7 +192,7 @@ wherever you see it. |---------|--------| | Click a channel's name | Silence it, or bring it back | | `Ctrl`+click a channel's name | Solo it — silence the other three; `Ctrl`+click again returns the mix you had | -| Click **Sample** (tracker) or **Master** (order) | Silence every channel, or bring them all back | +| Click **Voice** (tracker) or **Master** (order) | Silence every channel, or bring them all back | | Right-click any name | The same actions as a menu | The **Playback ▸ Channels** submenu carries the same mix: a check marks each channel diff --git a/docs/index.md b/docs/index.md index 1854ddcb6..be4c7046a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward. - [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration. - [Getting started](guide/getting-started.md) — your first reconstruction and your first song. - [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus. -- [The sequencer](guide/sequencer.md) — the tracker: arranging samples and hand-written shapes into a song, exporting a module, and rendering it to audio. +- [The sequencer](guide/sequencer.md) — the tracker: arranging samples and hand-written instruments into a song, exporting a module, and rendering it to audio. - [Command line](guide/command-line.md) — running without the graphical interface. - [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses. - [Configuration](guide/configuration.md) — the settings you can change, and where. diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 2de852478..93b8f08ca 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -18,20 +18,6 @@ class DialogElements(AbstractElement): ADD_ANYWAY = "add_anyway" -class TracebackElements(AbstractElement): - COPY = "copy" - SHOW = "show" - HIDE = "hide" - - -class TreeElements(AbstractElement): - ROOT = "root" - SEARCH = "search" - FILTER = "filter" - CLEAR_SEARCH = "clear_search" - FAVORITES_ONLY = "favorites_only" - - class ContextElements(AbstractElement): PLAY = "play" CUT = "cut" @@ -55,33 +41,6 @@ class ContextElements(AbstractElement): SIZE_BYTES = "size_bytes" -class StemsElements(AbstractElement): - """The vocabulary of a stems list, shared by every card that draws one.""" - - LEVEL_CAPTION = "level_caption" - REMOVE = "remove" - DRAG_TOOLTIP = "drag_tooltip" - INERT_TOOLTIP = "inert_tooltip" - MISSING_TOOLTIP = "missing_tooltip" - UNOFFERED_TOOLTIP = "unoffered_tooltip" - STATUS_ROW_DRAG = "status_row_drag" - STATUS_ROW_REVEAL = "status_row_reveal" - STATUS_MASTER = "status_master" - STATUS_CHANNEL = "status_channel" - STATUS_CHANNEL_MUTED = "status_channel_muted" - STATUS_REMOVE = "status_remove" - - -class NodeDetailElements(AbstractElement): - SAMPLE_RATE = "detail_sample_rate" - NES_FREQUENCY = "detail_nes_frequency" - CHANNELS = "detail_channels" - SPECTRUM_METHOD = "detail_spectrum_method" - TRANSFORMATION_GAMMA = "detail_transformation_gamma" - WINDOW_SIZE = "detail_window_size" - CONFIGURATION = "detail_configuration" - - class MenuElements(AbstractElement): GROUP_FILE = "group_file" ITEM_FILE_NEW_PROJECT = "item_file_new_project" @@ -145,43 +104,6 @@ class MenuElements(AbstractElement): TAB_SEQUENCER = "tab_sequencer" -class StatusElements(AbstractElement): - PATH = "path" - NODE_RECONSTRUCTION_NO_AUTOPLAY = "node_reconstruction_no_autoplay" - NODE_RECONSTRUCTION = "node_reconstruction" - NODE_LIBRARY = "node_library" - TREE_SEARCH = "tree_search" - CLEAR_SEARCH = "clear_search" - FAVORITES_ONLY = "favorites_only" - INPUT = "input" - COMBO = "combo" - NODE_DIRECTORY = "node_directory" - RETUNING_SAMPLES = "retuning_samples" - - -class PlayerElements(AbstractElement): - PLAY = "play" - PAUSE = "pause" - RESUME = "resume" - STOP = "stop" - AUDIO_PLAYBACK_ERROR = "audio_playback_error" - - -class GraphElements(AbstractElement): - WAVEFORM_ORIGINAL = "waveform_original" - WAVEFORM_RECONSTRUCTION = "waveform_reconstruction" - WAVEFORM_TIME_AXIS = "waveform_time_axis" - WAVEFORM_AMPLITUDE_AXIS = "waveform_amplitude_axis" - WAVEFORM_SAMPLE_NAME = "waveform_sample_name" - SPECTRUM_X_AXIS = "spectrum_x_axis" - SPECTRUM_FREQUENCY_AXIS = "spectrum_frequency_axis" - SPECTRUM_NAME = "spectrum_name" - BAR_DISPLAY = "bar_display" - SPECTRUM_NAVIGATION = "spectrum_navigation" - WAVEFORM_NAVIGATION = "waveform_navigation" - WAVEFORM_REGENERATING = "waveform_regenerating" - - class GlobalMessageElements(AbstractElement): TREE_NO_RESULTS = "tree_no_results" TREE_NO_FAVORITES = "tree_no_favorites" @@ -268,21 +190,3 @@ class FileFilterElements(AbstractElement): CONFIG = "config" AUDIO = "audio" WAVE = "wave" - - -class GlobalTemplateElements(AbstractElement): - TIME_ESTIMATION = "time_estimation" - CONFIGURATION_RECOVERY_INTRO = "configuration_recovery_intro" - FPS = "fps" - ABOUT_AUTHOR = "about_author" - EXPAND = "expand" - COLLAPSE = "collapse" - ON = "on" - OFF = "off" - - -class GlobalPitchElements(AbstractElement): - PITCH_NAME = "pitch_name" - PERIOD_NAME = "period_name" - PITCH_EXAMPLE = "pitch_example" - PERIOD_EXAMPLE = "period_example" diff --git a/src/sampletones_application/categories/elements/instructions.py b/src/sampletones_application/categories/elements/instructions.py index 617d4ff8e..7414a97bf 100644 --- a/src/sampletones_application/categories/elements/instructions.py +++ b/src/sampletones_application/categories/elements/instructions.py @@ -41,36 +41,3 @@ class InstructionsLibraryElements(AbstractElement): LIBRARY_EXISTS_TEMPLATE = "library_exists_template" LIBRARY_LOADED_TEMPLATE = "library_loaded_template" INCOMPATIBLE_VERSION_TEMPLATE = "incompatible_version_template" - - -class InstructionPanelElements(AbstractElement): - WAVEFORM_LABEL = "waveform_label" - SPECTRUM_LABEL = "spectrum_label" - - -class InstructionsDetailsElements(AbstractElement): - DETAILS_TEXT = "details_text" - PARAMETERS_TEXT = "parameters_text" - GENERAL_TEXT = "general_text" - CELL_NES_FREQUENCY = "cell_nes_frequency" - CELL_GENERATOR = "cell_generator" - CELL_NAME = "cell_name" - CELL_FREQUENCY = "cell_frequency" - CELL_NO_FREQUENCY = "cell_no_frequency" - CELL_SAMPLE_LENGTH = "cell_sample_length" - CELL_SAMPLES_SUFFIX = "cell_samples_suffix" - WINDOW_PULSE_PITCH = "window_pulse_pitch" - WINDOW_PULSE_VOLUME = "window_pulse_volume" - WINDOW_PULSE_DUTY_CYCLE = "window_pulse_duty_cycle" - WINDOW_NOISE_PERIOD = "window_noise_period" - WINDOW_NOISE_VOLUME = "window_noise_volume" - WINDOW_NOISE_SHORT = "window_noise_short" - WINDOW_TRIANGLE_PITCH = "window_triangle_pitch" - FREQUENCY_TEMPLATE = "frequency_template" - PITCH_TEMPLATE = "pitch_template" - PERIOD_TEMPLATE = "period_template" - DUTY_CYCLE_TEMPLATE = "duty_cycle_template" - PITCH_TOOLTIP_TEMPLATE = "pitch_tooltip_template" - STATUS_INPUT_PITCH = "status_input_pitch" - STATUS_INPUT_PERIOD = "status_input_period" - NO_INSTRUCTION_SELECTED = "no_instruction_selected" diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 11817b2e7..ed0fdf399 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -1,102 +1,6 @@ from sampletones_application.categories.abstract import AbstractElement -class ExplorerElements(AbstractElement): - SECTION = "section" - REFRESH_BUTTON = "refresh_button" - CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction" - CONTEXT_LOAD_LIBRARY = "context_load_library" - CONTEXT_RECONSTRUCT_FILE = "context_reconstruct_file" - CONTEXT_RECONSTRUCT_DIRECTORY = "context_reconstruct_directory" - CONTEXT_ADD_STEM = "context_add_stem" - CONTEXT_ADD_FOLDER_STEMS = "context_add_folder_stems" - CONTEXT_SET_LIBRARY_DIRECTORY = "context_set_library_directory" - CONTEXT_SET_OUTPUT_DIRECTORY = "context_set_output_directory" - STATUS_REFRESH = "status_refresh" - STATUS_NODE_AUDIO_NO_AUTOPLAY = "status_node_audio_no_autoplay" - STATUS_NODE_AUDIO = "status_node_audio" - STATUS_NODE_LIBRARY = "status_node_library" - CONVERTER_RUNNING_MSG = "converter_running_msg" - CONVERTER_RUNNING_DIALOG = "converter_running_dialog" - - -class ConfigPanelElements(AbstractElement): - SECTION = "section" - SECTION_LIBRARY = "section_library" - CHECKBOX_NORMALIZE = "checkbox_normalize" - CHECKBOX_QUANTIZE = "checkbox_quantize" - INPUT_SAMPLE_RATE = "input_sample_rate" - INPUT_NES_FREQUENCY = "input_nes_frequency" - TOOLTIP_NORMALIZE = "tooltip_normalize" - TOOLTIP_QUANTIZE = "tooltip_quantize" - TOOLTIP_SAMPLE_RATE = "tooltip_sample_rate" - TOOLTIP_NES_FREQUENCY = "tooltip_nes_frequency" - - -class ReconstructorElements(AbstractElement): - SECTION_CHANNELS = "section_channels" - SECTION_SETTINGS = "section_settings" - SLIDER_DRIVE = "slider_drive" - TOOLTIP_DRIVE = "tooltip_drive" - - -class ConverterElements(AbstractElement): - SECTION = "section" - CLOSE_BUTTON = "close_button" - CANCEL_BUTTON = "cancel_button" - LOAD_BUTTON = "load_button" - OPEN_BUTTON = "open_button" - STOP_BUTTON = "stop_button" - CONTINUE_BUTTON = "continue_button" - CONVERT_SAMPLE_BUTTON = "convert_sample_button" - CONVERT_DIRECTORY_BUTTON = "convert_directory_button" - STATUS_CONVERT = "status_convert" - STATUS_CANCEL = "status_cancel" - STATUS_ERROR = "status_error" - STATUS_RECONSTRUCTION_COMPLETED = "status_reconstruction_completed" - STATUS_NO_FILES = "status_no_files" - STATUS_NO_CHANNELS = "status_no_channels" - STATUS_IDLE = "status_idle" - STATUS_WAITING = "status_waiting" - STATUS_GENERATING_LIBRARY = "status_generating_library" - STATUS_CANCELLING = "status_cancelling" - STATUS_CANCELLED = "status_cancelled" - STATUS_INPUT_LABEL = "status_input_label" - STATUS_OUTPUT_LABEL = "status_output_label" - STATUS_EMPTY_HINT = "status_empty_hint" - PROGRESS_DIALOG = "progress_dialog" - LOAD_DIALOG = "load_dialog" - LOAD_FILE_PROMPT = "load_file_prompt" - LOAD_DIRECTORY_PROMPT = "load_directory_prompt" - CANCEL_DIALOG = "cancel_dialog" - CANCEL_PROMPT = "cancel_prompt" - PROGRESS_TEMPLATE = "progress_template" - SINGLE_PROGRESS_TEMPLATE = "single_progress_template" - CONVERT_LABEL_TEMPLATE = "convert_label_template" - STEMS_MODE = "stems_mode" - STEMS_MODE_TOOLTIP = "stems_mode_tooltip" - CHANNEL_CAP = "channel_cap" - CHANNEL_CAP_TOOLTIP = "channel_cap_tooltip" - HIERARCHY_MODE = "hierarchy_mode" - HIERARCHY_MODE_TOOLTIP = "hierarchy_mode_tooltip" - HIERARCHY_ROUND_ROBIN = "hierarchy_round_robin" - HIERARCHY_STRICT = "hierarchy_strict" - STEMS_EMPTY_HINT = "stems_empty_hint" - CONVERT_STEMS_BUTTON = "convert_stems_button" - DISCARD_STEMS_DIALOG = "discard_stems_dialog" - DISCARD_STEMS_PROMPT = "discard_stems_prompt" - DISCARD_STEMS_BUTTON = "discard_stems_button" - KEEP_STEMS_BUTTON = "keep_stems_button" - OVERWRITE_TARGET_DIALOG = "overwrite_target_dialog" - OVERWRITE_TARGET_PROMPT = "overwrite_target_prompt" - OVERWRITE_TARGET_BUTTON = "overwrite_target_button" - STEM_SELECTION_DIALOG = "stem_selection_dialog" - STEM_SELECTION_PROMPT = "stem_selection_prompt" - STEM_SELECTION_LIMIT = "stem_selection_limit" - ADD_STEMS_BUTTON = "add_stems_button" - STATUS_STEMS_MODE = "status_stems_mode" - - class ConverterStemMoveElements(AbstractElement): """The moves a gathered recording can make, as the row's menu names them.""" @@ -106,18 +10,3 @@ class ConverterStemMoveElements(AbstractElement): CONTEXT_JOIN_BELOW = "context_join_below" CONTEXT_ISOLATE = "context_isolate" CONTEXT_REMOVE_STEM = "context_remove_stem" - - -class AdvancedElements(AbstractElement): - SECTION = "section" - SECTION_METHOD = "section_method" - SELECT_OUTPUT_DIRECTORY = "select_output_directory" - SELECT_LIBRARY_DIRECTORY = "select_library_directory" - COMBO_SPECTRUM_METHOD = "combo_spectrum_method" - SLIDER_TRANSFORMATION_GAMMA = "slider_transformation_gamma" - INPUT_MAX_WORKERS = "input_max_workers" - TOOLTIP_SPECTRUM_METHOD = "tooltip_spectrum_method" - TOOLTIP_TRANSFORMATION_GAMMA = "tooltip_transformation_gamma" - TOOLTIP_MAX_WORKERS = "tooltip_max_workers" - STATUS_SELECT_LIBRARY = "status_select_library" - STATUS_SELECT_OUTPUT = "status_select_output" diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py deleted file mode 100644 index 3ecf933be..000000000 --- a/src/sampletones_application/categories/elements/reconstructions.py +++ /dev/null @@ -1,82 +0,0 @@ -from sampletones_application.categories.abstract import AbstractElement - - -class ReconstructionsBrowserElements(AbstractElement): - REFRESH_BUTTON = "refresh_button" - STATUS_REFRESH = "status_refresh" - RECONSTRUCTIONS_TREE = "reconstructions_tree" - CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction" - CONTEXT_REMOVE_RECONSTRUCTION = "context_remove_reconstruction" - CONTEXT_REMOVE_DIRECTORY = "context_remove_directory" - FILE_NOT_FOUND = "file_not_found" - AUDIO_FILE_NOT_FOUND = "audio_file_not_found" - LOAD_ERROR = "load_error" - INVALID_VALUES = "invalid_values" - INVALID_FILE = "invalid_file" - DESERIALIZATION_ERROR = "deserialization_error" - LOAD_RECONSTRUCTION_DIALOG = "load_reconstruction_dialog" - REMOVE_RECONSTRUCTION_DIALOG = "remove_reconstruction_dialog" - REMOVE_RECONSTRUCTION_MESSAGE = "remove_reconstruction_message" - REMOVE_DIRECTORY_DIALOG = "remove_directory_dialog" - REMOVE_DIRECTORY_MESSAGE = "remove_directory_message" - INCOMPATIBLE_VERSION_TEMPLATE = "incompatible_version_template" - - -class ReconstructionPanelElements(AbstractElement): - AUDIO_SOURCE_LABEL = "audio_source_label" - AUTOSCALE_CHECKBOX = "autoscale_checkbox" - RECONSTRUCTION_FILE_LABEL = "reconstruction_file_label" - NES_FREQUENCY_LABEL = "nes_frequency_label" - PATH_NOT_FOUND = "path_not_found" - PATH_NOT_APPLICABLE = "path_not_applicable" - ORIGINAL_AUDIO_RADIO = "original_audio_radio" - RECONSTRUCTION_RADIO = "reconstruction_radio" - WAVEFORM_LABEL = "waveform_label" - AUTOSCALE_TOOLTIP = "autoscale_tooltip" - LOCATE_AUDIO_FAILED = "locate_audio_failed" - EXPORT_WAV_SUCCESS = "export_wav_success" - EXPORT_WAV_FAILED = "export_wav_failed" - STEMS = "stems" - STEMS_EMPTY = "stems_empty" - STEMS_MODE_ROUND_ROBIN = "stems_mode_round_robin" - STEMS_MODE_STRICT = "stems_mode_strict" - STEMS_SETUP = "stems_setup" - COLLAPSE_LEVELS = "collapse_levels" - COLLAPSE_LEVELS_TOOLTIP = "collapse_levels_tooltip" - STATUS_COLLAPSE_LEVELS = "status_collapse_levels" - REMOVE_STEM_DIALOG = "remove_stem_dialog" - REMOVE_STEM_MESSAGE = "remove_stem_message" - - -class ReconstructionsInstrumentsElements(AbstractElement): - SECTION = "section" - EXPORT_INSTRUMENT_BUTTON = "export_instrument_button" - COPY_BUTTON = "copy_button" - PITCH_LABEL = "pitch_label" - HI_PITCH_LABEL = "hi_pitch_label" - VOLUME_LABEL = "volume_label" - ARPEGGIO_LABEL = "arpeggio_label" - DUTY_CYCLE_LABEL = "duty_cycle_label" - INITIAL_PERIOD = "initial_period" - INITIAL_PITCH = "initial_pitch" - STATUS_INPUT_PITCH = "status_input_pitch" - STATUS_INPUT_PERIOD = "status_input_period" - STATUS_BAR = "status_bar" - STATUS_SEQUENCE = "status_sequence" - STATUS_SEQUENCE_TOO_LONG = "status_sequence_too_long" - STATUS_COPY_SEQUENCE = "status_copy_sequence" - STATUS_CHANNEL_TOGGLE = "status_channel_toggle" - STATUS_CHANNEL_NOT_AVAILABLE = "status_channel_not_available" - STATUS_EXPORT_INSTRUMENT = "status_export_instrument" - EXPORT_INSTRUMENT_SUCCESS = "export_instrument_success" - EXPORT_INSTRUMENTS_SUCCESS = "export_instruments_success" - EXPORT_INSTRUMENT_TRUNCATED = "export_instrument_truncated" - EXPORT_INSTRUMENTS_TRUNCATED = "export_instruments_truncated" - EXPORT_INSTRUMENT_FAILED = "export_instrument_failed" - EXPORT_INSTRUMENTS_FAILED = "export_instruments_failed" - EXPORT_STATUS_DIALOG = "export_status_dialog" - NOT_LOADED_DIALOG = "not_loaded_dialog" - EXPORT_WAV_DIALOG = "export_wav_dialog" - EXPORT_INSTRUMENT_DIALOG = "export_instrument_dialog" - EXPORT_INSTRUMENTS_DIALOG = "export_instruments_dialog" - INITIAL_PITCH_TOOLTIP_TEMPLATE = "initial_pitch_tooltip_template" diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index a468d8740..342ae62ff 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -1,23 +1,6 @@ from sampletones_application.categories.abstract import AbstractElement -class SequencerBrowserElements(AbstractElement): - REFRESH_BUTTON = "refresh_button" - STATUS_REFRESH = "status_refresh" - RECONSTRUCTIONS_TREE = "reconstructions_tree" - FILE_NOT_FOUND = "file_not_found" - LOAD_ERROR = "load_error" - LOAD_RECONSTRUCTION_DIALOG = "load_reconstruction_dialog" - - -class SequencerModuleElements(AbstractElement): - MODULE_OPTIONS = "module_options" - NES_FREQUENCY = "nes_frequency" - ROWS = "rows" - TEMPO = "tempo" - SPEED = "speed" - - class SequencerTrackerElements(AbstractElement): TRACKER_TEXT = "tracker_text" OCTAVE = "octave" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index a5b9e53e3..651693d71 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -1,20 +1,6 @@ from sampletones_application.categories.abstract import AbstractElement -class AudioSettingsElements(AbstractElement): - OUTPUT_DEVICE = "output_device" - DEVICE_LABEL = "device_label" - SAMPLE_RATE = "sample_rate" - SAMPLE_RATE_LABEL = "sample_rate_label" - BUFFER_SIZE = "buffer_size" - MASTER_GAIN = "master_gain" - MASTER_GAIN_DB = "master_gain_db" - MASTER_GAIN_SILENT = "master_gain_silent" - APPLY_BUTTON = "apply_button" - REFRESH_DEVICES_BUTTON = "refresh_devices_button" - WINDOW_TITLE = "window_title" - - class ProjectPropertiesElements(AbstractElement): WINDOW_TITLE = "window_title" TITLE = "title" From 8b2fb8bda3a615a63a28ca5673b56c4ec64adc63 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 16:39:44 +0200 Subject: [PATCH 086/142] Fixed: the generator rows of the instruction library --- docs/development/bugs-and-todos.md | 7 ++ .../ui/panels/instruction/library.py | 6 +- .../test_library_generator_nodes.py | 76 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index dd9ccbdf4..da8e5b7c8 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -63,6 +63,13 @@ and the panels beside them. The language-keys check expands such a helper over the whole enum, so a member no call names is reached all the same and stands unnoticed. Spelling those keys literally at the call site would make each entry exactly checkable and retire the enums that remain. +* Tree node attributes stand outside the type checker. `TreeNode` derives from `anytree.Node`, + which ships no types, so mypy reads every attribute of a node — and of `FileSystemNode`, + `ConfigNode`, `LibraryNode`, `GeneratorNode` — as `Any`, and a misspelled one passes the gate. + A rename sweep spelling `GeneratorNode.generator_name` as `channel_name` reached the running + application that way. Declaring the attributes on a typed base, or stubbing the part of + `anytree` the tree uses, would put node reads back under the checker. Reaches + `sampletones_core/structures/tree/`. * Respecting FamiTracker limitations * Per-tab undo routing * In-application console diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 7b7602f28..75215a95d 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -347,7 +347,7 @@ def message_function( assert isinstance(node, GeneratorNode), "Node is not a GeneratorNode" assert isinstance(parent, LibraryNode), "Generator node parent is not a LibraryNode" message = self._language_manager["instructions.library.message.status_node_generator"].format( - generator=node.channel_name, + generator=node.generator_name, library_key=parent.library_key.filename, ) case _: @@ -367,7 +367,7 @@ def _on_generator_node_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: assert isinstance(node.parent, LibraryNode), "Generator node parent is not a LibraryNode" - self.call(self.on_generator_selected, node.parent.library_key, node.channel_name) + self.call(self.on_generator_selected, node.parent.library_key, node.generator_name) if mouse_button == dpg.mvMouseButton_Right: self._show_generator_context_menu(node) @@ -450,5 +450,5 @@ def _on_load_generator( self.call( self.on_generator_selected, user_data.parent.library_key, - user_data.channel_name, + user_data.generator_name, ) diff --git a/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py b/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py new file mode 100644 index 000000000..fd561ebe0 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py @@ -0,0 +1,76 @@ +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.ui.panels.instruction.library import GUIInstructionsLibraryPanel +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.library import InstructionLibraryKey +from sampletones_core.structures.tree import GeneratorNode, LibraryNode +from tests.suite.language import FakeLanguageManager + +STATUS_KEY: Final[str] = "instructions.library.message.status_node_generator" +STATUS_TEXT: Final[str] = "{generator} of {library_key}" +LIBRARY_FILENAME: Final[str] = "library_abc123" + + +def _library_key() -> InstructionLibraryKey: + return InstructionLibraryKey( + sample_rate=44100, + frame_length=1470, + window_size=2940, + transformation_gamma=100, + config_hash="abc123", + filename=LIBRARY_FILENAME, + ) + + +def _generator_node() -> GeneratorNode: + library = LibraryNode("Library", library_key=_library_key()) + return GeneratorNode("Pulse", generator_name=GeneratorName.PULSE, parent=library) + + +def _panel() -> GUIInstructionsLibraryPanel: + panel = GUIInstructionsLibraryPanel.__new__(GUIInstructionsLibraryPanel) + panel._language_manager = FakeLanguageManager(texts={STATUS_KEY: STATUS_TEXT}) + return panel + + +class TestGeneratorNodeMessage: + def test_the_message_names_the_generator_and_its_library(self) -> None: + """Hovering a generator row says which generator of which library it stands for.""" + panel = _panel() + message_function = panel._create_status_bar_message_function_for_instructions_node() + + message = message_function(user_data=(_generator_node(), "row_tag")) + + assert message == f"{GeneratorName.PULSE} of {LIBRARY_FILENAME}" + + +class TestGeneratorNodeSelection: + def test_a_click_names_the_library_and_the_generator(self) -> None: + """Clicking a generator row hands on the library it belongs to and the generator it is.""" + panel = _panel() + selected: List[Tuple[InstructionLibraryKey, GeneratorName]] = [] + panel.on_generator_selected = lambda library_key, generator_name: selected.append( + (library_key, generator_name), + ) + + panel._on_generator_node_clicked( + None, + (dpg.mvMouseButton_Left, 0), + (_generator_node(), "row_tag"), + ) + + assert selected == [(_library_key(), GeneratorName.PULSE)] + + def test_loading_from_the_row_menu_names_the_same_pair(self) -> None: + """The row menu's load item reaches the generator the row holds, as a click does.""" + panel = _panel() + selected: List[Tuple[InstructionLibraryKey, GeneratorName]] = [] + panel.on_generator_selected = lambda library_key, generator_name: selected.append( + (library_key, generator_name), + ) + + panel._on_load_generator(None, True, _generator_node()) + + assert selected == [(_library_key(), GeneratorName.PULSE)] From b44a07dcd7648bd604a878ad7b5eb116e2abc10e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 17:04:48 +0200 Subject: [PATCH 087/142] Fixed: the generator rows of the instruction library --- docs/development/bugs-and-todos.md | 7 --- docs/development/guidelines.md | 1 + pyproject.toml | 1 + .../reconstruction/browser/tree/collapse.py | 19 +++++-- .../ui/panels/main/explorer.py | 1 + src/sampletones_core/structures/tree/node.py | 20 ++++++- stubs/anytree/__init__.pyi | 55 +++++++++++++++++++ 7 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 stubs/anytree/__init__.pyi diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index da8e5b7c8..dd9ccbdf4 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -63,13 +63,6 @@ and the panels beside them. The language-keys check expands such a helper over the whole enum, so a member no call names is reached all the same and stands unnoticed. Spelling those keys literally at the call site would make each entry exactly checkable and retire the enums that remain. -* Tree node attributes stand outside the type checker. `TreeNode` derives from `anytree.Node`, - which ships no types, so mypy reads every attribute of a node — and of `FileSystemNode`, - `ConfigNode`, `LibraryNode`, `GeneratorNode` — as `Any`, and a misspelled one passes the gate. - A rename sweep spelling `GeneratorNode.generator_name` as `channel_name` reached the running - application that way. Declaring the attributes on a typed base, or stubbing the part of - `anytree` the tree uses, would put node reads back under the checker. Reaches - `sampletones_core/structures/tree/`. * Respecting FamiTracker limitations * Per-tab undo routing * In-application console diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 50b18b092..5ae4a5e60 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -44,6 +44,7 @@ These rules govern the Python in this repository. They complement 1. Write type names unquoted, using `from __future__ import annotations` (only when needed), `Self`, or `TYPE_CHECKING`. 1. Reserve `Any` and `object` for boundaries that genuinely accept arbitrary data. 1. Cast or silence a type error only at an untyped or mistyped third-party boundary. +1. Stub an untyped dependency under `stubs/` when our own classes derive from it. A class deriving from an untyped one reads as `Any` throughout, so the stub is what holds that class and every reader of it to the attributes it carries — `stubs/anytree` does this for the tree nodes. 1. Validate with `mypy`. ## Error Handling diff --git a/pyproject.toml b/pyproject.toml index b8632e7a9..473b5cbc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ fail_under = 90 [tool.mypy] python_version = "3.12" +mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs" files = [ "src/sampletones", "src/sampletones_application", diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py index 1bb4026c3..25ddb56a0 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py @@ -34,16 +34,25 @@ def _can_fold(node: TreeNode) -> bool: if node.node_type not in ARTIFICIAL_CONTAINERS or len(node.children) != 1: return False - return not _siblings_hold(node, _joined_name(node, node.children[0])) - - -def _siblings_hold(node: TreeNode, name: str) -> bool: + return not _siblings_hold( + node, + parent, + _joined_name(node, node.children[0]), + ) + + +def _siblings_hold( + node: TreeNode, + parent: TreeNode, + name: str, +) -> bool: """Whether a row beside this heading already reads as the name the fold would produce. The folded row joins the siblings of the heading it replaces, and a browser row is addressed by the names leading to it, so a heading whose fold would repeat a name beside it stays as it is. + The heading's parent is passed in, since the caller establishes it before a fold is considered. """ - return any(sibling.name == name for sibling in node.parent.children if sibling is not node) + return any(sibling.name == name for sibling in parent.children if sibling is not node) def _fold_into_child(node: TreeNode) -> None: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 3de65cdcc..8d79e3521 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -178,6 +178,7 @@ def _collect_subtree_specs( self._pending_specs = [] if self._explorer_logic.has_loaded_children(node.filepath): for child in node.children: + assert isinstance(child, FileSystemNode), "Explorer child is not a FileSystemNode" self._build_tree_node( child, TreeNodeState( diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index b4c8c4289..d26492972 100644 --- a/src/sampletones_core/structures/tree/node.py +++ b/src/sampletones_core/structures/tree/node.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from anytree import Node @@ -12,7 +12,23 @@ from .type import NodeType -class TreeNode(Node): # type: ignore[misc] +class TreeNode(Node): + """A node of one of the application's trees, and the base every node kind derives from. + + ``anytree`` lets a tree hold nodes of any type, so it states a node's relatives as untyped. + Every tree here is built from this class alone, which is what these declarations state: a + relative of a node is a node of ours, and the checker holds each reader to the attributes the + node kind it reached actually carries. + """ + + name: str + parent: Optional[TreeNode] + children: Tuple[TreeNode, ...] + path: Tuple[TreeNode, ...] + root: TreeNode + ancestors: Tuple[TreeNode, ...] + descendants: Tuple[TreeNode, ...] + def __init__( self, name: str, diff --git a/stubs/anytree/__init__.pyi b/stubs/anytree/__init__.pyi new file mode 100644 index 000000000..59e786c8f --- /dev/null +++ b/stubs/anytree/__init__.pyi @@ -0,0 +1,55 @@ +from typing import Any, Callable, Iterator, Optional, Tuple + +class NodeMixin: + separator: str + + @property + def parent(self) -> Any: ... + @parent.setter + def parent(self, value: Any) -> None: ... + @property + def children(self) -> Tuple[Any, ...]: ... + @children.setter + def children(self, value: Any) -> None: ... + @property + def path(self) -> Tuple[Any, ...]: ... + @property + def root(self) -> Any: ... + @property + def ancestors(self) -> Tuple[Any, ...]: ... + @property + def descendants(self) -> Tuple[Any, ...]: ... + @property + def siblings(self) -> Tuple[Any, ...]: ... + @property + def leaves(self) -> Tuple[Any, ...]: ... + @property + def is_leaf(self) -> bool: ... + @property + def is_root(self) -> bool: ... + @property + def height(self) -> int: ... + @property + def depth(self) -> int: ... + def iter_path_reverse(self) -> Iterator[Any]: ... + +class Node(NodeMixin): + name: Any + + def __init__( + self, + name: Any, + parent: Optional[Any] = ..., + children: Optional[Any] = ..., + **kwargs: Any, + ) -> None: ... + +class PreOrderIter: + def __init__( + self, + node: NodeMixin, + filter_: Optional[Callable[[Any], bool]] = ..., + stop: Optional[Callable[[Any], bool]] = ..., + maxlevel: Optional[int] = ..., + ) -> None: ... + def __iter__(self) -> Iterator[Any]: ... From 2230b7f9e19d069662592e27619b1ea1d53f5fd6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 17:31:44 +0200 Subject: [PATCH 088/142] Added: the Voice menu --- src/sampletones_application/application.py | 15 ++ .../categories/elements/global_.py | 1 + .../categories/elements/settings.py | 2 + .../coordinators/tabs/sequencer.py | 12 +- src/sampletones_application/shell.py | 4 + src/sampletones_application/tags/general.py | 30 ++++ .../ui/elements/menu_section.py | 73 +++++++++ src/sampletones_application/ui/menu.py | 133 ++++++++++------- .../ui/panels/sequencer/voices.py | 16 ++ .../utils/gui/shortcuts/ids.py | 2 + .../keybindings/default.yaml | 4 + src/sampletones_config/keybindings/macos.yaml | 4 + src/sampletones_config/lang/en.yaml | 3 + .../ui/elements/test_menu_section.py | 137 +++++++++++++++++ .../ui/panels/sequencer/test_voices_menu.py | 28 ++++ .../sampletones_application/ui/test_menu.py | 138 +++++++++++++----- 16 files changed, 508 insertions(+), 94 deletions(-) create mode 100644 src/sampletones_application/ui/elements/menu_section.py create mode 100644 tests/unit/sampletones_application/ui/elements/test_menu_section.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 688c97bc5..4329ecf78 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -348,6 +348,7 @@ def __init__( player_layout=self.layout.player, language_manager=self.language_manager, build_edit_actions=self._build_edit_actions, + build_voice_actions=self._build_voice_actions, on_play_from_start=self._play_from_start, on_pause_or_resume=self._play, on_stop=self._stop, @@ -656,6 +657,8 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: export_wav=self._export_reconstruction_wav_dialog, export_instruments=self._export_reconstruction_instruments_dialog, add_reconstruction_to_sequencer=self._add_current_reconstruction_to_sequencer, + new_instrument=self._add_instrument, + add_sample_from_file=self._add_sample_from_file, open_reconstruction_in_explorer=self._open_reconstruction_in_explorer, locate_original_audio=self._locate_original_audio, play=self._play, @@ -1465,6 +1468,18 @@ def _build_edit_actions(self) -> bool: """States the actions of the grid holding the cursor into the Edit menu being built.""" return self._edit_router.build_menu_actions() + def _build_voice_actions(self) -> None: + """States the chosen voice's actions into the Voice menu being built.""" + self._sequencer_tab.build_voice_actions() + + def _add_instrument(self) -> None: + """Writes a voice by hand into the open project's pool.""" + self._sequencer_tab.add_instrument() + + def _add_sample_from_file(self) -> None: + """Brings a reconstruction saved anywhere on disk into the pool as a sample.""" + self._sequencer_tab.add_sample_from_file() + def _play_from_start(self) -> None: self._playback_router.play_from_start() self._update_menu() diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 93b8f08ca..4fac92ce1 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -72,6 +72,7 @@ class MenuElements(AbstractElement): ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER = "item_reconstruction_export_instruments_famitracker" ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET = "item_reconstruction_export_instruments_bitphase_preset" ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF = "item_reconstruction_export_instruments_nsf" + GROUP_VOICE = "group_voice" GROUP_PLAYBACK = "group_playback" ITEM_PLAYBACK_PLAY = "item_playback_play" ITEM_PLAYBACK_PAUSE = "item_playback_pause" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 651693d71..6e233fa86 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -46,6 +46,8 @@ class KeybindingActionElements(AbstractElement): EXPORT_INSTRUMENTS_BITPHASE_PRESET = "export_instruments_bitphase_preset" EXPORT_INSTRUMENTS_NSF = "export_instruments_nsf" ADD_RECONSTRUCTION_TO_SEQUENCER = "add_reconstruction_to_sequencer" + NEW_INSTRUMENT = "new_instrument" + ADD_SAMPLE_FROM_FILE = "add_sample_from_file" OPEN_RECONSTRUCTION_IN_EXPLORER = "open_reconstruction_in_explorer" LOCATE_ORIGINAL_AUDIO = "locate_original_audio" PLAY = "play" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index c68f87b89..c013bd281 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -641,10 +641,10 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_voices_logic.duplicate_voice, detail=self._history_detail.duplicate_voice, ) - self._sequencer_voices_panel.on_new_instrument_requested = self._add_instrument - self._sequencer_voices_panel.on_add_sample_requested = self._add_sample_from_file + self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument + self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file - def _add_instrument(self) -> None: + def add_instrument(self) -> None: """Appends a hand-written voice, named for the position it takes in the list. An instrument arrives sustaining at full volume, so it plays as soon as it is placed and the @@ -660,7 +660,7 @@ def _add_instrument(self) -> None: ): self._sequencer_voices_logic.add_instrument(name) - def _add_sample_from_file(self) -> None: + def add_sample_from_file(self) -> None: """Brings a reconstruction saved anywhere on disk into the pool as a sample. The tree beside the list reaches the reconstructions folder, so a file kept elsewhere @@ -1581,6 +1581,10 @@ def _build_right_column(self, parent: str) -> None: def player(self) -> AudioPlayerProtocol: return self._guarded_player + def build_voice_actions(self) -> None: + """States the chosen voice's actions into the menu being built, for the bar's Voice group.""" + self._sequencer_voices_panel.build_voice_actions() + @property def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: """The panels offering editing gestures on what they hold selected. diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index f6fa2532c..5a827b1a2 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -89,6 +89,8 @@ class ShortcutBindings: export_wav: Callback export_instruments: Callable[[ExportFormat], None] add_reconstruction_to_sequencer: Callback + new_instrument: Callback + add_sample_from_file: Callback open_reconstruction_in_explorer: Callback locate_original_audio: Callback play: Callback @@ -241,6 +243,8 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.CLOSE_RECONSTRUCTION: bindings.close_reconstruction, ShortcutId.EXPORT_RECONSTRUCTION_WAV: bindings.export_wav, ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER: bindings.add_reconstruction_to_sequencer, + ShortcutId.NEW_INSTRUMENT: bindings.new_instrument, + ShortcutId.ADD_SAMPLE_FROM_FILE: bindings.add_sample_from_file, ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER: bindings.open_reconstruction_in_explorer, ShortcutId.LOCATE_ORIGINAL_AUDIO: bindings.locate_original_audio, ShortcutId.PLAY: bindings.play, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index a77da8d0b..1ac2e82ca 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -548,6 +548,36 @@ Widget.MENU, "item_reconstruction_export_instruments", ) +TAG_GLOBAL_MENU_GROUP_VOICE = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "group_voice", +) +TAG_GLOBAL_MENU_GROUP_VOICE_MARKER = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "group_voice_marker", +) +TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_voice_new_instrument", +) +TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_voice_add_sample", +) +TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_voice_add_to_sequencer", +) TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_ADD_TO_SEQUENCER = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/menu_section.py b/src/sampletones_application/ui/elements/menu_section.py new file mode 100644 index 000000000..ee5de8e86 --- /dev/null +++ b/src/sampletones_application/ui/elements/menu_section.py @@ -0,0 +1,73 @@ +from typing import Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.utils.gui.dpg import dpg_append_items, dpg_delete_item +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + + +class MenuSection: + """A run of items a menu states afresh each time a reader opens it. + + A menu bar is built once, while what its items should say follows the cursor and the selection + at the moment the menu is opened. A section keeps a marker inside the menu it belongs to: the + framework reports that marker drawn once a frame while the menu stands open, so a gap in those + reports names a fresh opening, and the section takes its standing items away and states them + again. The items stay between openings, which gives the popup its full height on the frame it + appears, and the rebuilt ones take over a frame later — long before an item can be reached. + + The marker leads the menu, holding nothing: a container standing below a menu item takes the + width those items span as its own, which the popup then grows to fit on every frame it stays + open. + """ + + def __init__( + self, + *, + menu_tag: str, + marker_tag: str, + build: VoidCallback, + ) -> None: + self._menu_tag = menu_tag + self._marker_tag = marker_tag + self._build = build + self._handler_tag = compose_tag(marker_tag, SUF_HANDLER_REGISTRY) + self._drawn_frame: Optional[int] = None + self._items: Tuple[Sender, ...] = () + + def add_marker(self) -> None: + """States the marker the section is reported by, inside the menu being built.""" + dpg.add_group(tag=self._marker_tag) + + def watch(self) -> None: + """Reports the marker drawn from here on, and states the section once.""" + with dpg.item_handler_registry(tag=self._handler_tag): + dpg.add_item_visible_handler(callback=self._on_marker_drawn) + + dpg.bind_item_handler_registry(self._marker_tag, self._handler_tag) + self.refresh() + + def refresh(self) -> None: + """Takes the standing items away and asks for the section as it reads now. + + Only what the last build stated is taken away, so the items the menu declares for itself + stand where they are. + """ + for item in self._items: + dpg_delete_item(item) + + self._items = dpg_append_items(self._menu_tag, self._build) + + def _on_marker_drawn( + self, + _sender: Sender, + _app_data: Sender, + ) -> None: + frame = dpg.get_frame_count() + reopened = self._drawn_frame is None or frame - self._drawn_frame > 1 + self._drawn_frame = frame + if reopened: + self.refresh() diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index b921a4fcd..26cedacf1 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable, Dict, Final, Optional, Tuple +from typing import Callable, Dict, Final, Tuple import dearpygui.dearpygui as dpg @@ -8,6 +8,7 @@ ContextElements, MenuElements, ) +from sampletones_application.categories.elements.sequencer import SequencerVoicesElements from sampletones_application.categories.exports import ( EXPORT_PROJECT_MENU_LABELS, EXPORT_SAMPLE_MENU_LABELS, @@ -19,9 +20,10 @@ from sampletones_application.layout.player import PlayerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( - SUF_HANDLER_REGISTRY, TAG_GLOBAL_MENU_GROUP_EDIT, TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, + TAG_GLOBAL_MENU_GROUP_VOICE, + TAG_GLOBAL_MENU_GROUP_VOICE_MARKER, TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, @@ -56,6 +58,9 @@ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, TAG_GLOBAL_MENU_ITEM_VIEW_FULLSCREEN, TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS, + TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE, + TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, + TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT, TAG_GLOBAL_PANEL_PLAYER, TAG_GLOBAL_TEXT_MENU_FPS, ) @@ -67,14 +72,13 @@ ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.menu_section import MenuSection from sampletones_application.ui.panels.player.controls import ( create_compact_transport_controls, ) from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( - dpg_append_items, dpg_configure_item, - dpg_delete_item, dpg_set_item_label, dpg_set_value, ) @@ -88,7 +92,6 @@ from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_core.constants.enums import ChannelName -from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback PROJECT_ITEM_TAGS: Final[Tuple[str, ...]] = ( @@ -129,6 +132,7 @@ def __init__( player_layout: PlayerLayout, language_manager: LanguageManager, build_edit_actions: Callable[[], bool], + build_voice_actions: VoidCallback, on_play_from_start: VoidCallback, on_pause_or_resume: VoidCallback, on_stop: VoidCallback, @@ -154,12 +158,16 @@ def __init__( self._pause_tooltip_tag = compose_tag(self._pause_button_tag, SUF_PLAYER_TOOLTIP) self._lbl_pause = language_manager["global.player.label.pause"] - self._edit_actions_handler_tag = compose_tag( - TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, - SUF_HANDLER_REGISTRY, + self._edit_section = MenuSection( + menu_tag=TAG_GLOBAL_MENU_GROUP_EDIT, + marker_tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, + build=self._add_edit_action_items, + ) + self._voice_section = MenuSection( + menu_tag=TAG_GLOBAL_MENU_GROUP_VOICE, + marker_tag=TAG_GLOBAL_MENU_GROUP_VOICE_MARKER, + build=build_voice_actions, ) - self._edit_actions_frame: Optional[int] = None - self._edit_action_items: Tuple[Sender, ...] = () def _label(self, element: MenuElements) -> str: return self._language_manager[ @@ -172,11 +180,21 @@ def _label(self, element: MenuElements) -> str: def _context_label(self, element: ContextElements) -> str: return context_label(self._language_manager, element) + def _voices_label(self, element: SequencerVoicesElements) -> str: + """The word the voices panel states for an action, so every door onto it reads alike.""" + return self._language_manager[ + Page.SEQUENCER, + Panel.VOICES, + TextType.LABEL, + element, + ] + def create(self, state: MenuBarViewModel) -> None: with dpg.menu_bar(): self._create_file_menu(state) self._create_edit_menu(state) self._create_reconstruction_menu(state) + self._create_voice_menu(state) self._create_playback_menu(state) self._create_view_menu() self._create_help_menu() @@ -256,16 +274,13 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None: def _create_edit_menu(self, state: MenuBarViewModel) -> None: """Builds the Edit menu: the history steps, then the actions of whoever holds the cursor. - The actions are stated into the menu itself and taken away again on each opening, so they - follow the cursor. A marker leads the menu, holding nothing and reporting the popup drawn: - a container standing below a menu item takes the width those items span as its own, which - the popup then grows to fit on every frame it stays open. + The actions are a :class:`MenuSection`, restated on each opening so they follow the cursor. """ with dpg.menu( label=self._label(MenuElements.GROUP_EDIT), tag=TAG_GLOBAL_MENU_GROUP_EDIT, ): - dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER) + self._edit_section.add_marker() self._shortcut_manager.add_menu_item( ShortcutId.UNDO, tag=TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, @@ -280,47 +295,7 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None: ) dpg.add_separator() - with dpg.item_handler_registry(tag=self._edit_actions_handler_tag): - dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn) - - dpg.bind_item_handler_registry( - TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, - self._edit_actions_handler_tag, - ) - self._refresh_edit_actions() - - def _on_edit_actions_drawn( - self, - _sender: Sender, - _app_data: Sender, - ) -> None: - """States the actions afresh each time the Edit menu is opened. - - DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in - those reports marks a fresh opening. The actions stay standing between openings, which - gives the popup its full height on the frame it appears, and the rebuilt ones take over a - frame later — long before an item can be reached and chosen. - """ - frame = dpg.get_frame_count() - reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1 - self._edit_actions_frame = frame - if reopened: - self._refresh_edit_actions() - - def _refresh_edit_actions(self) -> None: - """Takes the standing actions out of the Edit menu and asks the focused surface for its own. - - A surface builds the same actions its own cell menu offers, so the two doors print one set - with the keys and the enablement each action carries. The history steps above them stand - where they are, since only what the last build stated is taken away. - """ - for item in self._edit_action_items: - dpg_delete_item(item) - - self._edit_action_items = dpg_append_items( - TAG_GLOBAL_MENU_GROUP_EDIT, - self._add_edit_action_items, - ) + self._edit_section.watch() def _add_edit_action_items(self) -> None: """States the focused surface's actions, or the clipboard four greyed out while none is.""" @@ -426,6 +401,40 @@ def _create_instruments_export_menu(self, state: MenuBarViewModel) -> None: label=self._label(EXPORT_SAMPLE_MENU_LABELS[export_format]), ) + def _create_voice_menu(self, state: MenuBarViewModel) -> None: + """Builds the Voice menu: the ways a voice comes in, then what the chosen one offers. + + A voice is written by hand, converted from a recording, or brought in from the + reconstruction the Reconstructions tab holds, and the three stand together here so the bar + answers "how do I get a voice in" on its own. The chosen voice's actions are a + :class:`MenuSection` the voices panel builds, the same set its row menu prints. + """ + with dpg.menu( + label=self._label(MenuElements.GROUP_VOICE), + tag=TAG_GLOBAL_MENU_GROUP_VOICE, + ): + self._voice_section.add_marker() + self._shortcut_manager.add_menu_item( + ShortcutId.NEW_INSTRUMENT, + tag=TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT, + label=self._voices_label(SequencerVoicesElements.NEW_INSTRUMENT), + enabled=state.project_open, + ) + self._shortcut_manager.add_menu_item( + ShortcutId.ADD_SAMPLE_FROM_FILE, + tag=TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE, + label=self._voices_label(SequencerVoicesElements.ADD_SAMPLE), + enabled=state.project_open, + ) + self._shortcut_manager.add_menu_item( + ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, + tag=TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, + label=self._context_label(ContextElements.ADD_TO_SEQUENCER), + enabled=state.add_to_sequencer_enabled, + ) + + self._voice_section.watch() + def _create_playback_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_PLAYBACK)): self._shortcut_manager.add_menu_item( @@ -648,6 +657,18 @@ def update(self, state: MenuBarViewModel) -> None: TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_ADD_TO_SEQUENCER, enabled=state.add_to_sequencer_enabled, ) + dpg_configure_item( + TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT, + enabled=state.project_open, + ) + dpg_configure_item( + TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE, + enabled=state.project_open, + ) + dpg_configure_item( + TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, + enabled=state.add_to_sequencer_enabled, + ) dpg_configure_item( TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_OPEN_IN_EXPLORER, enabled=state.open_in_explorer_enabled, diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices.py index a3cac5550..9765e0e31 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -722,6 +722,20 @@ def build_edit_actions(self) -> None: if selection is not None: self.add_action_items(selection) + def build_voice_actions(self) -> None: + """States the actions of the voice the list holds, for a menu listing the pool above them. + + The Voice menu prints the ways a voice comes in first, so the chosen voice's actions are + led by a rule of their own here, and nothing is stated while no voice is chosen. The row + menu draws its own dividers around the same set. + """ + selection = self.selection + if selection is None: + return + + dpg.add_separator() + self.add_action_items(selection) + def add_pool_items(self) -> None: """Builds the ways a voice comes into the pool, in the order each menu prints them. @@ -735,6 +749,7 @@ def add_pool_items(self) -> None: self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT, ), + shortcut=self._shortcuts.display(ShortcutId.NEW_INSTRUMENT), callback=lambda: self.call(self.on_new_instrument_requested), ) dpg.add_menu_item( @@ -742,6 +757,7 @@ def add_pool_items(self) -> None: self._language_manager, SequencerVoicesElements.ADD_SAMPLE, ), + shortcut=self._shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE), callback=lambda: self.call(self.on_add_sample_requested), ) diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 1553db7e3..a2ba3c895 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -66,6 +66,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: EXPORT_INSTRUMENTS_FAMITRACKER = ("ExportInstrumentsFamiTracker", ShortcutCategory.APPLICATION) EXPORT_INSTRUMENTS_BITPHASE_PRESET = ("ExportInstrumentsBitphasePreset", ShortcutCategory.APPLICATION) EXPORT_INSTRUMENTS_NSF = ("ExportInstrumentsNSF", ShortcutCategory.APPLICATION) + NEW_INSTRUMENT = ("NewInstrument", ShortcutCategory.APPLICATION) + ADD_SAMPLE_FROM_FILE = ("AddSampleFromFile", ShortcutCategory.APPLICATION) ADD_RECONSTRUCTION_TO_SEQUENCER = ("AddReconstructionToSequencer", ShortcutCategory.APPLICATION) OPEN_RECONSTRUCTION_IN_EXPLORER = ("OpenReconstructionInExplorer", ShortcutCategory.APPLICATION) LOCATE_ORIGINAL_AUDIO = ("LocateOriginalAudio", ShortcutCategory.APPLICATION) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 2672b2502..05eaaeafe 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -35,6 +35,10 @@ bindings: OpenReconstructionInExplorer: {combination: ~} LocateOriginalAudio: {combination: ~} + # voice + NewInstrument: {combination: ~} + AddSampleFromFile: {combination: ~} + # playback Play: {combination: "Space"} PlayFromStart: {combination: "Shift+Space"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 340b76d73..d61316fbf 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -35,6 +35,10 @@ bindings: OpenReconstructionInExplorer: {combination: ~} LocateOriginalAudio: {combination: ~} + # voice + NewInstrument: {combination: ~} + AddSampleFromFile: {combination: ~} + # playback Play: {combination: "Space"} PlayFromStart: {combination: "Shift+Space"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8c208ee80..13eaa5614 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -209,6 +209,7 @@ global.menu.label.group_reconstruction_export_instruments: "Export instruments" global.menu.label.item_reconstruction_export_instruments_famitracker: "FamiTracker instruments..." global.menu.label.item_reconstruction_export_instruments_bitphase_preset: "Bitphase presets..." global.menu.label.item_reconstruction_export_instruments_nsf: "NSF program..." +global.menu.label.group_voice: "Voice" global.menu.label.group_playback: "Playback" global.menu.label.item_playback_play: "Play" global.menu.label.item_playback_pause: "Pause" @@ -856,6 +857,8 @@ settings.keybindings.label.export_instruments_famitracker: "Export instruments t settings.keybindings.label.export_instruments_bitphase_preset: "Export instruments to a Bitphase preset" settings.keybindings.label.export_instruments_nsf: "Export instruments to an NSF program" settings.keybindings.label.add_reconstruction_to_sequencer: "Add reconstruction to the sequencer" +settings.keybindings.label.new_instrument: "New instrument" +settings.keybindings.label.add_sample_from_file: "Add sample from file" settings.keybindings.label.open_reconstruction_in_explorer: "Show reconstruction in the file manager" settings.keybindings.label.locate_original_audio: "Locate the original audio" settings.keybindings.label.play: "Play or pause" diff --git a/tests/unit/sampletones_application/ui/elements/test_menu_section.py b/tests/unit/sampletones_application/ui/elements/test_menu_section.py new file mode 100644 index 000000000..19647fe4f --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_menu_section.py @@ -0,0 +1,137 @@ +from contextlib import contextmanager +from typing import Any, Callable, Dict, Iterator, List, Tuple + +import pytest + +from sampletones_application.ui.elements import menu_section as menu_section_module +from sampletones_application.ui.elements.menu_section import MenuSection + +MENU_TAG = "menu" +MARKER_TAG = "menu_marker" + + +class _DearPyGuiRecorder: + """Stands in for the framework, recording what a section states and takes away.""" + + def __init__(self) -> None: + self.items: List[Dict[str, Any]] = [] + self.built: List[str] = [] + self.containers: List[str] = [] + self.deleted: List[int] = [] + self.bound: List[Tuple[str, str]] = [] + + def add_group(self, *, tag: str) -> int: + self.built.append(f"group:{tag}") + return 0 + + @contextmanager + def item_handler_registry(self, **_kwargs: Any) -> Iterator[int]: + yield 0 + + def add_item_visible_handler(self, **_kwargs: Any) -> int: + return 0 + + def bind_item_handler_registry(self, item: str, registry: str) -> None: + self.bound.append((item, registry)) + + def append_items(self, tag: str, build: Callable[[], None]) -> Tuple[int, ...]: + self.containers.append(tag) + standing = len(self.items) + build() + return tuple(range(standing, len(self.items))) + + def add_item(self) -> int: + self.items.append({}) + return 0 + + def delete_item(self, item: int) -> None: + self.deleted.append(item) + + +@pytest.fixture +def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: + instance = _DearPyGuiRecorder() + monkeypatch.setattr(menu_section_module.dpg, "add_group", instance.add_group) + monkeypatch.setattr(menu_section_module.dpg, "item_handler_registry", instance.item_handler_registry) + monkeypatch.setattr(menu_section_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler) + monkeypatch.setattr(menu_section_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry) + monkeypatch.setattr(menu_section_module, "dpg_append_items", instance.append_items) + monkeypatch.setattr(menu_section_module, "dpg_delete_item", instance.delete_item) + return instance + + +def _section( + build: Callable[[], None], + *, + menu_tag: str = MENU_TAG, +) -> MenuSection: + return MenuSection(menu_tag=menu_tag, marker_tag=MARKER_TAG, build=build) + + +class TestSectionPlacement: + def test_the_items_are_stated_into_the_menu_itself( + self, + framework: _DearPyGuiRecorder, + ) -> None: + """A section belongs to one menu, so its items land in that menu and nowhere else.""" + _section(framework.add_item).refresh() + + assert framework.containers == [MENU_TAG] + + def test_the_marker_is_what_the_section_is_reported_by( + self, + framework: _DearPyGuiRecorder, + ) -> None: + section = _section(framework.add_item) + section.add_marker() + section.watch() + + assert framework.built[0] == f"group:{MARKER_TAG}" + assert [item for item, _ in framework.bound] == [MARKER_TAG] + + def test_a_build_takes_away_only_what_the_one_before_it_stated( + self, + framework: _DearPyGuiRecorder, + ) -> None: + """The menu's own items stand where they are, since only the last build is taken away.""" + section = _section(framework.add_item) + + section.refresh() + section.refresh() + + assert framework.deleted == [0] + + +class TestSectionRefresh: + """DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in + those reports is what marks a fresh opening.""" + + def test_the_items_are_stated_once_while_the_menu_stays_open( + self, + framework: _DearPyGuiRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + frames = iter([10, 11, 12, 13]) + monkeypatch.setattr(menu_section_module.dpg, "get_frame_count", lambda: next(frames)) + requests: List[int] = [] + section = _section(lambda: requests.append(1)) + + for _ in range(4): + section._on_marker_drawn(0, 0) + + assert len(requests) == 1 + + def test_the_items_are_stated_afresh_each_time_the_menu_is_opened( + self, + framework: _DearPyGuiRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + frames = iter([10, 11, 40, 41]) + monkeypatch.setattr(menu_section_module.dpg, "get_frame_count", lambda: next(frames)) + requests: List[int] = [] + section = _section(lambda: requests.append(1)) + + for _ in range(4): + section._on_marker_drawn(0, 0) + + assert len(requests) == 2 diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index 5d762e417..1e4d6b3a0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -408,6 +408,34 @@ def test_a_panel_holding_no_selection_builds_nothing( assert recorder.items == [] +class TestVoiceMenuActions: + """The menu bar's Voice group carries the same actions the row menu prints.""" + + def test_the_chosen_voice_states_its_actions_under_a_rule( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """The group lists the pool above them, so the voice's own actions are led by a divider.""" + _panel(monkeypatch).panel.build_voice_actions() + + kinds = [widget.kind for widget in build_recorder.widgets] + items = [widget.text for widget in build_recorder.widgets if widget.kind == "item"] + + assert kinds[0] == "separator" + assert items[0] == SequencerVoicesElements.CONTEXT_EDIT.value + + def test_no_voice_chosen_states_nothing_at_all( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """A group with no voice to act on shows the ways one comes in, and no divider below.""" + _panel(monkeypatch, selected_row=None).panel.build_voice_actions() + + assert build_recorder.widgets == [] + + class TestThePoolItems: """Every door onto the list offers the ways a voice comes in, so adding one is never hidden.""" diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 0e32bc8c1..91bf4dcc5 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -9,12 +9,16 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_MENU_GROUP_EDIT, TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, + TAG_GLOBAL_MENU_GROUP_VOICE, + TAG_GLOBAL_MENU_GROUP_VOICE_MARKER, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES, TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, ) from sampletones_application.ui import menu as menu_module +from sampletones_application.ui.elements import menu_section as menu_section_module +from sampletones_application.ui.elements.menu_section import MenuSection from sampletones_application.ui.menu import MenuBar from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, @@ -164,8 +168,12 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: monkeypatch.setattr(menu_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry) monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value) monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item) - monkeypatch.setattr(menu_module, "dpg_append_items", instance.append_items) - monkeypatch.setattr(menu_module, "dpg_delete_item", instance.delete_item) + monkeypatch.setattr(menu_section_module.dpg, "add_group", instance.add_group) + monkeypatch.setattr(menu_section_module.dpg, "item_handler_registry", instance.item_handler_registry) + monkeypatch.setattr(menu_section_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler) + monkeypatch.setattr(menu_section_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry) + monkeypatch.setattr(menu_section_module, "dpg_append_items", instance.append_items) + monkeypatch.setattr(menu_section_module, "dpg_delete_item", instance.delete_item) return instance @@ -174,6 +182,12 @@ def shortcuts(framework: _DearPyGuiRecorder) -> _ShortcutManagerRecorder: return _ShortcutManagerRecorder(framework.built) +@pytest.fixture +def voice_actions() -> Callable[[], None]: + """Stands in for the voices panel, which states the chosen voice's actions.""" + return lambda: None + + @pytest.fixture def switched() -> List[ChannelName]: """The channels the bar asks the sequencer to switch, in the order it asks.""" @@ -184,6 +198,7 @@ def switched() -> List[ChannelName]: def menu_bar( shortcuts: _ShortcutManagerRecorder, switched: List[ChannelName], + voice_actions: Callable[[], None], ) -> MenuBar: """A bar with the collaborators its submenus read, from the real language file.""" instance = MenuBar.__new__(MenuBar) @@ -191,9 +206,16 @@ def menu_bar( instance._language_manager = LanguageManager(LANG_EN) instance._on_channel_muted = switched.append instance._build_edit_actions = lambda: False - instance._edit_actions_handler_tag = "handlers" - instance._edit_actions_frame = None - instance._edit_action_items = () + instance._edit_section = MenuSection( + menu_tag=TAG_GLOBAL_MENU_GROUP_EDIT, + marker_tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, + build=instance._add_edit_action_items, + ) + instance._voice_section = MenuSection( + menu_tag=TAG_GLOBAL_MENU_GROUP_VOICE, + marker_tag=TAG_GLOBAL_MENU_GROUP_VOICE_MARKER, + build=voice_actions, + ) return instance @@ -422,8 +444,11 @@ def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: instance = MenuBar.__new__(MenuBar) instance._language_manager = LanguageManager(LANG_EN) instance._build_edit_actions = build_edit_actions - instance._edit_actions_frame = None - instance._edit_action_items = () + instance._edit_section = MenuSection( + menu_tag=TAG_GLOBAL_MENU_GROUP_EDIT, + marker_tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, + build=instance._add_edit_action_items, + ) return instance @@ -502,7 +527,7 @@ def test_the_clipboard_actions_are_named_greyed_out_with_no_grid_focused( self, framework: _DearPyGuiRecorder, ) -> None: - _edit_bar(lambda: False)._refresh_edit_actions() + _edit_bar(lambda: False)._edit_section.refresh() assert [item["label"] for item in framework.items] == ["Copy", "Cut", "Paste", "Delete"] assert [item["enabled"] for item in framework.items] == [False] * 4 @@ -517,7 +542,7 @@ def build() -> bool: requests.append(True) return True - _edit_bar(build)._refresh_edit_actions() + _edit_bar(build)._edit_section.refresh() assert requests == [True] assert framework.items == [] @@ -526,7 +551,7 @@ def test_the_actions_are_stated_into_the_menu_itself( self, framework: _DearPyGuiRecorder, ) -> None: - _edit_bar(lambda: False)._refresh_edit_actions() + _edit_bar(lambda: False)._edit_section.refresh() assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT] @@ -536,8 +561,8 @@ def test_a_build_takes_away_only_what_the_one_before_it_stated( ) -> None: menu_bar = _edit_bar(lambda: False) - menu_bar._refresh_edit_actions() - menu_bar._refresh_edit_actions() + menu_bar._edit_section.refresh() + menu_bar._edit_section.refresh() assert framework.deleted == [0, 1, 2, 3] @@ -566,36 +591,81 @@ def test_the_marker_stands_before_every_item( ] -class TestEditActionsRefresh: - """DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in - those reports is what marks a fresh opening.""" +class TestVoiceMenu: + """One group names every way a voice comes in, and carries what the chosen one offers.""" - def test_the_actions_are_stated_once_while_the_menu_stays_open( + def test_the_ways_a_voice_comes_in_are_named_together( self, - framework: _DearPyGuiRecorder, - monkeypatch: pytest.MonkeyPatch, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, ) -> None: - frames = iter([10, 11, 12, 13]) - monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames)) - requests: List[int] = [] - menu_bar = _edit_bar(lambda: bool(requests.append(1))) + menu_bar._create_voice_menu(_state(frozenset())) + + assert shortcuts.labels == [ + "New instrument", + "Add sample from file...", + "Add to Sequencer", + ] - for _ in range(4): - menu_bar._on_edit_actions_drawn(0, 0) + def test_each_way_carries_its_own_action( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_voice_menu(_state(frozenset())) - assert len(requests) == 1 + assert [item["shortcut_id"] for item in shortcuts.items] == [ + ShortcutId.NEW_INSTRUMENT, + ShortcutId.ADD_SAMPLE_FROM_FILE, + ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, + ] - def test_the_actions_are_stated_afresh_each_time_the_menu_is_opened( + def test_the_pool_waits_for_a_project_to_hold_a_voice( self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_voice_menu(_state(frozenset()).model_copy(update={"project_open": False})) + + assert shortcuts.item("New instrument")["enabled"] is False + assert shortcuts.item("Add sample from file...")["enabled"] is False + + def test_bringing_the_open_reconstruction_in_waits_for_one_to_be_open( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_voice_menu(_state(frozenset())) + + assert shortcuts.item("Add to Sequencer")["enabled"] is False + + def test_the_marker_stands_before_every_item( + self, + menu_bar: MenuBar, framework: _DearPyGuiRecorder, - monkeypatch: pytest.MonkeyPatch, ) -> None: - frames = iter([10, 11, 40, 41]) - monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames)) - requests: List[int] = [] - menu_bar = _edit_bar(lambda: bool(requests.append(1))) + menu_bar._create_voice_menu(_state(frozenset())) + + assert framework.built[0] == f"group:{TAG_GLOBAL_MENU_GROUP_VOICE_MARKER}" + + def test_the_chosen_voice_is_asked_for_as_the_group_is_built( + self, + shortcuts: _ShortcutManagerRecorder, + framework: _DearPyGuiRecorder, + switched: List[ChannelName], + ) -> None: + """The section is stated once the group stands, so the menu opens with its full height.""" + asked: List[int] = [] + instance = MenuBar.__new__(MenuBar) + instance._shortcut_manager = shortcuts + instance._language_manager = LanguageManager(LANG_EN) + instance._voice_section = MenuSection( + menu_tag=TAG_GLOBAL_MENU_GROUP_VOICE, + marker_tag=TAG_GLOBAL_MENU_GROUP_VOICE_MARKER, + build=lambda: asked.append(1), + ) - for _ in range(4): - menu_bar._on_edit_actions_drawn(0, 0) + instance._create_voice_menu(_state(frozenset())) - assert len(requests) == 2 + assert asked == [1] + assert framework.containers == [TAG_GLOBAL_MENU_GROUP_VOICE] From 9a6523064bb44f755d06b1e333147babc5083392 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 18:03:15 +0200 Subject: [PATCH 089/142] Added: shortcut codebase guards --- .pre-commit-config.yaml | 8 + Makefile | 5 +- docs/development/architecture.md | 26 +- scripts/checks/shortcut_actions.py | 260 ++++++++++++++++++ .../utils/gui/shortcuts/ids.py | 14 +- .../scripts/checks/test_shortcut_actions.py | 117 ++++++++ 6 files changed, 425 insertions(+), 5 deletions(-) create mode 100755 scripts/checks/shortcut_actions.py create mode 100644 tests/unit/scripts/checks/test_shortcut_actions.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f67e8714..ae40ddf33 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,6 +73,14 @@ repos: pass_filenames: false verbose: true + - id: shortcut-actions + name: shortcut actions + entry: uv run scripts/checks/shortcut_actions.py + language: system + files: (\.py|^src/sampletones_config/keybindings/.*\.yaml)$ + pass_filenames: false + verbose: true + - id: mypy name: mypy entry: uv run mypy diff --git a/Makefile b/Makefile index 4e859c64e..5e9480176 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: help setup install build release system-deps run clean pre-commit test benchmarks \ ftm-samples nsf-samples nsf-render compression-report icons player check-import-boundary check-tag-names check-unused-tags \ - check-language-keys check-palette-colors calibration lint pylint mypy format + check-language-keys check-palette-colors check-shortcut-actions calibration lint pylint mypy format ifeq ($(OS),Windows_NT) ifeq ($(MSYSTEM),) @@ -151,6 +151,9 @@ check-language-keys: check-palette-colors: uv run scripts/checks/palette_colors.py +check-shortcut-actions: + uv run scripts/checks/shortcut_actions.py + calibration: uv run scripts/calibration.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index d92708ed8..7fd45ff67 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -174,6 +174,25 @@ A shade is composed by naming its form. `utils/palette/colors/` is a flat star: What DearPyGui has already taken a copy of is registered rather than remembered by whoever set it. `PaletteBindings` (`utils/gui/palette/`) records each `(item, argument)` a palette colour reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a colour gets there. A palette change is then one switch: `PaletteSource.activate` fires the composition root's listener, which re-applies the bindings, refreshes the viewport clear colour, and repaints the sequencer for the row and cell highlights DearPyGui holds as table state. The `palette-colors` hook holds all three rules (see Enforcement). +### 14. An action is declared once; whoever shows it prints it + +An **action** is one `ShortcutId` — the name a key press, a menu item and a context item all reach one behaviour by. Declaring one is a chain of four links, and the `shortcut-actions` check holds every one of them (see Enforcement): + +| Link | Where | What it states | +|------|-------|----------------| +| The action | `utils/gui/shortcuts/ids.py` | its name, and the category that answers it | +| Its keys | every scheme under `sampletones_config/keybindings/` | the combination that fires it, `~` where it ships unbound | +| Its call | `shell.py` — a `ShortcutBindings` field and the entry naming it in the binding map, or membership of `FAMILY_SHORTCUT_IDS` | the one call the action makes | +| Its label | a `KeybindingActionElements` member and its `en.yaml` entry | how the keybindings editor lists it | + +Two kinds of action state their call differently, and the check knows both. One that a whole enum parameterises — an export item per format, an item per channel — is a **family**: a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. A family is *declared*, not recognised: `FAMILY_SHORTCUT_IDS` names the mappings that are ones, so what excuses an action from stating a call of its own is written down rather than inferred from the shape of a dictionary — `SHORTCUT_IDS_BY_NAME` answers with every action and is deliberately not among them. A **panel-scope** action states no call at all, because its key scope (principle 12) acts on the press itself. A `DIALOG` action is named nowhere in the editor, since a dialog is operated by the keys its category holds. + +**A menu item is a view of an action, never a second declaration of it.** `ShortcutManager.add_menu_item(shortcut_id, ...)` is how a menu names one: it takes both the accelerator and the call from the action, and keeps the item under it, so a rebind re-prints the key already on screen. An item passes a `callback` of its own only where it carries a state to show, and then that call is the one switching the state it shows. + +**A set of actions several menus show is declared by whoever owns them, once.** The owner states one builder — `GUISequencerVoicesPanel.add_action_items` for a voice, a grid's edit surface for a cell — and each door decides where to print it: the panel's own row menu, the menu bar's **Edit** group through `EditSurfaceProtocol` and `EditRouter`, the **Voice** group through the panel. Adding an action to the builder reaches every door, and the dividers around it belong to the door rather than to the set. + +**A menu whose contents follow a selection states them when it is opened.** A menu bar is built once, while what an item should say follows the cursor at the moment a reader opens the menu. `ui/elements/menu_section.py::MenuSection` is that mechanism: a marker leads the menu, the framework reports it drawn once a frame while the menu stands open, and a gap in those reports marks a fresh opening and restates the section. The marker leads rather than trails because a container standing below a menu item takes the width those items span as its own, which the popup would then grow to fit on every frame. + --- ## Enforcement @@ -182,7 +201,7 @@ Two mechanisms keep the codebase aligned with this document. **Import-expressible contracts are enforced by a check.** `sampletones_config/boundaries/rules.yaml` states one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the configuration is itself a defect. The same domain holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. `sampletones_config/boundaries/` declares what the boundaries are, `sampletones_shared/meta/import_boundary/` holds how they are read and reported, and `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) runs them over the source tree. A rule names the prefixes it reaches through the groups `boundaries/general.yaml` declares, so the interface several layers stay clear of is written once and each rule names it. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule names the contracts group that stays in reach. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work. -**The identifier vocabularies are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target: +**The identifier vocabularies, and the declarations that complete them, are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target: | Hook | Script | What it holds | |------|--------|---------------| @@ -190,8 +209,9 @@ Two mechanisms keep the codebase aligned with this document. | `tag-names` | `tag_names.py` | A tag constant's name against the tag it composes (principle 9) | | `unused-tags` | `unused_tags.py` | Every `TAG_*`/`SUF_*`/`PRE_*` the `tags/` package declares against the reads of it across `src/`, `tests/`, and `scripts/`, where an import alone stands at no reads | | `palette-colors` | `palette_colors.py` | A colour as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme colour filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) | +| `shortcut-actions` | `shortcut_actions.py` | Every action against the links it needs: a combination in every shipped scheme, a name the keybindings editor lists it by, and — for an application-scope action — the call it makes, whether its own binding or a family (principle 14) | -They read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members; the palette check reads the shipped YAML beside it. That layer derives each package directory from its own location and reports a root it finds nothing at, so a check that sweeps nothing fails loudly where it would otherwise pass clean. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames. +They read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members; the palette and shortcut checks read the shipped YAML beside it. That layer derives each package directory from its own location and reports a root it finds nothing at, so a check that sweeps nothing fails loudly where it would otherwise pass clean. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames. **Behavioral contracts are enforced by review.** Contracts a grep cannot see — where state lives, which methods touch DPG, how errors travel — are upheld in code review against this document. Deviations that survive review are recorded in `docs/development/bugs-and-todos.md § Architecture` until they are paid off; the ledger, not the codebase, is the memory of what is currently out of line. @@ -217,7 +237,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m | Path | Role | |------|------| -| `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar | +| `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar, and `MenuSection` — a run of menu items restated each time its menu is opened | | `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold), the `card()` context manager and the `well()` inset region, driven declaratively by tab coordinators | | `ui/panels/` | Domain-level composite panels, organised by feature area | | `ui/themes/` | DPG themes and per-widget style helpers | diff --git a/scripts/checks/shortcut_actions.py b/scripts/checks/shortcut_actions.py new file mode 100755 index 000000000..b8ff915ed --- /dev/null +++ b/scripts/checks/shortcut_actions.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 + +""" +Checks that every action a key or a menu item reaches is declared the whole way through. + +An action is one `ShortcutId`, and naming it is the first of the links it needs: every shipped +keybinding scheme states the combination that fires it, `KeybindingActionElements` names it so the +keybindings editor can list it, and an application-scope action states the call it makes — either +its own entry in the shell's binding map, or membership of a family that maps a whole enum onto +one call. + +The scheme a build loads validates itself as it loads, so this check covers what that validation +cannot reach: the schemes a platform other than this one ships, the editor's vocabulary, and the +call behind a menu item, which otherwise goes missing until the application is constructed. + +Four things are reported: + unanswered action — an action a shipped scheme states no combination for + unnamed action — a rebindable action `KeybindingActionElements` has no member for + uncalled action — an application-scope action no binding and no family answers + stale entry — a name a scheme or the editor states that no action carries + +Usage: + python scripts/checks/shortcut_actions.py +""" + +import argparse +import ast +import sys +from pathlib import Path +from typing import ( + AbstractSet, + Final, + FrozenSet, + Iterable, + List, + Mapping, + NamedTuple, + Sequence, + Set, +) + +import yaml + +from sampletones_application.categories.elements import settings as settings_module +from sampletones_application.categories.elements.settings import ( + KeybindingActionElements, +) +from sampletones_application.paths import KEYBINDINGS_DIRECTORY +from sampletones_application.utils.gui.shortcuts import ids as ids_module +from sampletones_application.utils.gui.shortcuts.ids import ( + EDITABLE_SHORTCUT_CATEGORIES, + FAMILY_SHORTCUT_IDS, + ShortcutCategory, + ShortcutId, +) +from sampletones_shared.meta.source.modules import SourceModule, parse_module +from sampletones_shared.meta.source.packages import package_directory + +SHORTCUTS_MODULE: Final[Path] = Path(ids_module.__file__) +ELEMENTS_MODULE: Final[Path] = Path(settings_module.__file__) +SHELL_MODULE: Final[Path] = package_directory("sampletones_application") / "shell.py" + +SCHEME_ENCODING: Final[str] = "utf-8" +SCHEME_BINDINGS_FIELD: Final[str] = "bindings" +SHORTCUT_ID_CLASS: Final[str] = "ShortcutId" + +UNANSWERED_ACTION: Final[str] = "unanswered action" +UNNAMED_ACTION: Final[str] = "unnamed action" +UNCALLED_ACTION: Final[str] = "uncalled action" +STALE_ENTRY: Final[str] = "stale entry" + +CALLED_RULE: Final[str] = ( + "an application-scope action states the call it makes, as an entry in the shell's binding map " + "or as a member of FAMILY_SHORTCUT_IDS, whose call is dispatched from an enum" +) + + +class Finding(NamedTuple): + """One thing the check reports, named by kind and located where a reader can open it.""" + + kind: str + location: str + message: str + + +def editable_actions(actions: Iterable[ShortcutId]) -> FrozenSet[ShortcutId]: + """The actions a reader may rebind, which is the set the keybindings editor lists. + + A dialog is operated by the keys its own category holds, so those stay as they are and the + editor names them nowhere. + """ + return frozenset(action for action in actions if action.category in EDITABLE_SHORTCUT_CATEGORIES) + + +def scheme_files(directory: Path = KEYBINDINGS_DIRECTORY) -> List[Path]: + """Every keybinding scheme a directory ships, in name order.""" + return sorted(directory.glob("*.yaml")) + + +def scheme_actions(path: Path) -> Set[str]: + """The action names one scheme states a combination for.""" + document = yaml.safe_load(path.read_text(encoding=SCHEME_ENCODING)) + bindings = ( + document.get(SCHEME_BINDINGS_FIELD) + if isinstance( + document, + dict, + ) + else None + ) + return set(bindings) if isinstance(bindings, dict) else set() + + +def mapping_keys(module: SourceModule) -> Set[str]: + """Every action a module names as a mapping key, which is how the shell states its calls. + + The shell builds its map inside the method that takes the bindings, so the map is read from the + source rather than imported. Families are declared instead, and are read as what they declare. + """ + return { + key.attr + for node in ast.walk(module.tree) + if isinstance(node, ast.Dict) + for key in node.keys + if isinstance(key, ast.Attribute) + and isinstance( + key.value, + ast.Name, + ) + and key.value.id == SHORTCUT_ID_CLASS + } + + +def unanswered_actions( + schemes: Mapping[Path, Set[str]], + actions: Iterable[ShortcutId], +) -> List[Finding]: + """Every action a shipped scheme states no combination for.""" + return [ + Finding( + kind=UNANSWERED_ACTION, + location=str(path), + message=f"this scheme states no combination for {action.value!r}", + ) + for path, stated in schemes.items() + for action in actions + if action.value not in stated + ] + + +def unnamed_actions( + actions: Iterable[ShortcutId], + named: AbstractSet[str], +) -> List[Finding]: + """Every rebindable action the keybindings editor has no member to name.""" + return [ + Finding( + kind=UNNAMED_ACTION, + location=str(SHORTCUTS_MODULE), + message=f"{action.name} has no KeybindingActionElements member, so the editor cannot list it", + ) + for action in sorted( + editable_actions(actions), + key=lambda action: action.name, + ) + if action.name not in named + ] + + +def uncalled_actions( + actions: Iterable[ShortcutId], + answered: AbstractSet[str], +) -> List[Finding]: + """Every application-scope action no call stands behind.""" + return [ + Finding( + kind=UNCALLED_ACTION, + location=str(SHELL_MODULE), + message=f"{action.name} reaches no call: {CALLED_RULE}", + ) + for action in actions + if action.category is ShortcutCategory.APPLICATION and action.name not in answered + ] + + +def stale_scheme_entries( + schemes: Mapping[Path, Set[str]], + actions: Iterable[ShortcutId], +) -> List[Finding]: + """Every name a shipped scheme states that no action carries.""" + values = {action.value for action in actions} + return [ + Finding( + kind=STALE_ENTRY, + location=str(path), + message=f"{stated!r} names no action this build carries", + ) + for path, entries in schemes.items() + for stated in sorted(entries - values) + ] + + +def stale_editor_entries( + elements: Iterable[KeybindingActionElements], + actions: Iterable[ShortcutId], +) -> List[Finding]: + """Every name the keybindings editor states that no rebindable action carries.""" + names = {action.name for action in editable_actions(actions)} + return [ + Finding( + kind=STALE_ENTRY, + location=str(ELEMENTS_MODULE), + message=f"KeybindingActionElements.{element.name} names no action this build carries", + ) + for element in elements + if element.name not in names + ] + + +def check() -> List[Finding]: + """Every link of the chain, over every shipped scheme.""" + actions = tuple(ShortcutId) + elements = tuple(KeybindingActionElements) + schemes = {path: scheme_actions(path) for path in scheme_files()} + answered = mapping_keys(parse_module(SHELL_MODULE)) | {action.name for action in FAMILY_SHORTCUT_IDS} + named = {element.name for element in elements} + + return [ + *unanswered_actions(schemes, actions), + *unnamed_actions(actions, named), + *uncalled_actions(actions, answered), + *stale_scheme_entries(schemes, actions), + *stale_editor_entries(elements, actions), + ] + + +def main(argv: Sequence[str]) -> int: + """Report every action left short of a combination, a name, or the call it makes.""" + parser = argparse.ArgumentParser( + description="Check that every action is declared the whole way through.", + ) + parser.parse_args(list(argv)) + + findings = check() + if not findings: + return 0 + + print("Action(s) declared only part of the way:", file=sys.stderr) + for kind, location, message in findings: + print(f" {kind} | {location}: {message}", file=sys.stderr) + + print( + f"\nFound {len(findings)} incomplete action declaration(s).", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index a2ba3c895..840de695f 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -1,5 +1,5 @@ from enum import Enum, StrEnum -from typing import Dict, Final, Self, Tuple +from typing import Dict, Final, FrozenSet, Self, Tuple from sampletones_application.categories.hierarchy import Tab from sampletones_application.constants.playback import FollowMode @@ -235,3 +235,15 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ExportFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET, ExportFormat.NSF: ShortcutId.EXPORT_INSTRUMENTS_NSF, } + +FAMILY_SHORTCUT_IDS: Final[FrozenSet[ShortcutId]] = frozenset( + shortcut_id + for family in ( + FOLLOW_MODE_SHORTCUT_IDS, + TAB_SHORTCUT_IDS, + CHANNEL_SHORTCUT_IDS, + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, + ) + for shortcut_id in family.values() +) diff --git a/tests/unit/scripts/checks/test_shortcut_actions.py b/tests/unit/scripts/checks/test_shortcut_actions.py new file mode 100644 index 000000000..eac948f9c --- /dev/null +++ b/tests/unit/scripts/checks/test_shortcut_actions.py @@ -0,0 +1,117 @@ +from pathlib import Path +from typing import Final, List, Set + +from sampletones_application.utils.gui.shortcuts.ids import ( + FAMILY_SHORTCUT_IDS, + SHORTCUT_IDS_BY_NAME, + ShortcutCategory, + ShortcutId, +) +from sampletones_shared.meta.source.modules import SourceModule +from tests.suite.scripts import load_script +from tests.suite.source import parse_source + +check_shortcut_actions = load_script("checks/shortcut_actions.py") + +SCHEME: Final[Path] = Path("keybindings/default.yaml") +BOUND_ACTION: Final[ShortcutId] = ShortcutId.NEW_PROJECT +FAMILY_ACTION: Final[ShortcutId] = ShortcutId.EXPORT_PROJECT_FAMITRACKER +PANEL_ACTION: Final[ShortcutId] = ShortcutId.TRACKER_NEXT_ROW +DIALOG_ACTION: Final[ShortcutId] = ShortcutId.DIALOG_ACTIVATE + +SHELL_SOURCE: Final[str] = """ +def bind(bindings): + return { + ShortcutId.NEW_PROJECT: bindings.new_project, + ShortcutId.OPEN_PROJECT: bindings.open_project, + } +""" + + +def _module(source: str) -> SourceModule: + return SourceModule(path=Path("module.py"), tree=parse_source(source)) + + +def _kinds(findings: List[object]) -> Set[str]: + return {finding.kind for finding in findings} # type: ignore[attr-defined] + + +class TestWhereACallIsFound: + """An action reaches its call either as a binding of its own or as a member of a family.""" + + def test_a_binding_map_names_its_actions_as_keys(self) -> None: + """The shell builds its map from the bindings it is handed, so the map is read as source.""" + assert check_shortcut_actions.mapping_keys(_module(SHELL_SOURCE)) == {"NEW_PROJECT", "OPEN_PROJECT"} + + def test_a_family_declares_the_actions_it_dispatches(self) -> None: + """A family is declared rather than recognised, so what counts as one is never guessed.""" + assert FAMILY_ACTION in FAMILY_SHORTCUT_IDS + + def test_the_lookup_of_every_action_by_name_is_no_family(self) -> None: + """`SHORTCUT_IDS_BY_NAME` answers with every action; reading it as a family would excuse + every action from stating a call of its own.""" + assert set(SHORTCUT_IDS_BY_NAME.values()) - FAMILY_SHORTCUT_IDS + + +class TestUnansweredActions: + def test_an_action_a_scheme_passes_over_is_reported(self) -> None: + findings = check_shortcut_actions.unanswered_actions({SCHEME: set()}, (BOUND_ACTION,)) + + assert [finding.location for finding in findings] == [str(SCHEME)] + assert BOUND_ACTION.value in findings[0].message + + def test_an_action_every_scheme_states_passes(self) -> None: + assert check_shortcut_actions.unanswered_actions({SCHEME: {BOUND_ACTION.value}}, (BOUND_ACTION,)) == [] + + def test_each_scheme_is_held_to_the_whole_action_set(self) -> None: + """A scheme no platform loads here falls behind silently, so every shipped one is read.""" + other = Path("keybindings/macos.yaml") + schemes = {SCHEME: {BOUND_ACTION.value}, other: set()} + + findings = check_shortcut_actions.unanswered_actions(schemes, (BOUND_ACTION,)) + + assert [finding.location for finding in findings] == [str(other)] + + +class TestUncalledActions: + def test_an_application_action_with_no_call_is_reported(self) -> None: + findings = check_shortcut_actions.uncalled_actions((BOUND_ACTION,), set()) + + assert _kinds(findings) == {check_shortcut_actions.UNCALLED_ACTION} + assert BOUND_ACTION.name in findings[0].message + + def test_an_action_a_binding_answers_passes(self) -> None: + assert check_shortcut_actions.uncalled_actions((BOUND_ACTION,), {BOUND_ACTION.name}) == [] + + def test_an_action_a_family_answers_passes(self) -> None: + assert check_shortcut_actions.uncalled_actions((FAMILY_ACTION,), {FAMILY_ACTION.name}) == [] + + def test_a_panel_action_needs_no_call_of_its_own(self) -> None: + """A key scope acts on the press itself, so the shell's map answers for nothing there.""" + assert PANEL_ACTION.category is not ShortcutCategory.APPLICATION + assert check_shortcut_actions.uncalled_actions((PANEL_ACTION,), set()) == [] + + +class TestTheEditorsVocabulary: + def test_a_rebindable_action_with_no_member_is_reported(self) -> None: + findings = check_shortcut_actions.unnamed_actions((BOUND_ACTION,), set()) + + assert _kinds(findings) == {check_shortcut_actions.UNNAMED_ACTION} + + def test_a_dialog_action_is_named_nowhere(self) -> None: + """The dialog is operated by its own keys, so the editor leaves them as they are.""" + assert check_shortcut_actions.unnamed_actions((DIALOG_ACTION,), set()) == [] + + def test_a_named_action_passes(self) -> None: + assert check_shortcut_actions.unnamed_actions((BOUND_ACTION,), {BOUND_ACTION.name}) == [] + + +class TestStaleEntries: + def test_a_scheme_naming_a_dropped_action_is_reported(self) -> None: + findings = check_shortcut_actions.stale_scheme_entries({SCHEME: {"RemovedLongAgo"}}, (BOUND_ACTION,)) + + assert _kinds(findings) == {check_shortcut_actions.STALE_ENTRY} + assert "RemovedLongAgo" in findings[0].message + + def test_a_scheme_naming_only_live_actions_passes(self) -> None: + assert check_shortcut_actions.stale_scheme_entries({SCHEME: {BOUND_ACTION.value}}, (BOUND_ACTION,)) == [] From 35cd38c4ed0035f5ecdf73eb6e7a929e6acdc659 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 18:35:06 +0200 Subject: [PATCH 090/142] Added: reading a FamiTracker instrument file --- src/sampletones_core/formats/binary.py | 65 ++++ .../formats/famitracker/instrument.py | 143 ++++++++- .../formats/famitracker/voice.py | 153 ++++++++++ src/sampletones_shared/exceptions/__init__.py | 18 ++ .../exceptions/instrument.py | 31 ++ .../exceptions/validation.py | 4 + .../formats/famitracker/test_fti.py | 287 ++++++++++-------- .../formats/famitracker/test_voice.py | 209 +++++++++++++ .../sampletones_core/formats/test_binary.py | 122 +++++++- 9 files changed, 909 insertions(+), 123 deletions(-) create mode 100644 src/sampletones_core/formats/famitracker/voice.py create mode 100644 src/sampletones_shared/exceptions/instrument.py create mode 100644 tests/unit/sampletones_core/formats/famitracker/test_voice.py diff --git a/src/sampletones_core/formats/binary.py b/src/sampletones_core/formats/binary.py index 5d5777f47..a7b0bf7f5 100644 --- a/src/sampletones_core/formats/binary.py +++ b/src/sampletones_core/formats/binary.py @@ -1,5 +1,7 @@ import struct +from sampletones_shared.exceptions import TruncatedDataError + class BinaryWriter: """Builds a little-endian byte buffer through named, semantic write methods. @@ -55,3 +57,66 @@ def write_terminated_string(self, text: str) -> None: """Writes the UTF-8 bytes of ``text`` followed by a single NUL terminator.""" self._buffer.extend(text.encode("utf-8")) self._buffer.extend(b"\x00") + + +class BinaryReader: + """Takes a little-endian byte buffer apart through named, semantic read methods. + + Binary file reading goes through this class, so raw struct unpacking stays confined here and + the readers above it take the fields one by one, the way the format specification states them. + Every read advances past what it took, so a reader states the layout as a sequence of calls. + """ + + def __init__(self, data: bytes) -> None: + self._data = data + self._offset = 0 + + @property + def remaining(self) -> int: + """The bytes left between where the reader stands and the end of the buffer.""" + return len(self._data) - self._offset + + def read_bytes(self, count: int) -> bytes: + """Takes the next ``count`` bytes. + + Raises: + TruncatedDataError: If the buffer holds fewer than ``count`` bytes from here. + """ + self._require(count) + chunk = self._data[self._offset : self._offset + count] + self._offset += count + return chunk + + def read_uint8(self) -> int: + return self._read(" int: + return self._read(" int: + return self._read(" int: + return self._read(" str: + """Takes a ``uint32`` byte length and the UTF-8 text of that many bytes behind it. + + Raises: + TruncatedDataError: If the buffer holds fewer bytes than the length states. + UnicodeDecodeError: If those bytes are not UTF-8 text. + """ + return self.read_bytes(self.read_uint32()).decode("utf-8") + + def _read(self, fmt: str) -> int: + size = struct.calcsize(fmt) + self._require(size) + (value,) = struct.unpack_from(fmt, self._data, self._offset) + self._offset += size + return int(value) + + def _require(self, count: int) -> None: + if count > self.remaining: + raise TruncatedDataError( + f"A read of {count} bytes at offset {self._offset} runs past the {len(self._data)} bytes held" + ) diff --git a/src/sampletones_core/formats/famitracker/instrument.py b/src/sampletones_core/formats/famitracker/instrument.py index fe18550af..9880ceac4 100644 --- a/src/sampletones_core/formats/famitracker/instrument.py +++ b/src/sampletones_core/formats/famitracker/instrument.py @@ -1,11 +1,20 @@ +from typing import Dict + +from pydantic import ValidationError + +from sampletones_core.formats.binary import BinaryReader from sampletones_core.formats.famitracker.binary import FamiTrackerWriter from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence -from sampletones_core.formats.famitracker.specification.file import FTI_MAGIC, FTI_VERSION +from sampletones_core.formats.famitracker.specification.file import ( + FTI_MAGIC, + FTI_VERSION, +) from sampletones_core.formats.famitracker.specification.instruments import ( EMPTY_DPCM_ASSIGNMENTS, EMPTY_DPCM_SAMPLES, INSTRUMENT_TYPE_2A03, + STANDALONE_INSTRUMENT_INDEX, ) from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, @@ -13,8 +22,16 @@ SEQUENCE_ENABLED, SequenceKind, ) +from sampletones_shared.exceptions import ( + IncompatibleInstrumentVersionError, + InvalidInstrumentValuesError, + MalformedInstrumentError, + NotAnInstrumentFileError, + TruncatedDataError, + UnsupportedInstrumentTypeError, +) from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.serialization import save_binary +from sampletones_shared.utils.serialization import load_binary, save_binary def _write_header(writer: FamiTrackerWriter) -> None: @@ -71,3 +88,125 @@ def instrument_to_fti_bytes(instrument: Instrument2A03) -> bytes: def write_fti(filepath: Pathlike, instrument: Instrument2A03) -> None: """Writes a 2A03 instrument to a ``.fti`` file.""" save_binary(filepath, instrument_to_fti_bytes(instrument)) + + +def _read_header(reader: BinaryReader) -> None: + magic = reader.read_bytes(len(FTI_MAGIC)) + if magic != FTI_MAGIC: + raise NotAnInstrumentFileError(f"An instrument file opens with {FTI_MAGIC!r}, this one with {magic!r}") + + version = reader.read_bytes(len(FTI_VERSION)) + if version != FTI_VERSION: + raise IncompatibleInstrumentVersionError( + f"Instrument file version mismatch: expected {FTI_VERSION!r}, got {version!r}.", + expected_version=FTI_VERSION.decode("ascii"), + actual_version=version.decode("ascii", errors="replace"), + ) + + +def _read_type_and_name(reader: BinaryReader) -> str: + instrument_type = reader.read_uint8() + if instrument_type != INSTRUMENT_TYPE_2A03: + raise UnsupportedInstrumentTypeError( + f"Instrument type {instrument_type} is read here only as the 2A03 type {INSTRUMENT_TYPE_2A03}" + ) + + return reader.read_counted_string() + + +def _read_sequence( + reader: BinaryReader, + kind: SequenceKind, +) -> InstrumentSequence: + if reader.read_int8() == SEQUENCE_DISABLED: + return InstrumentSequence(kind=kind) + + length = reader.read_uint32() + loop_point = reader.read_int32() + release_point = reader.read_int32() + setting = reader.read_uint32() + + return InstrumentSequence( + kind=kind, + items=tuple(reader.read_int8() for _ in range(length)), + loop_point=loop_point, + release_point=release_point, + setting=setting, + ) + + +def _read_sequences( + reader: BinaryReader, +) -> Dict[SequenceKind, InstrumentSequence]: + count = reader.read_int8() + if count != SEQUENCE_COUNT_2A03: + raise MalformedInstrumentError( + f"A 2A03 instrument carries {SEQUENCE_COUNT_2A03} sequences, this one states {count}" + ) + + sequences: Dict[SequenceKind, InstrumentSequence] = {} + for kind in SequenceKind: + sequences[kind] = _read_sequence(reader, kind) + + return sequences + + +def _read_dpcm_section(reader: BinaryReader) -> None: + """Steps past the key assignments and samples a file states behind its sequences.""" + reader.read_uint32() + reader.read_uint32() + + +def _read_instrument(reader: BinaryReader) -> Instrument2A03: + _read_header(reader) + name = _read_type_and_name(reader) + sequences = _read_sequences(reader) + _read_dpcm_section(reader) + + return Instrument2A03( + index=STANDALONE_INSTRUMENT_INDEX, + name=name, + sequences=sequences, + ) + + +def fti_bytes_to_instrument(data: bytes) -> Instrument2A03: + """Reads a 2A03 instrument from the FamiTracker ``.fti`` byte layout. + + The five sequences and the name are what a file carries into an instrument, taken in the + order :func:`instrument_to_fti_bytes` writes them. A file states its own DPCM key + assignments and samples behind them, which a 2A03 instrument here leaves to the file. + + Args: + data: The bytes of a ``.fti`` file. + + Returns: + Instrument2A03: The instrument the bytes describe, numbered as a standalone file's. + + Raises: + NotAnInstrumentFileError: If the data opens with something other than the signature. + IncompatibleInstrumentVersionError: If the file states another layout version. + UnsupportedInstrumentTypeError: If the file states a chip other than the 2A03. + MalformedInstrumentError: If the file departs from the layout its own fields describe. + InvalidInstrumentValuesError: If a sequence carries more items than one holds. + """ + try: + return _read_instrument(BinaryReader(data)) + except (TruncatedDataError, UnicodeDecodeError) as exception: + raise MalformedInstrumentError( + f"The instrument file ends inside the layout it states: {exception}" + ) from exception + except ValidationError as exception: + raise InvalidInstrumentValuesError( + f"Failed to read an instrument due to validation error: {exception}", + exception, + ) from exception + + +def read_fti(filepath: Pathlike) -> Instrument2A03: + """Reads a 2A03 instrument from a ``.fti`` file. + + Raises: + FileNotFoundError: If no file stands at ``filepath``. + """ + return fti_bytes_to_instrument(load_binary(filepath)) diff --git a/src/sampletones_core/formats/famitracker/voice.py b/src/sampletones_core/formats/famitracker/voice.py new file mode 100644 index 000000000..c629e3b7e --- /dev/null +++ b/src/sampletones_core/formats/famitracker/voice.py @@ -0,0 +1,153 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, Optional, Tuple + +from pydantic import ValidationError + +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.sequences import ( + DEFAULT_SEQUENCE_SETTING, + LOOP_FROM_START, + NO_RELEASE_POINT, + SequenceKind, +) +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.exceptions import InvalidInstrumentValuesError + +Sequences = Dict[SequenceKind, InstrumentSequence] + + +class InstrumentOmission(StrEnum): + """What a tracker instrument states beyond the envelopes and the one loop point a voice holds.""" + + PITCH = "pitch" + HI_PITCH = "hi_pitch" + RELEASE_POINT = "release_point" + ARPEGGIO_MODE = "arpeggio_mode" + SEQUENCE_LOOP_POINTS = "sequence_loop_points" + + +@dataclass(frozen=True) +class ImportedVoice: + """A voice made from a tracker instrument, beside what the instrument stated past it. + + Attributes: + voice: The instrument voice the envelopes describe. + omissions: What the tracker instrument carried that the voice leaves to the file. + """ + + voice: Instrument + omissions: Tuple[InstrumentOmission, ...] + + +def instrument_to_voice(instrument: Instrument2A03) -> ImportedVoice: + """Makes a voice from a FamiTracker instrument, and names what the instrument stated past it. + + A voice carries a volume, an arpeggio and a duty-cycle envelope, and one loop point every + dimension follows, so those come across as they stand. A tracker instrument states more than + that — a pitch bend, a release segment, an arpeggio mode, a loop point of its own per + sequence — and each of those is reported, so a reader learns what the file held. + + The voice measures its arpeggio against the roots a voice added by hand rests on, since a + tracker instrument sounds at whatever note a row names it with. + + Args: + instrument: The instrument a ``.fti`` file or a module holds. + + Returns: + ImportedVoice: The voice, and what it leaves to the instrument it came from. + + Raises: + InvalidInstrumentValuesError: If a sequence carries an item outside the range the + dimension it feeds holds. + """ + sequences = instrument.sequences + governing = _governing_sequence(sequences) + + return ImportedVoice( + voice=_voice(instrument.name, sequences, _loop_point(governing)), + omissions=_omissions(sequences, governing), + ) + + +def _voice( + name: str, + sequences: Sequences, + loop_point: Optional[int], +) -> Instrument: + try: + envelopes = InstrumentEnvelopes( + volume=sequences[SequenceKind.VOLUME].items, + arpeggio=sequences[SequenceKind.ARPEGGIO].items, + duty_cycle=sequences[SequenceKind.DUTY].items, + ) + except ValidationError as exception: + raise InvalidInstrumentValuesError( + f'Failed to read the envelopes of instrument "{name}" due to validation error: {exception}', + exception, + ) from exception + + return Instrument( + name=name, + envelopes=envelopes, + loop_point=loop_point, + ) + + +def _governing_sequence(sequences: Sequences) -> Optional[InstrumentSequence]: + """The sequence whose loop point the whole voice adopts. + + A voice repeats every dimension from one tick, so one sequence states the point the rest + follow. The volume sequence governs wherever it is written, since it is the one that shapes + a held note; otherwise the first sequence the instrument writes does. + """ + volume = sequences[SequenceKind.VOLUME] + if volume.enabled: + return volume + + return next( + (sequence for sequence in sequences.values() if sequence.enabled), + None, + ) + + +def _loop_point(governing: Optional[InstrumentSequence]) -> Optional[int]: + if governing is None or governing.loop_point < LOOP_FROM_START: + return None + + return governing.loop_point + + +def _omissions( + sequences: Sequences, + governing: Optional[InstrumentSequence], +) -> Tuple[InstrumentOmission, ...]: + arpeggio = sequences[SequenceKind.ARPEGGIO] + held = { + InstrumentOmission.PITCH: sequences[SequenceKind.PITCH].enabled, + InstrumentOmission.HI_PITCH: sequences[SequenceKind.HI_PITCH].enabled, + InstrumentOmission.RELEASE_POINT: _holds_release_point(sequences), + InstrumentOmission.ARPEGGIO_MODE: arpeggio.enabled and arpeggio.setting != DEFAULT_SEQUENCE_SETTING, + InstrumentOmission.SEQUENCE_LOOP_POINTS: _holds_separate_loop_points( + sequences, + governing, + ), + } + + return tuple(omission for omission, stated in held.items() if stated) + + +def _holds_release_point(sequences: Sequences) -> bool: + return any(sequence.enabled and sequence.release_point != NO_RELEASE_POINT for sequence in sequences.values()) + + +def _holds_separate_loop_points( + sequences: Sequences, + governing: Optional[InstrumentSequence], +) -> bool: + if governing is None: + return False + + return any(sequence.enabled and sequence.loop_point != governing.loop_point for sequence in sequences.values()) diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index d4be86cca..62cae0b58 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -3,6 +3,15 @@ from .callback import CallbackQueueStop from .cuda import CuPyNotInstalledWarning from .dialog import FileDialogUnavailableError +from .instrument import ( + IncompatibleInstrumentVersionError, + InstrumentError, + InvalidInstrumentValuesError, + LoadInstrumentError, + MalformedInstrumentError, + NotAnInstrumentFileError, + UnsupportedInstrumentTypeError, +) from .language import LanguageError, MalformedTextKeyError, MissingTextError from .library import ( IncompatibleLibraryDataVersionError, @@ -45,6 +54,7 @@ DeserializationError, InvalidMetadataError, SerializationError, + TruncatedDataError, ) from .window import WindowError, WindowNotAvailableError @@ -55,12 +65,15 @@ "DeserializationError", "DriverBuildError", "FileDialogUnavailableError", + "IncompatibleInstrumentVersionError", "IncompatibleLibraryDataVersionError", "IncompatibleProjectVersionError", "IncompatibleReconstructionVersionError", "IncompleteHistogramRebinningWarning", "IncorrectReconstructionDataError", "InstructionTypeMismatchError", + "InstrumentError", + "InvalidInstrumentValuesError", "InvalidLibraryDataError", "InvalidLibraryDataValuesError", "InvalidMetadataError", @@ -70,15 +83,18 @@ "LanguageError", "LibraryDisplayError", "LibraryError", + "LoadInstrumentError", "LoadLibraryError", "LoadProjectError", "LoadReconstructionError", + "MalformedInstrumentError", "MalformedTextKeyError", "MissingProjectDataFileError", "MissingTextError", "NoFilesToProcessError", "NoLibraryDataError", "NotAValidArchiveError", + "NotAnInstrumentFileError", "OperationCancelled", "PlaybackError", "PlayerError", @@ -87,10 +103,12 @@ "SerializationError", "SongTooLargeError", "ToolchainMissingError", + "TruncatedDataError", "UnhandledLibraryError", "UnhandledProjectError", "UnhandledReconstructionError", "UnsupportedAudioFormatError", + "UnsupportedInstrumentTypeError", "WindowError", "WindowNotAvailableError", ] diff --git a/src/sampletones_shared/exceptions/instrument.py b/src/sampletones_shared/exceptions/instrument.py new file mode 100644 index 000000000..93ef4408e --- /dev/null +++ b/src/sampletones_shared/exceptions/instrument.py @@ -0,0 +1,31 @@ +from .base import SampleToNESError +from .validation import InvalidValuesError +from .version import IncompatibleVersionError + + +class InstrumentError(SampleToNESError): + """Base class for instrument file errors.""" + + +class LoadInstrumentError(InstrumentError): + """Exception raised when there is an error loading an instrument file.""" + + +class NotAnInstrumentFileError(LoadInstrumentError): + """Raised when the data opens with something other than the instrument file signature.""" + + +class IncompatibleInstrumentVersionError(IncompatibleVersionError, LoadInstrumentError): + """Raised when the instrument file states a layout version other than the supported one.""" + + +class UnsupportedInstrumentTypeError(LoadInstrumentError): + """Raised when the instrument file states a chip other than the one read here.""" + + +class MalformedInstrumentError(LoadInstrumentError): + """Raised when an instrument file departs from the layout its own fields describe.""" + + +class InvalidInstrumentValuesError(InvalidValuesError, LoadInstrumentError): + """Raised when instrument data contains invalid values.""" diff --git a/src/sampletones_shared/exceptions/validation.py b/src/sampletones_shared/exceptions/validation.py index 1a0cbcffc..1c9956bb9 100644 --- a/src/sampletones_shared/exceptions/validation.py +++ b/src/sampletones_shared/exceptions/validation.py @@ -27,3 +27,7 @@ class SerializationError(InvalidDataError): class DeserializationError(InvalidDataError): """Raised when deserialization fails.""" + + +class TruncatedDataError(InvalidDataError): + """Raised when a reader is asked for more bytes than the data holds from where it stands.""" diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index 865e73ee1..a9693a309 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -1,16 +1,46 @@ -import struct -from dataclasses import dataclass from pathlib import Path -from typing import List, Optional, Tuple +from typing import Optional import numpy as np - -from sampletones_core.formats.famitracker.instrument import write_fti +import pytest + +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.formats.binary import BinaryWriter +from sampletones_core.formats.famitracker.instrument import ( + fti_bytes_to_instrument, + instrument_to_fti_bytes, + read_fti, + write_fti, +) from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, ) +from sampletones_core.formats.famitracker.specification.file import FTI_MAGIC, FTI_VERSION +from sampletones_core.formats.famitracker.specification.instruments import ( + EMPTY_DPCM_ASSIGNMENTS, + EMPTY_DPCM_SAMPLES, + INSTRUMENT_TYPE_2A03, + STANDALONE_INSTRUMENT_INDEX, +) +from sampletones_core.formats.famitracker.specification.sequences import ( + DEFAULT_SEQUENCE_SETTING, + MAX_SEQUENCE_ITEMS, + NO_LOOP_POINT, + NO_RELEASE_POINT, + SEQUENCE_COUNT_2A03, + SEQUENCE_DISABLED, + SEQUENCE_ENABLED, + SequenceKind, +) from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_shared.exceptions import ( + IncompatibleInstrumentVersionError, + InvalidInstrumentValuesError, + MalformedInstrumentError, + NotAnInstrumentFileError, + UnsupportedInstrumentTypeError, +) GOLDEN_INSTRUMENT_NAME = "Test Instrument" GOLDEN_VOLUME = np.array([15, 12, 8, 0]) @@ -26,6 +56,34 @@ b"\x00\x00\x00\x00\x00\x00\x00\x00" ) +SEQUENCE_COUNT_OFFSET = 26 +TYPE_OFFSET = 6 +SIGNATURE_LENGTH = 3 + + +def fti_stating_volume_items(count: int) -> bytes: + """A file whose volume sequence states ``count`` items, written field by field.""" + writer = BinaryWriter() + writer.write_bytes(FTI_MAGIC) + writer.write_bytes(FTI_VERSION) + writer.write_uint8(INSTRUMENT_TYPE_2A03) + writer.write_counted_string("Long") + writer.write_int8(SEQUENCE_COUNT_2A03) + writer.write_int8(SEQUENCE_ENABLED) + writer.write_uint32(count) + writer.write_int32(NO_LOOP_POINT) + writer.write_int32(NO_RELEASE_POINT) + writer.write_uint32(DEFAULT_SEQUENCE_SETTING) + for _ in range(count): + writer.write_int8(MAX_VOLUME) + + for _ in range(SEQUENCE_COUNT_2A03 - 1): + writer.write_int8(SEQUENCE_DISABLED) + + writer.write_uint32(EMPTY_DPCM_ASSIGNMENTS) + writer.write_uint32(EMPTY_DPCM_SAMPLES) + return writer.data + def build_instrument( name: str, @@ -49,72 +107,12 @@ def build_instrument( return Instrument2A03(index=index, name=name, sequences=sequences) -@dataclass -class ParsedSequence: - enabled: bool - loop_point: int - release_point: int - setting: int - items: List[int] - - -@dataclass -class ParsedFti: - magic: bytes - version: bytes - instrument_type: int - name: str - sequences: List[ParsedSequence] - dpcm_assignment_count: int - dpcm_sample_count: int - - -def _read(data: bytes, offset: int, fmt: str) -> Tuple[int, int]: - size = struct.calcsize(fmt) - (value,) = struct.unpack_from(fmt, data, offset) - return value, offset + size - - -def parse_fti(data: bytes) -> ParsedFti: - offset = 0 - magic = data[offset : offset + 3] - version = data[offset + 3 : offset + 6] - offset += 6 - - instrument_type, offset = _read(data, offset, " Instrument2A03: + return build_instrument( + GOLDEN_INSTRUMENT_NAME, + volume=GOLDEN_VOLUME, + arpeggio=GOLDEN_ARPEGGIO, + duty_cycle=GOLDEN_DUTY_CYCLE, ) @@ -125,63 +123,112 @@ class TestWriteFtiGoldenBytes: def test_output_matches_golden(self, tmp_path: Path) -> None: path = tmp_path / "golden.fti" - instrument = build_instrument( - GOLDEN_INSTRUMENT_NAME, - volume=GOLDEN_VOLUME, - arpeggio=GOLDEN_ARPEGGIO, - duty_cycle=GOLDEN_DUTY_CYCLE, - ) - write_fti(path, instrument) + write_fti(path, golden_instrument()) assert path.read_bytes() == GOLDEN_FTI_BYTES -class TestWriteFtiRoundTrip: - def test_header_and_type(self, tmp_path: Path) -> None: - path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Lead", volume=np.array([15, 0]))) - parsed = parse_fti(path.read_bytes()) - assert parsed.magic == b"FTI" - assert parsed.version == b"2.4" - assert parsed.instrument_type == 1 +class TestReadGoldenBytes: + """The reader takes the pinned bytes back into the instrument that wrote them.""" - def test_name_round_trips(self, tmp_path: Path) -> None: - path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Bass Line", volume=np.array([15, 0]))) - parsed = parse_fti(path.read_bytes()) - assert parsed.name == "Bass Line" + def test_the_golden_bytes_read_back_as_the_instrument(self) -> None: + assert fti_bytes_to_instrument(GOLDEN_FTI_BYTES) == golden_instrument() - def test_all_five_sequence_slots_present(self, tmp_path: Path) -> None: - path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Lead", volume=np.array([15, 0]))) - parsed = parse_fti(path.read_bytes()) - assert len(parsed.sequences) == 5 + def test_the_name_comes_back(self) -> None: + assert fti_bytes_to_instrument(GOLDEN_FTI_BYTES).name == GOLDEN_INSTRUMENT_NAME - def test_enabled_sequence_items_round_trip(self, tmp_path: Path) -> None: - path = tmp_path / "instrument.fti" + def test_a_standalone_file_holds_the_first_slot(self) -> None: + assert fti_bytes_to_instrument(GOLDEN_FTI_BYTES).index == STANDALONE_INSTRUMENT_INDEX + + +class TestFtiRoundTrip: + def test_the_bytes_come_back_the_same(self) -> None: + instrument = golden_instrument() + data = instrument_to_fti_bytes(instrument) + assert instrument_to_fti_bytes(fti_bytes_to_instrument(data)) == data + + def test_the_name_round_trips(self) -> None: + instrument = build_instrument("Bass Line", volume=np.array([15, 0])) + assert fti_bytes_to_instrument(instrument_to_fti_bytes(instrument)).name == "Bass Line" + + def test_all_five_sequence_slots_are_present(self) -> None: + instrument = fti_bytes_to_instrument( + instrument_to_fti_bytes(build_instrument("Lead", volume=np.array([15, 0]))) + ) + assert set(instrument.sequences) == set(SequenceKind) + + def test_enabled_sequence_items_round_trip(self) -> None: instrument = build_instrument("Lead", volume=np.array([15, 12, 8, 0]), arpeggio=np.array([0, 2, -3])) - write_fti(path, instrument) - parsed = parse_fti(path.read_bytes()) - assert parsed.sequences[0].enabled is True - assert parsed.sequences[0].items == [15, 12, 8, 0] - assert parsed.sequences[1].items == [0, 2, -3] + read = fti_bytes_to_instrument(instrument_to_fti_bytes(instrument)) + assert read.sequences[SequenceKind.VOLUME].items == (15, 12, 8, 0) + assert read.sequences[SequenceKind.ARPEGGIO].items == (0, 2, -3) - def test_missing_sequences_are_disabled(self, tmp_path: Path) -> None: - path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Lead", volume=np.array([15, 0]))) - parsed = parse_fti(path.read_bytes()) - assert parsed.sequences[2].enabled is False - assert parsed.sequences[3].enabled is False - assert parsed.sequences[4].enabled is False + def test_missing_sequences_come_back_disabled(self) -> None: + instrument = fti_bytes_to_instrument( + instrument_to_fti_bytes(build_instrument("Lead", volume=np.array([15, 0]))) + ) + assert not instrument.sequences[SequenceKind.PITCH].enabled + assert not instrument.sequences[SequenceKind.HI_PITCH].enabled + assert not instrument.sequences[SequenceKind.DUTY].enabled - def test_loop_flag_sets_loop_point(self, tmp_path: Path) -> None: - path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Pad", volume=np.array([15, 10, 5]), loop_point=WHOLE_LOOP_POINT)) - parsed = parse_fti(path.read_bytes()) - assert parsed.sequences[0].loop_point == 0 + def test_the_loop_point_round_trips(self) -> None: + instrument = build_instrument("Pad", volume=np.array([15, 10, 5]), loop_point=WHOLE_LOOP_POINT) + read = fti_bytes_to_instrument(instrument_to_fti_bytes(instrument)) + assert read.sequences[SequenceKind.VOLUME].loop_point == WHOLE_LOOP_POINT - def test_dpcm_section_is_empty(self, tmp_path: Path) -> None: + def test_a_written_file_reads_back(self, tmp_path: Path) -> None: path = tmp_path / "instrument.fti" - write_fti(path, build_instrument("Lead", volume=np.array([15, 0]))) - parsed = parse_fti(path.read_bytes()) - assert parsed.dpcm_assignment_count == 0 - assert parsed.dpcm_sample_count == 0 + instrument = golden_instrument() + write_fti(path, instrument) + assert read_fti(path) == instrument + + def test_a_missing_file_raises(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + read_fti(tmp_path / "absent.fti") + + +class TestReadFtiRefusals: + def test_other_data_is_not_an_instrument_file(self) -> None: + with pytest.raises(NotAnInstrumentFileError): + fti_bytes_to_instrument(b"XXX" + GOLDEN_FTI_BYTES[SIGNATURE_LENGTH:]) + + def test_another_layout_version_is_refused(self) -> None: + with pytest.raises(IncompatibleInstrumentVersionError): + fti_bytes_to_instrument(b"FTI9.9" + GOLDEN_FTI_BYTES[TYPE_OFFSET:]) + + def test_the_refused_version_is_named(self) -> None: + with pytest.raises(IncompatibleInstrumentVersionError) as raised: + fti_bytes_to_instrument(b"FTI9.9" + GOLDEN_FTI_BYTES[TYPE_OFFSET:]) + + assert raised.value.actual_version == "9.9" + assert raised.value.expected_version == "2.4" + + def test_another_chip_is_refused(self) -> None: + data = GOLDEN_FTI_BYTES[:TYPE_OFFSET] + b"\x05" + GOLDEN_FTI_BYTES[TYPE_OFFSET + 1 :] + with pytest.raises(UnsupportedInstrumentTypeError): + fti_bytes_to_instrument(data) + + def test_another_sequence_count_is_refused(self) -> None: + data = GOLDEN_FTI_BYTES[:SEQUENCE_COUNT_OFFSET] + b"\x03" + GOLDEN_FTI_BYTES[SEQUENCE_COUNT_OFFSET + 1 :] + with pytest.raises(MalformedInstrumentError): + fti_bytes_to_instrument(data) + + def test_a_file_cut_short_is_refused(self) -> None: + with pytest.raises(MalformedInstrumentError): + fti_bytes_to_instrument(GOLDEN_FTI_BYTES[:20]) + + def test_empty_data_is_refused(self) -> None: + with pytest.raises(MalformedInstrumentError): + fti_bytes_to_instrument(b"") + + def test_a_sequence_longer_than_one_holds_is_refused(self) -> None: + with pytest.raises(InvalidInstrumentValuesError): + fti_bytes_to_instrument(fti_stating_volume_items(MAX_SEQUENCE_ITEMS + 1)) + + def test_a_sequence_of_the_length_one_holds_is_read(self) -> None: + instrument = fti_bytes_to_instrument(fti_stating_volume_items(MAX_SEQUENCE_ITEMS)) + assert len(instrument.sequences[SequenceKind.VOLUME].items) == MAX_SEQUENCE_ITEMS + + def test_a_name_that_is_not_text_is_refused(self) -> None: + data = GOLDEN_FTI_BYTES[:11] + b"\xff" * 15 + GOLDEN_FTI_BYTES[SEQUENCE_COUNT_OFFSET:] + with pytest.raises(MalformedInstrumentError): + fti_bytes_to_instrument(data) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_voice.py b/tests/unit/sampletones_core/formats/famitracker/test_voice.py new file mode 100644 index 000000000..336e68212 --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_voice.py @@ -0,0 +1,209 @@ +from typing import Dict, Mapping, Tuple + +import pytest + +from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH +from sampletones_core.formats.famitracker.builder import build_instrument +from sampletones_core.formats.famitracker.instrument import ( + fti_bytes_to_instrument, + instrument_to_fti_bytes, +) +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.instruments import ( + STANDALONE_INSTRUMENT_INDEX, +) +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.voice import ( + ImportedVoice, + InstrumentOmission, + instrument_to_voice, +) +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.exceptions import InvalidInstrumentValuesError + +ARPEGGIO_SCHEME_SETTING = 2 + + +def written( + kind: SequenceKind, + items: Tuple[int, ...], + **fields: int, +) -> InstrumentSequence: + return InstrumentSequence(kind=kind, items=items, **fields) + + +def tracker_instrument( + *sequences: InstrumentSequence, + name: str = "Lead", +) -> Instrument2A03: + written_sequences: Dict[SequenceKind, InstrumentSequence] = { + kind: InstrumentSequence(kind=kind) for kind in SequenceKind + } + for sequence in sequences: + written_sequences[sequence.kind] = sequence + + return Instrument2A03( + index=STANDALONE_INSTRUMENT_INDEX, + name=name, + sequences=written_sequences, + ) + + +def imported(*sequences: InstrumentSequence) -> ImportedVoice: + return instrument_to_voice(tracker_instrument(*sequences)) + + +class TestTheEnvelopesAVoiceTakes: + def test_the_three_dimensions_come_across(self) -> None: + voice = imported( + written(SequenceKind.VOLUME, (15, 8, 0)), + written(SequenceKind.ARPEGGIO, (0, 3, 7)), + written(SequenceKind.DUTY, (0, 1, 2)), + ).voice + + assert voice.envelopes == InstrumentEnvelopes(volume=(15, 8, 0), arpeggio=(0, 3, 7), duty_cycle=(0, 1, 2)) + + def test_a_dimension_the_instrument_leaves_out_stays_empty(self) -> None: + voice = imported(written(SequenceKind.VOLUME, (15, 0))).voice + assert voice.envelopes == InstrumentEnvelopes(volume=(15, 0)) + + def test_the_name_comes_across(self) -> None: + assert imported(written(SequenceKind.VOLUME, (15,))).voice.name == "Lead" + + def test_the_voice_rests_on_the_roots_a_voice_added_by_hand_does(self) -> None: + voice = imported(written(SequenceKind.VOLUME, (15,))).voice + assert voice.root_pitch == RESTING_REFERENCE_PITCH + assert voice.root_period == RESTING_REFERENCE_PERIOD + + def test_an_item_outside_the_range_a_dimension_holds_is_refused(self) -> None: + with pytest.raises(InvalidInstrumentValuesError): + imported(written(SequenceKind.VOLUME, (99,))) + + +class TestTheLoopPointAVoiceAdopts: + def test_the_volume_sequence_governs(self) -> None: + voice = imported( + written(SequenceKind.VOLUME, (15, 8), loop_point=1), + written(SequenceKind.ARPEGGIO, (0, 3), loop_point=1), + ).voice + + assert voice.loop_point == 1 + + def test_the_first_written_sequence_governs_where_volume_is_absent(self) -> None: + voice = imported(written(SequenceKind.ARPEGGIO, (0, 3), loop_point=1)).voice + assert voice.loop_point == 1 + + def test_an_instrument_that_states_no_loop_plays_once(self) -> None: + voice = imported(written(SequenceKind.VOLUME, (15, 0))).voice + assert voice.loop_point is None + + def test_an_instrument_with_nothing_written_plays_once(self) -> None: + assert imported().voice.loop_point is None + + def test_a_loop_point_before_the_first_item_plays_once(self) -> None: + voice = imported(written(SequenceKind.VOLUME, (15, 0), loop_point=-4)).voice + assert voice.loop_point is None + + +class TestWhatTheInstrumentStatesPastTheVoice: + @staticmethod + def omissions(*sequences: InstrumentSequence) -> Mapping[InstrumentOmission, bool]: + reported = imported(*sequences).omissions + return {omission: omission in reported for omission in InstrumentOmission} + + def test_a_plain_instrument_leaves_nothing_behind(self) -> None: + assert imported(written(SequenceKind.VOLUME, (15, 8, 0))).omissions == () + + def test_a_pitch_bend_is_reported(self) -> None: + assert self.omissions(written(SequenceKind.PITCH, (1, -1)))[InstrumentOmission.PITCH] + + def test_a_hi_pitch_bend_is_reported(self) -> None: + assert self.omissions(written(SequenceKind.HI_PITCH, (1,)))[InstrumentOmission.HI_PITCH] + + def test_a_release_point_is_reported(self) -> None: + volume = written(SequenceKind.VOLUME, (15, 8), release_point=1) + assert self.omissions(volume)[InstrumentOmission.RELEASE_POINT] + + def test_an_arpeggio_mode_other_than_absolute_is_reported(self) -> None: + arpeggio = written(SequenceKind.ARPEGGIO, (0, 3), setting=ARPEGGIO_SCHEME_SETTING) + assert self.omissions(arpeggio)[InstrumentOmission.ARPEGGIO_MODE] + + def test_the_absolute_arpeggio_a_voice_reads_is_no_omission(self) -> None: + arpeggio = written(SequenceKind.ARPEGGIO, (0, 3)) + assert not self.omissions(arpeggio)[InstrumentOmission.ARPEGGIO_MODE] + + def test_a_second_loop_point_is_reported(self) -> None: + omissions = self.omissions( + written(SequenceKind.VOLUME, (15, 8), loop_point=1), + written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0), + ) + assert omissions[InstrumentOmission.SEQUENCE_LOOP_POINTS] + + def test_sequences_repeating_from_one_point_leave_nothing_behind(self) -> None: + omissions = self.omissions( + written(SequenceKind.VOLUME, (15, 8), loop_point=1), + written(SequenceKind.ARPEGGIO, (0, 3), loop_point=1), + ) + assert not omissions[InstrumentOmission.SEQUENCE_LOOP_POINTS] + + def test_a_sequence_the_instrument_leaves_out_states_nothing(self) -> None: + omissions = self.omissions(written(SequenceKind.VOLUME, (15, 8), loop_point=1)) + assert not omissions[InstrumentOmission.SEQUENCE_LOOP_POINTS] + + def test_every_dimension_past_the_voice_is_named_at_once(self) -> None: + reported = imported( + written(SequenceKind.VOLUME, (15, 8), loop_point=1), + written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0, setting=ARPEGGIO_SCHEME_SETTING), + written(SequenceKind.PITCH, (1, -1), release_point=1), + written(SequenceKind.HI_PITCH, (0,)), + ).omissions + + assert set(reported) == set(InstrumentOmission) + + +class TestAVoiceThroughAFileAndBack: + """A voice written here reaches a ``.fti`` and comes back holding what it held.""" + + @staticmethod + def round_trip(voice: Instrument) -> ImportedVoice: + tracker = build_instrument( + STANDALONE_INSTRUMENT_INDEX, + voice.name, + voice.instrument_features(), + loop_point=voice.loop_point, + ) + return instrument_to_voice(fti_bytes_to_instrument(instrument_to_fti_bytes(tracker))) + + def test_the_envelopes_come_back(self) -> None: + voice = Instrument( + name="Pad", + envelopes=InstrumentEnvelopes(volume=(15, 10, 5), arpeggio=(0, 3, 7), duty_cycle=(0, 1, 2)), + loop_point=1, + ) + assert self.round_trip(voice).voice.envelopes == voice.envelopes + + def test_the_loop_point_comes_back(self) -> None: + voice = Instrument(name="Pad", envelopes=InstrumentEnvelopes(volume=(15, 10, 5)), loop_point=2) + assert self.round_trip(voice).voice.loop_point == 2 + + def test_the_name_comes_back(self) -> None: + voice = Instrument(name="Bass Line", envelopes=InstrumentEnvelopes(volume=(15, 0))) + assert self.round_trip(voice).voice.name == "Bass Line" + + def test_a_voice_of_its_own_making_leaves_nothing_behind(self) -> None: + voice = Instrument( + name="Pad", + envelopes=InstrumentEnvelopes(volume=(15, 10, 5), arpeggio=(0, 3, 7)), + loop_point=1, + ) + assert self.round_trip(voice).omissions == () + + def test_a_shorter_dimension_comes_back_holding_its_final_value(self) -> None: + voice = Instrument( + name="Pad", + envelopes=InstrumentEnvelopes(volume=(15, 10, 5), duty_cycle=(2,)), + loop_point=0, + ) + assert self.round_trip(voice).voice.envelopes.duty_cycle == (2, 2, 2) diff --git a/tests/unit/sampletones_core/formats/test_binary.py b/tests/unit/sampletones_core/formats/test_binary.py index 8c0a99219..a25b8f266 100644 --- a/tests/unit/sampletones_core/formats/test_binary.py +++ b/tests/unit/sampletones_core/formats/test_binary.py @@ -4,7 +4,8 @@ import pytest -from sampletones_core.formats.binary import BinaryWriter +from sampletones_core.formats.binary import BinaryReader, BinaryWriter +from sampletones_shared.exceptions import TruncatedDataError from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -92,3 +93,122 @@ def test_terminated_string_appends_nul(self) -> None: writer = BinaryWriter() writer.write_terminated_string("note") assert writer.data == b"note\x00" + + +class TestReadIntegerPrimitives(BaseTestSuite): + """Each named read takes the width and signedness its name states, back off the writer.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + method: str + + @property + def label(self) -> str: + return f"{self.method}-{self.expected}" + + test_cases: Tuple[TestCase, ...] = ( + TestCase(method="uint8", expected=0), + TestCase(method="uint8", expected=255), + TestCase(method="int8", expected=-128), + TestCase(method="int8", expected=127), + TestCase(method="uint32", expected=0x0440), + TestCase(method="uint32", expected=4294967295), + TestCase(method="int32", expected=-1), + TestCase(method="int32", expected=2147483647), + ) + + @staticmethod + def written(test_case: TestCase) -> bytes: + writer = BinaryWriter() + { + "uint8": writer.write_uint8, + "int8": writer.write_int8, + "uint32": writer.write_uint32, + "int32": writer.write_int32, + }[test_case.method](test_case.expected) + return writer.data + + @staticmethod + def read(reader: BinaryReader, method: str) -> int: + return { + "uint8": reader.read_uint8, + "int8": reader.read_int8, + "uint32": reader.read_uint32, + "int32": reader.read_int32, + }[method]() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_value_comes_back_off_the_bytes(self, test_case: TestCase) -> None: + reader = BinaryReader(self.written(test_case)) + assert self.read(reader, test_case.method) == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_field_leaves_the_reader_at_the_end(self, test_case: TestCase) -> None: + reader = BinaryReader(self.written(test_case)) + self.read(reader, test_case.method) + assert reader.remaining == 0 + + +class TestReadingAdvances: + def test_each_read_takes_the_next_field(self) -> None: + writer = BinaryWriter() + writer.write_uint8(1) + writer.write_uint32(2) + writer.write_int32(-3) + + reader = BinaryReader(writer.data) + assert (reader.read_uint8(), reader.read_uint32(), reader.read_int32()) == (1, 2, -3) + + def test_the_remainder_counts_down(self) -> None: + reader = BinaryReader(b"\x01\x02\x03\x04") + reader.read_uint8() + assert reader.remaining == 3 + + def test_read_bytes_takes_the_count_asked_for(self) -> None: + reader = BinaryReader(b"abcdef") + assert reader.read_bytes(3) == b"abc" + assert reader.read_bytes(3) == b"def" + + def test_counted_string_round_trips(self) -> None: + writer = BinaryWriter() + writer.write_counted_string("Bass Line") + assert BinaryReader(writer.data).read_counted_string() == "Bass Line" + + def test_counted_string_leaves_what_follows_it(self) -> None: + writer = BinaryWriter() + writer.write_counted_string("hi") + writer.write_uint8(7) + + reader = BinaryReader(writer.data) + reader.read_counted_string() + assert reader.read_uint8() == 7 + + +class TestReadingPastTheEnd: + def test_a_field_wider_than_the_remainder_raises(self) -> None: + reader = BinaryReader(b"\x01\x02") + with pytest.raises(TruncatedDataError): + reader.read_uint32() + + def test_more_bytes_than_held_raises(self) -> None: + reader = BinaryReader(b"abc") + with pytest.raises(TruncatedDataError): + reader.read_bytes(4) + + def test_an_empty_buffer_raises_on_the_first_read(self) -> None: + with pytest.raises(TruncatedDataError): + BinaryReader(b"").read_uint8() + + def test_a_counted_string_longer_than_the_remainder_raises(self) -> None: + writer = BinaryWriter() + writer.write_uint32(10) + writer.write_bytes(b"hi") + with pytest.raises(TruncatedDataError): + BinaryReader(writer.data).read_counted_string() + + def test_the_message_names_the_width_and_the_offset(self) -> None: + reader = BinaryReader(b"\x01\x02") + reader.read_uint8() + with pytest.raises(TruncatedDataError, match="4 bytes at offset 1"): + reader.read_uint32() From 07209bc1daf2a427b6dbb9f31037c4f5d875a9bd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 19:50:53 +0200 Subject: [PATCH 091/142] Added: importing a FamiTracker instrument file --- src/sampletones_application/application.py | 5 + .../categories/elements/sequencer.py | 6 + .../categories/elements/settings.py | 1 + .../categories/instrument.py | 92 +++++++++ .../{export.py => export/song.py} | 0 .../coordinators/tabs/sequencer.py | 93 ++++++++- .../logic/sequencer/voices.py | 34 +++- src/sampletones_application/shell.py | 2 + src/sampletones_application/tags/general.py | 12 ++ src/sampletones_application/ui/menu.py | 20 +- .../ui/panels/sequencer/voices.py | 17 +- .../utils/gui/shortcuts/ids.py | 1 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 11 + .../categories/test_instrument.py | 74 +++++++ .../coordinators/tabs/test_sequencer.py | 190 +++++++++++++++++- .../logic/sequencer/test_voices.py | 156 +++++++++++++- .../ui/panels/sequencer/test_voices_menu.py | 21 +- .../sampletones_application/ui/test_menu.py | 3 + 20 files changed, 721 insertions(+), 19 deletions(-) create mode 100644 src/sampletones_application/categories/instrument.py rename src/sampletones_application/coordinators/{export.py => export/song.py} (100%) create mode 100644 tests/unit/sampletones_application/categories/test_instrument.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 4329ecf78..953b0bf70 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -659,6 +659,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: add_reconstruction_to_sequencer=self._add_current_reconstruction_to_sequencer, new_instrument=self._add_instrument, add_sample_from_file=self._add_sample_from_file, + import_instrument=self._import_instrument, open_reconstruction_in_explorer=self._open_reconstruction_in_explorer, locate_original_audio=self._locate_original_audio, play=self._play, @@ -1480,6 +1481,10 @@ def _add_sample_from_file(self) -> None: """Brings a reconstruction saved anywhere on disk into the pool as a sample.""" self._sequencer_tab.add_sample_from_file() + def _import_instrument(self) -> None: + """Brings a FamiTracker instrument file into the pool as an instrument voice.""" + self._sequencer_tab.import_instrument() + def _play_from_start(self) -> None: self._playback_router.play_from_start() self._update_menu() diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 342ae62ff..0d6e3ef9f 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -72,6 +72,7 @@ class SequencerVoicesElements(AbstractElement): VOICES_TEXT = "voices_text" NEW_INSTRUMENT = "new_instrument" ADD_SAMPLE = "add_sample" + IMPORT_INSTRUMENT = "import_instrument" KIND_SAMPLE = "kind_sample" KIND_INSTRUMENT = "kind_instrument" COLUMN_KIND = "column_kind" @@ -86,6 +87,11 @@ class SequencerVoicesElements(AbstractElement): CONTEXT_MOVE_DOWN = "context_move_down" CONTEXT_MOVE_TOP = "context_move_top" CONTEXT_MOVE_BOTTOM = "context_move_bottom" + OMISSION_PITCH = "omission_pitch" + OMISSION_HI_PITCH = "omission_hi_pitch" + OMISSION_RELEASE_POINT = "omission_release_point" + OMISSION_ARPEGGIO_MODE = "omission_arpeggio_mode" + OMISSION_SEQUENCE_LOOP_POINTS = "omission_sequence_loop_points" class SequencerHistoryElements(AbstractElement): diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 6e233fa86..53fe0d432 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -48,6 +48,7 @@ class KeybindingActionElements(AbstractElement): ADD_RECONSTRUCTION_TO_SEQUENCER = "add_reconstruction_to_sequencer" NEW_INSTRUMENT = "new_instrument" ADD_SAMPLE_FROM_FILE = "add_sample_from_file" + IMPORT_INSTRUMENT = "import_instrument" OPEN_RECONSTRUCTION_IN_EXPLORER = "open_reconstruction_in_explorer" LOCATE_ORIGINAL_AUDIO = "locate_original_audio" PLAY = "play" diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py new file mode 100644 index 000000000..1a328d577 --- /dev/null +++ b/src/sampletones_application/categories/instrument.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import Dict, Final, Optional, Self, Tuple + +from sampletones_application.categories.elements.sequencer import SequencerVoicesElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_core.formats.famitracker.voice import InstrumentOmission + +OMISSION_ELEMENTS: Final[Dict[InstrumentOmission, SequencerVoicesElements]] = { + InstrumentOmission.PITCH: SequencerVoicesElements.OMISSION_PITCH, + InstrumentOmission.HI_PITCH: SequencerVoicesElements.OMISSION_HI_PITCH, + InstrumentOmission.RELEASE_POINT: SequencerVoicesElements.OMISSION_RELEASE_POINT, + InstrumentOmission.ARPEGGIO_MODE: SequencerVoicesElements.OMISSION_ARPEGGIO_MODE, + InstrumentOmission.SEQUENCE_LOOP_POINTS: SequencerVoicesElements.OMISSION_SEQUENCE_LOOP_POINTS, +} + +OMISSION_BULLET: Final[str] = " - " + + +def omission_label( + language_manager: LanguageManager, + element: SequencerVoicesElements, +) -> str: + """Resolves the words one dimension of a tracker instrument is named in.""" + return language_manager[ + Page.SEQUENCER, + Panel.VOICES, + TextType.LABEL, + element, + ] + + +@dataclass(frozen=True) +class InstrumentImportMessages: + """The words a read instrument file is reported in. + + A ``.fti`` states a channel's whole instrument, and a voice takes the three envelopes every + channel here plays. Whatever the file states past them stays in the file, so the import names + it in the reader's own words as the voice arrives. + + Attributes: + title: Title of the dialog reporting a finished import. + template: The opening lines, naming the voice that arrived. + omissions: The words each dimension a file states past the voice is named in. + """ + + title: str + template: str + omissions: Dict[InstrumentOmission, str] + + @classmethod + def build(cls, language_manager: LanguageManager) -> Self: + """Resolves every word the import report prints. + + Args: + language_manager: The catalogue the words are read from. + + Returns: + Self: The bundle the import handler reads. + """ + return cls( + title=language_manager["sequencer.voices.title.instrument_imported"], + template=language_manager["sequencer.voices.template.instrument_omissions"], + omissions={ + omission: omission_label(language_manager, element) + for omission, element in OMISSION_ELEMENTS.items() + }, + ) + + def notice( + self, + name: str, + omissions: Tuple[InstrumentOmission, ...], + ) -> Optional[str]: + """Phrases what an instrument file held beyond the voice it made. + + The dimensions are named in the order the reader states them, so one wording describes a + file however many of them it carries. + + Args: + name: The name the voice arrived under. + omissions: What the instrument file stated past the voice's envelopes. + + Returns: + Optional[str]: The report the import dialog prints, or ``None`` where the file + stated the voice alone. + """ + if not omissions: + return None + + listed = "\n".join(f"{OMISSION_BULLET}{self.omissions[omission]}" for omission in omissions) + return f"{self.template.format(name=name)}\n{listed}" diff --git a/src/sampletones_application/coordinators/export.py b/src/sampletones_application/coordinators/export/song.py similarity index 100% rename from src/sampletones_application/coordinators/export.py rename to src/sampletones_application/coordinators/export/song.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index c013bd281..a2678fc56 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -7,6 +7,7 @@ SequencerHistoryElements, ) from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType +from sampletones_application.categories.instrument import InstrumentImportMessages from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager @@ -61,6 +62,7 @@ SUF_PANEL_CENTER, SUF_PANEL_LEFT, SUF_PANEL_RIGHT, + TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, TAG_GLOBAL_TAB_SEQUENCER, TAG_GLOBAL_TABS, @@ -128,13 +130,17 @@ ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.voice import ImportedVoice from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode from sampletones_core.utils.display import display_id -from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.exceptions import LoadInstrumentError, SampleToNESError from sampletones_shared.logger import logger -from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from sampletones_shared.paths.extensions import ( + EXT_FILE_INSTRUMENT, + EXT_FILE_RECONSTRUCTION, +) from sampletones_shared.types.callback import StringCallback, VoidCallback _UndoableParams = ParamSpec("_UndoableParams") @@ -182,6 +188,7 @@ def __init__( self._language_manager = language_manager self._dialogs = dialogs + self._import_messages = InstrumentImportMessages.build(language_manager) self._msg_no_project = language_manager["global.dialog.message.no_project_open"] self._ttl_no_project = language_manager["global.dialog.title.no_project_open"] self._nes_frequency_change_acknowledged: bool = False @@ -643,6 +650,7 @@ def _wire_samples_callbacks(self) -> None: ) self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file + self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument def add_instrument(self) -> None: """Appends a hand-written voice, named for the position it takes in the list. @@ -658,7 +666,7 @@ def add_instrument(self) -> None: HistoryAction.ADD_INSTRUMENT, detail=self._history_detail.add_instrument(name), ): - self._sequencer_voices_logic.add_instrument(name) + self._sequencer_voices_logic.add_new_instrument(name) def add_sample_from_file(self) -> None: """Brings a reconstruction saved anywhere on disk into the pool as a sample. @@ -685,6 +693,85 @@ def _import_located_reconstruction(self, filepath: Path) -> None: self._session_manager.set_reconstruction_path(filepath.parent) self.import_reconstruction(filepath) + def import_instrument(self) -> None: + """Brings a FamiTracker instrument file into the pool as an instrument voice. + + The file arrives through the system's own browser, which opens on the folder the last + instrument was written to or read from, so an export and the import that follows it meet + in one place. A project is asked for first, since a voice needs a pool to land in. + """ + if not self._project_controller.is_open: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, + self._msg_no_project, + self._ttl_no_project, + ) + return + + filepath = open_file_dialog( + title=self._language_manager["sequencer.voices.title.import_instrument_dialog"], + initial_directory=self._session_manager.get_instrument_path(), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.famitracker_instrument"], + [EXT_FILE_INSTRUMENT], + ), + ), + ) + + self._import_located_instrument(filepath) + + @ignore_none_path + def _import_located_instrument(self, filepath: Path) -> None: + """Reads a located instrument file into the pool, then reports what it held. + + The file is read before the pool is touched, so a file the reader cannot use leaves the + project as it stands and the history without an entry. + """ + self._session_manager.set_instrument_path(filepath.parent) + imported = self._read_instrument(filepath) + if imported is None: + return + + with self._history.transaction( + HistoryAction.ADD_INSTRUMENT, + detail=self._history_detail.add_instrument(imported.voice.name), + ): + self._sequencer_voices_logic.add_instrument(imported.voice) + + self._report_import(imported) + + def _read_instrument(self, filepath: Path) -> Optional[ImportedVoice]: + """Reads an instrument file, reporting a file the reader cannot take as a voice. + + Returns: + Optional[ImportedVoice]: The voice the file describes, or ``None`` once the failure + has been shown. + """ + try: + return self._sequencer_voices_logic.read_instrument(filepath) + except FileNotFoundError as exception: + logger.error_with_traceback(exception, f"No instrument file at {filepath}") + self._dialogs.show_file_not_found( + filepath, + self._language_manager["sequencer.voices.message.instrument_not_found"], + ) + except (LoadInstrumentError, OSError) as exception: + logger.error_with_traceback(exception, f"Failed to read an instrument from {filepath}") + self._dialogs.show_error(exception) + + return None + + def _report_import(self, imported: ImportedVoice) -> None: + """Names what the instrument file carried beyond the voice the pool took from it.""" + notice = self._import_messages.notice(imported.voice.name, imported.omissions) + if notice is not None: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, + notice, + self._import_messages.title, + ) + def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed) self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 48e2327be..90f40bca6 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Callable, Final, Optional import numpy as np @@ -22,6 +23,8 @@ features_footprint, reconstruction_footprints, ) +from sampletones_core.formats.famitracker.instrument import read_fti +from sampletones_core.formats.famitracker.voice import ImportedVoice, instrument_to_voice from sampletones_core.generators.render import render_instructions from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.instrument import Instrument @@ -86,8 +89,35 @@ def push_voices(self) -> None: def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: return self._controller.add_sample(reconstruction, name) - def add_instrument(self, name: str) -> Instrument: - return self._controller.add_instrument(new_instrument(name)) + def add_new_instrument(self, name: str) -> Instrument: + """Writes a fresh instrument into the pool, sustaining until its envelopes are edited.""" + return self.add_instrument(new_instrument(name)) + + def add_instrument(self, instrument: Instrument) -> Instrument: + """Takes a whole instrument voice into the pool, whichever route made it.""" + return self._controller.add_instrument(instrument) + + def read_instrument(self, filepath: Path) -> ImportedVoice: + """Reads a FamiTracker instrument file as a voice, leaving the pool as it stands. + + The file states the name the voice takes, and a file naming nothing leaves the voice + named after the file itself, so the list states where every voice came from. + + Args: + filepath: The ``.fti`` file the voice is read from. + + Returns: + ImportedVoice: The voice the file describes, beside what the file stated past it. + + Raises: + FileNotFoundError: If no file stands at ``filepath``. + LoadInstrumentError: If the file departs from the instrument layout. + """ + imported = instrument_to_voice(read_fti(filepath)) + if not imported.voice.name: + imported.voice.name = filepath.stem + + return imported def rename_voice(self, voice_id: str, name: str) -> None: self._controller.rename_voice(voice_id, name) diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 5a827b1a2..652ae2852 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -91,6 +91,7 @@ class ShortcutBindings: add_reconstruction_to_sequencer: Callback new_instrument: Callback add_sample_from_file: Callback + import_instrument: Callback open_reconstruction_in_explorer: Callback locate_original_audio: Callback play: Callback @@ -245,6 +246,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER: bindings.add_reconstruction_to_sequencer, ShortcutId.NEW_INSTRUMENT: bindings.new_instrument, ShortcutId.ADD_SAMPLE_FROM_FILE: bindings.add_sample_from_file, + ShortcutId.IMPORT_INSTRUMENT: bindings.import_instrument, ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER: bindings.open_reconstruction_in_explorer, ShortcutId.LOCATE_ORIGINAL_AUDIO: bindings.locate_original_audio, ShortcutId.PLAY: bindings.play, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 1ac2e82ca..996f1212d 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -500,6 +500,12 @@ Widget.DIALOG, "project_unsaved", ) +TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "instrument_imported", +) TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -572,6 +578,12 @@ Widget.MENU, "item_voice_add_sample", ) +TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_voice_import_instrument", +) TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 26cedacf1..93a55f317 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -60,6 +60,7 @@ TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS, TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE, TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, + TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT, TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT, TAG_GLOBAL_PANEL_PLAYER, TAG_GLOBAL_TEXT_MENU_FPS, @@ -404,10 +405,11 @@ def _create_instruments_export_menu(self, state: MenuBarViewModel) -> None: def _create_voice_menu(self, state: MenuBarViewModel) -> None: """Builds the Voice menu: the ways a voice comes in, then what the chosen one offers. - A voice is written by hand, converted from a recording, or brought in from the - reconstruction the Reconstructions tab holds, and the three stand together here so the bar - answers "how do I get a voice in" on its own. The chosen voice's actions are a - :class:`MenuSection` the voices panel builds, the same set its row menu prints. + A voice is written by hand, converted from a recording, brought from a tracker's own + instrument file, or taken from the reconstruction the Reconstructions tab holds, and the + four stand together here so the bar answers "how do I get a voice in" on its own. The + chosen voice's actions are a :class:`MenuSection` the voices panel builds, the same set + its row menu prints. """ with dpg.menu( label=self._label(MenuElements.GROUP_VOICE), @@ -426,6 +428,12 @@ def _create_voice_menu(self, state: MenuBarViewModel) -> None: label=self._voices_label(SequencerVoicesElements.ADD_SAMPLE), enabled=state.project_open, ) + self._shortcut_manager.add_menu_item( + ShortcutId.IMPORT_INSTRUMENT, + tag=TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT, + label=self._voices_label(SequencerVoicesElements.IMPORT_INSTRUMENT), + enabled=state.project_open, + ) self._shortcut_manager.add_menu_item( ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, tag=TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, @@ -665,6 +673,10 @@ def update(self, state: MenuBarViewModel) -> None: TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE, enabled=state.project_open, ) + dpg_configure_item( + TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT, + enabled=state.project_open, + ) dpg_configure_item( TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, enabled=state.add_to_sequencer_enabled, diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices.py index 9765e0e31..4ebb79714 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -136,6 +136,7 @@ def __init__( self.on_duplicate_requested: Optional[StringCallback] = None self.on_new_instrument_requested: Optional[VoidCallback] = None self.on_add_sample_requested: Optional[VoidCallback] = None + self.on_import_instrument_requested: Optional[VoidCallback] = None super().__init__( tag=TAG_SEQUENCER_VOICES_PANEL, @@ -739,10 +740,10 @@ def build_voice_actions(self) -> None: def add_pool_items(self) -> None: """Builds the ways a voice comes into the pool, in the order each menu prints them. - A voice is written by hand or converted from a recording, and both stand apart from the - actions a listed voice offers, since each answers with an entry the list did not hold. - Every door onto the list prints this section, so a reader reaches it from the list and - from a row alike. + A voice is written by hand, converted from a recording, or brought from a tracker, and + the three stand apart from the actions a listed voice offers, since each answers with an + entry the list did not hold. Every door onto the list prints this section, so a reader + reaches it from the list and from a row alike. """ dpg.add_menu_item( label=self._label( @@ -760,6 +761,14 @@ def add_pool_items(self) -> None: shortcut=self._shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE), callback=lambda: self.call(self.on_add_sample_requested), ) + dpg.add_menu_item( + label=self._label( + self._language_manager, + SequencerVoicesElements.IMPORT_INSTRUMENT, + ), + shortcut=self._shortcuts.display(ShortcutId.IMPORT_INSTRUMENT), + callback=lambda: self.call(self.on_import_instrument_requested), + ) def add_action_items(self, target: VoiceSelection) -> None: """Builds every action a sample offers, in the order each menu prints them. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 840de695f..afd04fd8d 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -68,6 +68,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: EXPORT_INSTRUMENTS_NSF = ("ExportInstrumentsNSF", ShortcutCategory.APPLICATION) NEW_INSTRUMENT = ("NewInstrument", ShortcutCategory.APPLICATION) ADD_SAMPLE_FROM_FILE = ("AddSampleFromFile", ShortcutCategory.APPLICATION) + IMPORT_INSTRUMENT = ("ImportInstrument", ShortcutCategory.APPLICATION) ADD_RECONSTRUCTION_TO_SEQUENCER = ("AddReconstructionToSequencer", ShortcutCategory.APPLICATION) OPEN_RECONSTRUCTION_IN_EXPLORER = ("OpenReconstructionInExplorer", ShortcutCategory.APPLICATION) LOCATE_ORIGINAL_AUDIO = ("LocateOriginalAudio", ShortcutCategory.APPLICATION) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 05eaaeafe..94e754d36 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -38,6 +38,7 @@ bindings: # voice NewInstrument: {combination: ~} AddSampleFromFile: {combination: ~} + ImportInstrument: {combination: ~} # playback Play: {combination: "Space"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index d61316fbf..36f6fcdaa 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -38,6 +38,7 @@ bindings: # voice NewInstrument: {combination: ~} AddSampleFromFile: {combination: ~} + ImportInstrument: {combination: ~} # playback Play: {combination: "Space"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 13eaa5614..269b7e5a4 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -601,6 +601,7 @@ sequencer.order.tooltip.label_master: "Click to mute every channel, or to bring sequencer.voices.label.voices_text: "Voices" sequencer.voices.label.new_instrument: "New instrument" sequencer.voices.label.add_sample: "Add sample from file..." +sequencer.voices.label.import_instrument: "Import instrument..." sequencer.voices.label.column_kind: "Kind" sequencer.voices.label.column_id: "ID" sequencer.voices.label.column_name: "Name" @@ -613,11 +614,20 @@ sequencer.voices.label.context_move_up: "Move up" sequencer.voices.label.context_move_down: "Move down" sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" +sequencer.voices.label.omission_pitch: "a pitch envelope" +sequencer.voices.label.omission_hi_pitch: "a hi-pitch envelope" +sequencer.voices.label.omission_release_point: "a release point" +sequencer.voices.label.omission_arpeggio_mode: "an arpeggio in fixed, relative or scheme mode" +sequencer.voices.label.omission_sequence_loop_points: "a repeat point per envelope, which the voice takes as one" sequencer.voices.tooltip.new_instrument: "Add an instrument written by hand, playable on any channel" sequencer.voices.tooltip.kind_sample: "Sample" sequencer.voices.tooltip.kind_instrument: "Instrument" +sequencer.voices.message.instrument_not_found: "The instrument file could not be found." sequencer.voices.title.add_sample_dialog: "Add sample" +sequencer.voices.title.import_instrument_dialog: "Import instrument" +sequencer.voices.title.instrument_imported: "Instrument imported" sequencer.voices.template.instrument_name: "Instrument {position}" +sequencer.voices.template.instrument_omissions: "\"{name}\" plays the volume, arpeggio and duty envelopes the file states.\nThe file also holds, on the instrument's own terms:" sequencer.history.label.history_text: "History" sequencer.history.label.undo: "Undo" @@ -859,6 +869,7 @@ settings.keybindings.label.export_instruments_nsf: "Export instruments to an NSF settings.keybindings.label.add_reconstruction_to_sequencer: "Add reconstruction to the sequencer" settings.keybindings.label.new_instrument: "New instrument" settings.keybindings.label.add_sample_from_file: "Add sample from file" +settings.keybindings.label.import_instrument: "Import a FamiTracker instrument" settings.keybindings.label.open_reconstruction_in_explorer: "Show reconstruction in the file manager" settings.keybindings.label.locate_original_audio: "Locate the original audio" settings.keybindings.label.play: "Play or pause" diff --git a/tests/unit/sampletones_application/categories/test_instrument.py b/tests/unit/sampletones_application/categories/test_instrument.py new file mode 100644 index 000000000..60ceb652f --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_instrument.py @@ -0,0 +1,74 @@ +import pytest + +from sampletones_application.categories.instrument import ( + OMISSION_BULLET, + OMISSION_ELEMENTS, + InstrumentImportMessages, +) +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.paths import LANG_EN +from sampletones_core.formats.famitracker.voice import InstrumentOmission + + +@pytest.fixture +def messages() -> InstrumentImportMessages: + return InstrumentImportMessages.build(LanguageManager(LANG_EN)) + + +class TestEveryDimensionIsNamed: + """A dimension a file states past the voice reaches the reader in words, never as an enum.""" + + def test_each_one_a_reader_can_meet_carries_words(self, messages: InstrumentImportMessages) -> None: + assert set(messages.omissions) == set(InstrumentOmission) + assert all(messages.omissions[omission] for omission in InstrumentOmission) + + def test_each_one_reads_differently_from_the_rest(self, messages: InstrumentImportMessages) -> None: + assert len(set(messages.omissions.values())) == len(InstrumentOmission) + + def test_the_map_answers_for_every_dimension(self) -> None: + assert set(OMISSION_ELEMENTS) == set(InstrumentOmission) + + +class TestTheNotice: + def test_a_file_stating_the_voice_alone_is_reported_nowhere( + self, + messages: InstrumentImportMessages, + ) -> None: + """An import that lost nothing has nothing to say, so no dialog interrupts it.""" + assert messages.notice("Lead", ()) is None + + def test_the_voice_is_named_in_the_opening(self, messages: InstrumentImportMessages) -> None: + notice = messages.notice("Lead", (InstrumentOmission.PITCH,)) + + assert notice is not None + assert "Lead" in notice.splitlines()[0] + + def test_each_dimension_is_listed_on_its_own_line(self, messages: InstrumentImportMessages) -> None: + stated = (InstrumentOmission.PITCH, InstrumentOmission.RELEASE_POINT) + notice = messages.notice("Lead", stated) + + assert notice is not None + assert [line for line in notice.splitlines() if line.startswith(OMISSION_BULLET)] == [ + f"{OMISSION_BULLET}{messages.omissions[omission]}" for omission in stated + ] + + def test_a_dimension_the_file_left_out_is_named_nowhere( + self, + messages: InstrumentImportMessages, + ) -> None: + notice = messages.notice("Lead", (InstrumentOmission.PITCH,)) + + assert notice is not None + assert messages.omissions[InstrumentOmission.RELEASE_POINT] not in notice + + def test_a_file_stating_everything_names_everything(self, messages: InstrumentImportMessages) -> None: + notice = messages.notice("Lead", tuple(InstrumentOmission)) + + assert notice is not None + assert all(words in notice for words in messages.omissions.values()) + + def test_the_wording_holds_no_placeholder_open(self, messages: InstrumentImportMessages) -> None: + notice = messages.notice("Lead", (InstrumentOmission.PITCH,)) + + assert notice is not None + assert "{" not in notice diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index c0790050c..e61a9f96b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -6,10 +6,12 @@ import pytest from sampletones_application.categories.hierarchy import Tab +from sampletones_application.categories.instrument import InstrumentImportMessages from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.playback import FollowMode from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.coordinators.playback.guard import GuardedPlayer +from sampletones_application.coordinators.tabs import sequencer as sequencer_module from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager @@ -62,8 +64,14 @@ HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.voice import ImportedVoice, InstrumentOmission from sampletones_core.project.song_position import SongPosition -from sampletones_shared.exceptions import InvalidReconstructionValuesError +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.exceptions import ( + InvalidReconstructionValuesError, + MalformedInstrumentError, +) from tests.suite.language import FakeLanguageManager FREQUENCY_MISMATCH_MESSAGE_KEY: Final[str] = "global.dialog.message.frequency_mismatch" @@ -101,6 +109,186 @@ def coordinator() -> SequencerTabCoordinator: return instance +INSTRUMENT_FILE: Final[Path] = Path("/instruments/Lead.fti") + +IMPORTED_VOICE: Final[Instrument] = Instrument( + name="Lead", + envelopes=InstrumentEnvelopes(volume=(15, 8, 0)), + loop_point=0, +) + + +def _imported(*omissions: InstrumentOmission) -> ImportedVoice: + return ImportedVoice(voice=IMPORTED_VOICE, omissions=omissions) + + +@pytest.fixture +def instrument_coordinator() -> SequencerTabCoordinator: + """A coordinator with only the collaborators ``import_instrument`` touches.""" + instance = object.__new__(SequencerTabCoordinator) + instance._history = MagicMock() + instance._history_detail = MagicMock() + instance._project_controller = MagicMock() + instance._project_controller.is_open = True + instance._sequencer_voices_logic = MagicMock() + instance._sequencer_voices_logic.read_instrument.return_value = _imported() + instance._session_manager = MagicMock() + instance._session_manager.get_instrument_path.return_value = INSTRUMENT_FILE.parent + instance._dialogs = MagicMock() + instance._language_manager = FakeLanguageManager(TEXTS) + instance._import_messages = InstrumentImportMessages.build(LanguageManager(LANG_EN)) + instance._msg_no_project = "no project" + instance._ttl_no_project = "No project open" + return instance + + +@pytest.fixture +def located_file(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, object]]: + """The file dialog, answering with an instrument file and recording how it was opened.""" + opened: List[Dict[str, object]] = [] + + def _open(**kwargs: object) -> Path: + opened.append(kwargs) + return INSTRUMENT_FILE + + monkeypatch.setattr(sequencer_module, "open_file_dialog", _open) + return opened + + +@pytest.fixture +def cancelled_dialog(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sequencer_module, "open_file_dialog", lambda **_kwargs: None) + + +class TestImportInstrument: + """A FamiTracker instrument file arrives as a voice, and says what it held past one.""" + + def test_a_project_is_asked_for_before_a_file_is( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + """A voice needs a pool to land in, so a closed project stops the gesture at the door.""" + instrument_coordinator._project_controller.is_open = False + + instrument_coordinator.import_instrument() + + assert located_file == [] + instrument_coordinator._dialogs.show_info.assert_called_once() + + def test_the_dialog_opens_where_the_last_instrument_was( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + instrument_coordinator.import_instrument() + + assert located_file[0]["initial_directory"] == INSTRUMENT_FILE.parent + + def test_the_folder_the_file_came_from_is_remembered( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + instrument_coordinator.import_instrument() + + instrument_coordinator._session_manager.set_instrument_path.assert_called_once_with(INSTRUMENT_FILE.parent) + + def test_a_cancelled_dialog_leaves_the_pool_as_it_stands( + self, + instrument_coordinator: SequencerTabCoordinator, + cancelled_dialog: None, + ) -> None: + instrument_coordinator.import_instrument() + + instrument_coordinator._sequencer_voices_logic.read_instrument.assert_not_called() + instrument_coordinator._sequencer_voices_logic.add_instrument.assert_not_called() + + def test_the_voice_the_file_made_joins_the_pool( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + instrument_coordinator.import_instrument() + + logic = instrument_coordinator._sequencer_voices_logic + logic.read_instrument.assert_called_once_with(INSTRUMENT_FILE) + logic.add_instrument.assert_called_once_with(IMPORTED_VOICE) + + def test_the_whole_gesture_is_one_history_entry( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + instrument_coordinator.import_instrument() + + action = instrument_coordinator._history.transaction.call_args.args[0] + assert action is HistoryAction.ADD_INSTRUMENT + instrument_coordinator._history_detail.add_instrument.assert_called_once_with(IMPORTED_VOICE.name) + + def test_what_the_file_held_past_the_voice_is_reported( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + logic = instrument_coordinator._sequencer_voices_logic + logic.read_instrument.return_value = _imported(InstrumentOmission.PITCH) + + instrument_coordinator.import_instrument() + + notice = instrument_coordinator._dialogs.show_info.call_args.args[1] + assert instrument_coordinator._import_messages.omissions[InstrumentOmission.PITCH] in notice + + def test_a_file_holding_the_voice_alone_is_reported_nowhere( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + """An import that lost nothing interrupts the reader with nothing.""" + instrument_coordinator.import_instrument() + + instrument_coordinator._dialogs.show_info.assert_not_called() + + def test_a_file_that_is_not_there_is_reported_as_missing( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + logic = instrument_coordinator._sequencer_voices_logic + logic.read_instrument.side_effect = FileNotFoundError(INSTRUMENT_FILE) + + instrument_coordinator.import_instrument() + + instrument_coordinator._dialogs.show_file_not_found.assert_called_once() + logic.add_instrument.assert_not_called() + + def test_a_file_the_reader_cannot_take_is_reported( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + logic = instrument_coordinator._sequencer_voices_logic + logic.read_instrument.side_effect = MalformedInstrumentError("truncated") + + instrument_coordinator.import_instrument() + + instrument_coordinator._dialogs.show_error.assert_called_once() + logic.add_instrument.assert_not_called() + + def test_a_file_the_reader_cannot_take_records_no_history( + self, + instrument_coordinator: SequencerTabCoordinator, + located_file: List[Dict[str, object]], + ) -> None: + """The file is read before the pool is touched, so a refusal leaves the project as it was.""" + logic = instrument_coordinator._sequencer_voices_logic + logic.read_instrument.side_effect = MalformedInstrumentError("truncated") + + instrument_coordinator.import_instrument() + + instrument_coordinator._history.transaction.assert_not_called() + + @pytest.fixture def samples_coordinator() -> SequencerTabCoordinator: """A coordinator with only the collaborators the samples-menu handlers touch.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 3a84ac6a8..8ac11415c 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -1,7 +1,9 @@ -from typing import Callable, Tuple +from pathlib import Path +from typing import Callable, Dict, Tuple from unittest.mock import MagicMock import numpy as np +import pytest from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager @@ -14,10 +16,19 @@ features_footprint, reconstruction_footprints, ) +from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.instruments import ( + STANDALONE_INSTRUMENT_INDEX, +) +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.voice import InstrumentOmission from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.exceptions import LoadInstrumentError from tests.suite.sequencer import sample_reconstruction @@ -324,7 +335,7 @@ def test_an_instrument_is_listed_beside_the_samples_that_were_added( ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="bass") - instrument = logic.add_instrument("lead") + instrument = logic.add_new_instrument("lead") entries = logic.build_voices().voices @@ -335,7 +346,7 @@ def test_an_instrument_is_listed_beside_the_samples_that_were_added( def test_an_instrument_is_measured_as_the_one_export_it_writes(self) -> None: controller, logic = _logic() - instrument = logic.add_instrument("lead") + instrument = logic.add_new_instrument("lead") controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12, 9)) footprint = logic.build_voice_footprint(instrument.id) @@ -352,7 +363,7 @@ def test_an_instrument_is_measured_as_the_one_export_it_writes(self) -> None: def test_an_instrument_previews_through_the_pulse_channel(self) -> None: controller, logic, session_manager, audio_device_manager = _logic_with_mocks() - instrument = logic.add_instrument("lead") + instrument = logic.add_new_instrument("lead") controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12)) logic.play_voice(instrument.id) @@ -367,3 +378,140 @@ def test_an_instrument_writing_nothing_sounds_no_preview(self) -> None: logic.play_voice(instrument.id) audio_device_manager.play.assert_not_called() + + +def _tracker_instrument( + name: str, + *sequences: InstrumentSequence, +) -> Instrument2A03: + """A 2A03 instrument as a ``.fti`` holds one: every sequence stated, most of them empty.""" + written: Dict[SequenceKind, InstrumentSequence] = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind} + for sequence in sequences: + written[sequence.kind] = sequence + + return Instrument2A03( + index=STANDALONE_INSTRUMENT_INDEX, + name=name, + sequences=written, + ) + + +def _instrument_file( + directory: Path, + filename: str, + name: str, + *sequences: InstrumentSequence, +) -> Path: + filepath = directory / filename + write_fti(filepath, _tracker_instrument(name, *sequences)) + return filepath + + +class TestReadingAnInstrumentFile: + """A ``.fti`` FamiTracker wrote arrives as a voice the pool holds like any other.""" + + def test_the_envelopes_the_file_states_reach_the_voice(self, tmp_path: Path) -> None: + filepath = _instrument_file( + tmp_path, + "Lead.fti", + "Lead", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 8, 0)), + InstrumentSequence(kind=SequenceKind.ARPEGGIO, items=(0, 3, 7)), + ) + _, logic = _logic() + + voice = logic.read_instrument(filepath).voice + + assert voice.envelopes.volume == (15, 8, 0) + assert voice.envelopes.arpeggio == (0, 3, 7) + + def test_the_file_names_the_voice(self, tmp_path: Path) -> None: + filepath = _instrument_file( + tmp_path, + "whatever.fti", + "Lead", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)), + ) + _, logic = _logic() + + assert logic.read_instrument(filepath).voice.name == "Lead" + + def test_a_file_naming_nothing_leaves_the_voice_named_after_it(self, tmp_path: Path) -> None: + """A nameless entry reads as nothing in the list, so the file it came from names it.""" + filepath = _instrument_file( + tmp_path, + "Bass Line.fti", + "", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)), + ) + _, logic = _logic() + + assert logic.read_instrument(filepath).voice.name == "Bass Line" + + def test_what_the_file_states_past_the_voice_comes_back_with_it(self, tmp_path: Path) -> None: + filepath = _instrument_file( + tmp_path, + "Lead.fti", + "Lead", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 8)), + InstrumentSequence(kind=SequenceKind.PITCH, items=(1, -1)), + ) + _, logic = _logic() + + assert InstrumentOmission.PITCH in logic.read_instrument(filepath).omissions + + def test_reading_leaves_the_pool_as_it_stands(self, tmp_path: Path) -> None: + """The pool is edited by the gesture that adds, so a read alone records no history entry.""" + filepath = _instrument_file( + tmp_path, + "Lead.fti", + "Lead", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)), + ) + controller, logic = _logic() + + logic.read_instrument(filepath) + + assert list(controller.project.voices) == [] + + def test_the_voice_the_file_made_joins_the_pool(self, tmp_path: Path) -> None: + filepath = _instrument_file( + tmp_path, + "Lead.fti", + "Lead", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)), + ) + controller, logic = _logic() + + voice = logic.add_instrument(logic.read_instrument(filepath).voice) + + assert [entry.voice_id for entry in logic.build_voices().voices] == [voice.id] + assert controller.project.voices.get(voice.id) is voice + + def test_a_file_of_another_kind_is_refused(self, tmp_path: Path) -> None: + filepath = tmp_path / "notes.fti" + filepath.write_bytes(b"not an instrument file at all") + _, logic = _logic() + + with pytest.raises(LoadInstrumentError): + logic.read_instrument(filepath) + + def test_a_file_that_ends_inside_its_own_layout_is_refused(self, tmp_path: Path) -> None: + whole = _instrument_file( + tmp_path, + "Lead.fti", + "Lead", + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 8, 0)), + ) + truncated = tmp_path / "Short.fti" + truncated.write_bytes(whole.read_bytes()[:12]) + _, logic = _logic() + + with pytest.raises(LoadInstrumentError): + logic.read_instrument(truncated) + + def test_a_file_that_is_not_there_is_reported_as_missing(self, tmp_path: Path) -> None: + _, logic = _logic() + + with pytest.raises(FileNotFoundError): + logic.read_instrument(tmp_path / "nowhere.fti") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index 1e4d6b3a0..44be436a3 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -145,6 +145,7 @@ def _panel( panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target)) panel.on_new_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.NEW_INSTRUMENT.value) panel.on_add_sample_requested = lambda: requests.pool.append(SequencerVoicesElements.ADD_SAMPLE.value) + panel.on_import_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.IMPORT_INSTRUMENT.value) monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) return VoicesPanelFixture(panel=panel, requests=requests) @@ -452,6 +453,7 @@ def test_the_list_menu_prints_the_ways_a_voice_comes_in( assert [widget.text for widget in build_recorder.widgets] == [ SequencerVoicesElements.NEW_INSTRUMENT.value, SequencerVoicesElements.ADD_SAMPLE.value, + SequencerVoicesElements.IMPORT_INSTRUMENT.value, ] def test_a_row_menu_carries_the_pool_section_below_the_voice_actions( @@ -464,12 +466,28 @@ def test_a_row_menu_carries_the_pool_section_below_the_voice_actions( items = [widget.text for widget in build_recorder.widgets if widget.kind == "item"] - assert items[-2:] == [ + assert items[-3:] == [ SequencerVoicesElements.NEW_INSTRUMENT.value, SequencerVoicesElements.ADD_SAMPLE.value, + SequencerVoicesElements.IMPORT_INSTRUMENT.value, ] assert SequencerVoicesElements.CONTEXT_EDIT.value in items + def test_each_item_prints_the_key_it_answers_to( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """Every way a voice comes in is rebindable, so each item names the press that fires it.""" + shortcuts = shipped_source() + _panel(monkeypatch).panel.add_pool_items() + + assert [item.shortcut for item in recorder.items] == [ + shortcuts.display(ShortcutId.NEW_INSTRUMENT), + shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE), + shortcuts.display(ShortcutId.IMPORT_INSTRUMENT), + ] + def test_the_items_ask_for_a_written_voice_and_for_a_located_one( self, monkeypatch: pytest.MonkeyPatch, @@ -484,6 +502,7 @@ def test_the_items_ask_for_a_written_voice_and_for_a_located_one( assert fixture.requests.pool == [ SequencerVoicesElements.NEW_INSTRUMENT.value, SequencerVoicesElements.ADD_SAMPLE.value, + SequencerVoicesElements.IMPORT_INSTRUMENT.value, ] diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 91bf4dcc5..5da7968d6 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -604,6 +604,7 @@ def test_the_ways_a_voice_comes_in_are_named_together( assert shortcuts.labels == [ "New instrument", "Add sample from file...", + "Import instrument...", "Add to Sequencer", ] @@ -617,6 +618,7 @@ def test_each_way_carries_its_own_action( assert [item["shortcut_id"] for item in shortcuts.items] == [ ShortcutId.NEW_INSTRUMENT, ShortcutId.ADD_SAMPLE_FROM_FILE, + ShortcutId.IMPORT_INSTRUMENT, ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, ] @@ -629,6 +631,7 @@ def test_the_pool_waits_for_a_project_to_hold_a_voice( assert shortcuts.item("New instrument")["enabled"] is False assert shortcuts.item("Add sample from file...")["enabled"] is False + assert shortcuts.item("Import instrument...")["enabled"] is False def test_bringing_the_open_reconstruction_in_waits_for_one_to_be_open( self, From 9dab24e0410e5291a0611801226a80410832bcec Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 20:19:50 +0200 Subject: [PATCH 092/142] Refactored: one path for exporting an instrument --- src/sampletones_application/application.py | 16 +- .../categories/instrument.py | 9 +- .../coordinators/export/__init__.py | 9 + .../coordinators/export/instrument.py | 90 +++++++ .../coordinators/tabs/reconstruction.py | 66 ++--- .../logic/export/instrument.py | 166 ++++++++++++ .../logic/reconstruction/reconstruction.py | 103 ++----- src/sampletones_core/exporters/slices.py | 96 +++++-- src/sampletones_core/exports/request.py | 46 ++++ .../coordinators/export/__init__.py | 0 .../coordinators/export/test_instrument.py | 146 ++++++++++ .../logic/export/test_instrument.py | 255 ++++++++++++++++++ .../reconstruction/test_reconstruction.py | 141 +++------- .../sampletones_core/exporters/test_slices.py | 72 +++++ 14 files changed, 955 insertions(+), 260 deletions(-) create mode 100644 src/sampletones_application/coordinators/export/__init__.py create mode 100644 src/sampletones_application/coordinators/export/instrument.py create mode 100644 src/sampletones_application/logic/export/instrument.py create mode 100644 tests/unit/sampletones_application/coordinators/export/__init__.py create mode 100644 tests/unit/sampletones_application/coordinators/export/test_instrument.py create mode 100644 tests/unit/sampletones_application/logic/export/test_instrument.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 953b0bf70..877140b18 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -16,7 +16,10 @@ from sampletones_application.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator from sampletones_application.coordinators.edit.router import EditRouter -from sampletones_application.coordinators.export import SongExportCoordinator +from sampletones_application.coordinators.export import ( + InstrumentExportCoordinator, + SongExportCoordinator, +) from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -37,6 +40,7 @@ from sampletones_application.exports import build_export_backends from sampletones_application.layout import LayoutConfig, load_layout_config from sampletones_application.logic.export import SongExportLogic +from sampletones_application.logic.export.instrument import InstrumentExportLogic from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.instruction.library_manager import ( @@ -415,6 +419,15 @@ def __init__( language_manager=self.language_manager, ) + self._instrument_exports = InstrumentExportCoordinator( + InstrumentExportLogic( + self.session_manager, + self.export_service, + self.export_backends, + ), + self.language_manager, + ) + self._reconstructions_tab = ReconstructionTabCoordinator( config_manager=self.config_manager, session_manager=self.session_manager, @@ -430,6 +443,7 @@ def __init__( on_reconstruction_instrument_updated=self._regenerate_instrument, on_reconstruction_stem_removed=self._reconstruction_coordinator.apply_edit, original_audio_locator=self._original_audio_locator, + instrument_exports=self._instrument_exports, layout=ReconstructionTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py index 1a328d577..b7decd6fb 100644 --- a/src/sampletones_application/categories/instrument.py +++ b/src/sampletones_application/categories/instrument.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from typing import Dict, Final, Optional, Self, Tuple -from sampletones_application.categories.elements.sequencer import SequencerVoicesElements +from sampletones_application.categories.elements.sequencer import ( + SequencerVoicesElements, +) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_core.formats.famitracker.voice import InstrumentOmission @@ -62,7 +64,10 @@ def build(cls, language_manager: LanguageManager) -> Self: title=language_manager["sequencer.voices.title.instrument_imported"], template=language_manager["sequencer.voices.template.instrument_omissions"], omissions={ - omission: omission_label(language_manager, element) + omission: omission_label( + language_manager, + element, + ) for omission, element in OMISSION_ELEMENTS.items() }, ) diff --git a/src/sampletones_application/coordinators/export/__init__.py b/src/sampletones_application/coordinators/export/__init__.py new file mode 100644 index 000000000..a9f863fdd --- /dev/null +++ b/src/sampletones_application/coordinators/export/__init__.py @@ -0,0 +1,9 @@ +from sampletones_application.coordinators.export.instrument import ( + InstrumentExportCoordinator, +) +from sampletones_application.coordinators.export.song import SongExportCoordinator + +__all__ = [ + "InstrumentExportCoordinator", + "SongExportCoordinator", +] diff --git a/src/sampletones_application/coordinators/export/instrument.py b/src/sampletones_application/coordinators/export/instrument.py new file mode 100644 index 000000000..9b68293a8 --- /dev/null +++ b/src/sampletones_application/coordinators/export/instrument.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Dict, Tuple + +from sampletones_application.categories.elements.global_ import FileFilterElements +from sampletones_application.categories.exports import ( + EXPORT_INSTRUMENT_FILTERS, + INSTRUMENT_EXPORT_FORMATS, +) +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.export.instrument import InstrumentExportLogic +from sampletones_application.utils.file_dialogs.api import save_file_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.exports.scope import ExportScope + + +class InstrumentExportCoordinator: + """The screen one instrument's export is asked for on, wherever the request came from. + + Every surface offering an instrument export — the Reconstructions tab's channel buttons, the + sequencer's voice menu — raises the same dialog here, so an instrument is written the same way + whichever of them asked. All three formats that write a single instrument are offered at once, + which leaves choosing one to the dialog's own type selector rather than to the menu that + reached it. + """ + + def __init__( + self, + export_logic: InstrumentExportLogic, + language_manager: LanguageManager, + ) -> None: + self._logic = export_logic + self._title = language_manager["reconstructions.instruments.title.export_instrument_dialog"] + self._filter_names: Dict[ExportFormat, str] = { + export_format: self._filter_name(language_manager, element) + for export_format, element in EXPORT_INSTRUMENT_FILTERS.items() + } + + def request(self, source: InstrumentSource, suggested_name: str) -> None: + """Asks where one instrument goes, then writes it there. + + The dialog opens on the folder the last instrument landed in and suggests the instrument's + own name, leaving the format to the type selector and to any extension typed over it. + + Args: + source: The instrument to write, awaiting the name its destination gives it. + suggested_name: The name the dialog opens with, which the instrument keeps unless the + reader renames the file. + """ + destination = save_file_dialog( + title=self._title, + initial_directory=self._logic.suggested_directory, + default_filename=suggested_name, + filters=self._filters(), + ) + + self._write(destination, source) + + @ignore_none_path + def _write(self, destination: Path, source: InstrumentSource) -> None: + self._logic.export(destination, source) + + def _filters(self) -> Tuple[FileFilter, ...]: + """The types a destination may be given, one per format writing a single instrument. + + Naming each format's own type puts the programs an export can reach into the dialog's type + selector, so the one picked there names the format the instrument is written in. + """ + return tuple(self._filter(export_format) for export_format in INSTRUMENT_EXPORT_FORMATS) + + def _filter(self, export_format: ExportFormat) -> FileFilter: + return FileFilter.for_extensions( + self._filter_names[export_format], + [self._logic.backends[export_format].extension(ExportScope.INSTRUMENT)], + ) + + @staticmethod + def _filter_name( + language_manager: LanguageManager, + element: FileFilterElements, + ) -> str: + return language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + element, + ] diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 10f142d15..50b0817fb 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,18 +1,18 @@ from functools import partial from pathlib import Path -from typing import Callable, Dict, Optional, Sequence, Tuple +from typing import Callable, Dict, Optional, Sequence import dearpygui.dearpygui as dpg from sampletones_application.categories.export import ExportMessages -from sampletones_application.categories.exports import ( - EXPORT_INSTRUMENT_FILTERS, - INSTRUMENT_EXPORT_FORMATS, -) +from sampletones_application.categories.exports import EXPORT_INSTRUMENT_FILTERS from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager +from sampletones_application.coordinators.export.instrument import ( + InstrumentExportCoordinator, +) from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -129,6 +129,7 @@ def __init__( on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, on_reconstruction_stem_removed: Callable[[StemRemoval], None], original_audio_locator: OriginalAudioLocator, + instrument_exports: InstrumentExportCoordinator, *, layout: ReconstructionTabParameters, language_manager: LanguageManager, @@ -143,6 +144,7 @@ def __init__( ) self._session_manager = session_manager self._export_backends = export_backends + self._instrument_exports = instrument_exports self._dialogs = dialogs self._original_audio_locator = original_audio_locator self._on_reconstruction_stem_removed = on_reconstruction_stem_removed @@ -266,7 +268,6 @@ def __init__( self._reconstruction_panel_logic.on_waveform_source_changed = ( self._reconstruction_plot_panel.set_waveform_top_source ) - self._reconstruction_panel_logic.on_open_export_instrument_dialog = self._open_export_instrument_dialog self._reconstruction_panel_logic.on_open_export_instruments_dialog = self._open_export_instruments_dialog self._reconstruction_panel_logic.on_open_export_wav_dialog = self._open_export_wav_dialog self._reconstruction_panel_logic.on_locate_audio_not_found = lambda path: dialogs.show_file_not_found( @@ -284,9 +285,7 @@ def __init__( on_reconstruction_instrument_updated ) - self._reconstruction_instruments_panel.on_instrument_export = ( - self._reconstruction_panel_logic.request_export_instrument_dialog - ) + self._reconstruction_instruments_panel.on_instrument_export = self._export_instrument self._reconstruction_instruments_panel.on_reconstruction_instrument_hovered = ( self._reconstruction_plot_panel.set_overlay ) @@ -393,49 +392,16 @@ def _update_reconstruction_view( self._reconstruction_audio_panel.update_view(view_model) self._reconstruction_plot_panel.update_view(view_model) - def _open_export_instrument_dialog( - self, - default_filename: str, - default_path: str, - channel_name: ChannelName, - ) -> None: - """Prompts for the file the ``channel_name`` slice is written to. + def _export_instrument(self, channel_name: ChannelName) -> None: + """Writes the instrument the tab's ``channel_name`` tab holds, wherever it is asked for. - Every format that writes a single slice is offered at once, so the type picked in the - dialog names the format the slice is written in. + An instrument reaches a file the same way whichever surface asked for it, so the whole + gesture from here on belongs to the shared exporter; what the tab contributes is which + instrument it has in front of it. """ - filepath = save_file_dialog( - title=self._language_manager["reconstructions.instruments.title.export_instrument_dialog"], - initial_directory=default_path, - default_filename=default_filename, - filters=self._instrument_filters(), - ) - self._handle_export_instrument(filepath, channel_name) - - def _instrument_filters(self) -> Tuple[FileFilter, ...]: - """The types a destination for one slice may be given, one per format offered. - - Naming each format's own type puts the programs an export can reach in the dialog's - type selector, so the one that is picked there names the format. - """ - return tuple( - self._export_filter( - export_format, - ExportScope.INSTRUMENT, - ) - for export_format in INSTRUMENT_EXPORT_FORMATS - ) - - @ignore_none_path - def _handle_export_instrument( - self, - filepath: Path, - channel_name: ChannelName, - ) -> None: - self._reconstruction_panel_logic.handle_export_instrument_confirmed( - filepath, - channel_name, - ) + exportable = self._reconstruction_panel_logic.exportable_instrument(channel_name) + if exportable is not None: + self._instrument_exports.request(exportable.source, exportable.name) def _open_export_instruments_dialog( self, diff --git a/src/sampletones_application/logic/export/instrument.py b/src/sampletones_application/logic/export/instrument.py new file mode 100644 index 000000000..ebe91a4c1 --- /dev/null +++ b/src/sampletones_application/logic/export/instrument.py @@ -0,0 +1,166 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Mapping, Protocol, Tuple + +from sampletones_application.config.managers.session import SessionManager +from sampletones_core.exporters.slices import ( + FIRST_INSTRUMENT_INDEX, + InstrumentEntry, + voice_instrument_entries, +) +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.extensions import format_for_extension +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentExport, InstrumentSource +from sampletones_core.exports.scope import ExportScope +from sampletones_core.project.project import Project +from sampletones_core.project.tuning import tuning_from_project +from sampletones_core.project.voices.voice import VoiceUnion + + +@dataclass(frozen=True) +class ExportableInstrument: + """One instrument ready to be asked for a destination. + + Attributes: + name: The name the save dialog suggests, which the written instrument keeps unless the + reader renames the file. + source: The instrument itself, awaiting the name its destination gives it. + """ + + name: str + source: InstrumentSource + + +def voice_instruments(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]: + """The instruments one voice offers to an export. + + A module writing every voice and a file writing one read the same rule, so a reader is offered + exactly the instruments a module would have held: a sample yields one per channel its + reconstruction found frames for, and a voice written by hand yields the single set of envelopes + every channel reads. + + Args: + voice: The voice whose instruments are offered. + + Returns: + Tuple[InstrumentEntry, ...]: Its instruments, in channel order. + """ + return tuple(voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX)) + + +def voice_instrument(project: Project, entry: InstrumentEntry) -> ExportableInstrument: + """One of a voice's instruments, ready to be given a destination. + + Args: + project: The project the voice belongs to, which states the rate and the tuning. + entry: The instrument being written. + + Returns: + ExportableInstrument: The instrument and the name to suggest for it. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + return ExportableInstrument(name=entry.name, source=instrument_source(project, entry)) + + +def instrument_source(project: Project, entry: InstrumentEntry) -> InstrumentSource: + """One of a voice's instruments, measured at the rate and tuning its project plays it at. + + Args: + project: The project the voice belongs to, which states the rate and the tuning. + entry: The instrument being written. + + Returns: + InstrumentSource: The instrument, awaiting the name its destination gives it. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + return InstrumentSource( + channel=entry.export_channel, + features=entry.features, + loop_point=entry.loop_point, + nes_frequency=project.settings.nes_frequency, + tuning=tuning_from_project(project), + ) + + +class InstrumentExportServiceProtocol(Protocol): + """The slice of the export service one instrument's export drives. + + Typing the collaborator structurally keeps the logic layer independent of the service + implementation; the composition root supplies the real service. + """ + + def export_instrument( + self, + destination: Path, + backend: ExportBackend, + request: InstrumentExport, + ) -> None: ... + + +class InstrumentExportLogic: + """The one way an instrument reaches a file, whichever surface asked for one. + + An instrument export is a set of envelopes, the channel they are read for and the rate they + advance at. What produced them — a channel of the open reconstruction, a channel of a project + sample, or a voice written by hand — is settled before anything here, so every surface answers + with an :class:`InstrumentSource` and reaches the same write. + + The destination carries the last two decisions: its extension names the format, and its stem + names the instrument the file holds, so renaming a file in the save dialog renames what is + written into it. + """ + + def __init__( + self, + session_manager: SessionManager, + export_service: InstrumentExportServiceProtocol, + export_backends: Dict[ExportFormat, ExportBackend], + ) -> None: + self._session_manager = session_manager + self._export_service = export_service + self._export_backends = export_backends + + @property + def backends(self) -> Mapping[ExportFormat, ExportBackend]: + """Every backend an instrument can be written through, keyed by its format.""" + return self._export_backends + + @property + def suggested_directory(self) -> Path: + """The folder the save dialog opens on, which is where the last instrument landed.""" + return self._session_manager.get_instrument_path() + + def export(self, destination: Path, source: InstrumentSource) -> None: + """Writes one instrument to ``destination``, in the format its extension names. + + Args: + destination: The file the save dialog was confirmed with. + source: The instrument to write, awaiting the name the destination gives it. + + Raises: + ValueError: If no format writing a single instrument claims the destination's + extension, which a dialog offering those formats alone never yields. + """ + export_format = self._format(destination) + self._session_manager.set_instrument_path(destination.parent) + self._export_service.export_instrument( + destination, + self._export_backends[export_format], + source.named(destination.stem), + ) + + def _format(self, destination: Path) -> ExportFormat: + export_format = format_for_extension( + self._export_backends, + ExportScope.INSTRUMENT, + destination.suffix, + ) + if export_format is None: + raise ValueError(f"No export format writes '{destination.suffix}' for an instrument export") + + return export_format diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 76f5fe25f..1ba340e72 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -4,6 +4,7 @@ import numpy as np from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.instrument import ExportableInstrument from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.view_model.reconstruction.paths.path import ( @@ -29,9 +30,12 @@ from sampletones_core.exporters.feature import Features from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.exports.backend import ExportBackend -from sampletones_core.exports.extensions import format_for_extension from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.request import ( + InstrumentExport, + InstrumentSource, + SampleExport, +) from sampletones_core.exports.scope import ExportScope from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_shared.logger import logger @@ -108,7 +112,6 @@ def __init__( self.on_waveform_cleared: Optional[VoidCallback] = None self.on_waveform_source_changed: Optional[Callable[[AudioSourceType], None]] = None - self.on_open_export_instrument_dialog: Optional[Callable[[str, str, ChannelName], None]] = None self.on_open_export_instruments_dialog: Optional[Callable[[str, str, ExportFormat], None]] = None self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None @@ -367,35 +370,41 @@ def _build_stems_view_model( channel_cap=stems_data.config.channel_cap, ) - def request_export_instrument_dialog( + def exportable_instrument( self, channel_name: ChannelName, - ) -> None: - """Asks for the destination one channel slice is written to. + ) -> Optional[ExportableInstrument]: + """The loaded reconstruction's ``channel_name`` slice, ready to be given a destination. - Every format able to write a single slice is offered at once, so the channel travels - with the request to the dialog and back. The suggestion is the instrument's name on its - own, leaving the format to the dialog's file-type selector and to any extension typed - over it. + A reconstruction has no loop flag of its own — that belongs to a sample placed in a + project — so the instrument plays its envelopes once. Args: channel_name: The channel whose slice is written. + + Returns: + Optional[ExportableInstrument]: The slice and the name to suggest for it, or ``None`` + where that channel describes no frame and is written nowhere. + + Raises: + AssertionError: If no reconstruction is loaded. """ reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") if channel_name not in reconstruction_data.reconstruction.playing_channels: - return - - instrument_name = self._get_instrument_name(channel_name) - default_path = str(self._session_manager.get_instrument_path()) + return None - self.call( - self.on_open_export_instrument_dialog, - instrument_name, - default_path, - channel_name, + return ExportableInstrument( + name=self._get_instrument_name(channel_name), + source=InstrumentSource( + channel=channel_name, + features=reconstruction_data.feature_data[channel_name], + loop_point=None, + nes_frequency=self._nes_frequency(), + tuning=self._tuning(), + ), ) def request_export_instruments_dialog( @@ -434,36 +443,6 @@ def request_export_wav_dialog(self) -> None: self.call(self.on_open_export_wav_dialog, default_filename, default_path) - def handle_export_instrument_confirmed( - self, - filepath: Path, - channel_name: ChannelName, - ) -> None: - """Writes the ``channel_name`` slice of the loaded reconstruction to ``filepath``. - - The extension picks the format the slice is written in, and the instrument carries - the name the destination was saved under, so renaming the file in the dialog renames - the instrument the file carries. - - Args: - filepath: The destination the dialog was confirmed with. - channel_name: The channel whose slice is written. - """ - reconstruction_data = self._reconstruction_data - if not reconstruction_data: - logger.warning("No reconstruction data available for instrument export") - return - - export_format = self._export_format(filepath, ExportScope.INSTRUMENT) - feature = reconstruction_data.feature_data[channel_name] - - self._session_manager.set_instrument_path(filepath.parent) - self._export_service.export_instrument( - filepath, - self._export_backends[export_format], - self._instrument_export(channel_name, feature, filepath.stem), - ) - def handle_export_instruments_confirmed( self, destination: Path, @@ -507,32 +486,6 @@ def handle_export_instruments_confirmed( request, ) - def _export_format( - self, - destination: Path, - scope: ExportScope, - ) -> ExportFormat: - """Reads the export format out of the destination's extension. - - A save dialog answers with one of the extensions it offered, and an export offers the - types its own formats write, so every destination reaching here names a format. - - Args: - destination: The destination the export was confirmed with. - scope: The scope about to be written. - - Returns: - ExportFormat: The format to write in. - - Raises: - ValueError: If no format able to express ``scope`` claims the extension. - """ - export_format = format_for_extension(self._export_backends, scope, destination.suffix) - if export_format is None: - raise ValueError(f"No export format writes '{destination.suffix}' for a {scope} export") - - return export_format - def _instrument_export( self, channel_name: ChannelName, diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 09461b201..2cdcb9c23 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, Iterator, Optional, Tuple +from typing import Dict, Final, Iterator, Optional, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features @@ -22,6 +22,8 @@ class InstrumentSlot: InstrumentTable = Dict[Tuple[str, ChannelName], InstrumentSlot] +FIRST_INSTRUMENT_INDEX: Final[int] = 0 + @dataclass(frozen=True) class VoiceSlice: @@ -73,6 +75,23 @@ class InstrumentEntry: loop_point: Optional[int] slots: Dict[ChannelName, InstrumentSlot] + @property + def export_channel(self) -> ChannelName: + """The channel a backend sounding this instrument on its own plays it through. + + A slice carries the channel it was reconstructed for, and a hand-written voice carries one + set of envelopes every channel reads, so the first channel it answers for — in channel + order — is the one a file holding this instrument alone is sounded on. + + Raises: + ValueError: If the instrument answers for no channel at all. + """ + for channel in ChannelName.items(): + if channel in self.slots: + return channel + + raise ValueError(f"Instrument '{self.name}' answers for no channel to be sounded on") + def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: """Walks what every channel of every voice plays, in voice order then channel order. @@ -100,48 +119,69 @@ def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: yield VoiceSlice(voice=voice, channel=channel, features=voice.features(channel)) -def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]: - """Walks the instruments an export writes, numbered in voice order then channel order. +def voice_instrument_entries( + voice: VoiceUnion, + *, + start_index: int, +) -> Iterator[InstrumentEntry]: + """The instruments an export writes for one voice, numbered from ``start_index``. + + This is the whole rule for what a voice contributes to an export, so a module writing every + voice and a file writing one read the same thing: a reader is offered exactly the instruments + a module would have held. Args: - project: The project whose voices are exported. + voice: The voice being exported. + start_index: The slot the first of its instruments is numbered under. Yields: InstrumentEntry: Each instrument alongside the channels whose rows reach it. """ - index = 0 - for voice in project.voices: - match voice: - case Sample(): - features_by_channel = voice.reconstruction.export() - for channel in ChannelName.items(): - features = features_by_channel[channel] - if not features.has_frames: - continue - - yield InstrumentEntry( - index=index, - voice_id=voice.id, - name=instrument_slice_name(voice.name, channel), - features=features, - loop_point=voice.loop_point, - slots={channel: InstrumentSlot(index=index, initial_pitch=features.initial_pitch)}, - ) - index += 1 - case Instrument(): - channels = voice_channels(voice) - if not channels: + match voice: + case Sample(): + features_by_channel = voice.reconstruction.export() + index = start_index + for channel in ChannelName.items(): + features = features_by_channel[channel] + if not features.has_frames: continue yield InstrumentEntry( index=index, voice_id=voice.id, + name=instrument_slice_name(voice.name, channel), + features=features, + loop_point=voice.loop_point, + slots={channel: InstrumentSlot(index=index, initial_pitch=features.initial_pitch)}, + ) + index += 1 + case Instrument(): + channels = voice_channels(voice) + if channels: + yield InstrumentEntry( + index=start_index, + voice_id=voice.id, name=voice.name, features=voice.instrument_features(), loop_point=voice.loop_point, slots={ - channel: InstrumentSlot(index=index, initial_pitch=voice.reference(channel)) + channel: InstrumentSlot(index=start_index, initial_pitch=voice.reference(channel)) for channel in channels }, ) - index += 1 + + +def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]: + """Walks the instruments an export writes, numbered in voice order then channel order. + + Args: + project: The project whose voices are exported. + + Yields: + InstrumentEntry: Each instrument alongside the channels whose rows reach it. + """ + index = FIRST_INSTRUMENT_INDEX + for voice in project.voices: + for entry in voice_instrument_entries(voice, start_index=index): + yield entry + index += 1 diff --git a/src/sampletones_core/exports/request.py b/src/sampletones_core/exports/request.py index df7f11aa2..88b510886 100644 --- a/src/sampletones_core/exports/request.py +++ b/src/sampletones_core/exports/request.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from typing import Optional, Tuple @@ -29,6 +31,50 @@ class InstrumentExport: tuning: Tuning +@dataclass(frozen=True) +class InstrumentSource: + """One instrument ready to be written, awaiting the name its destination gives it. + + An instrument reaches a file the same way whatever produced it — a channel of the open + reconstruction, a channel of a project sample, or a voice written by hand — so each of those + answers with this, and one path carries it the rest of the way. The name is left out because + the destination states it: whoever saves the file names the instrument the file carries. + + Attributes: + channel: The NES channel the envelopes are read for, which a backend sounding them on + its own plays them through. + features: The per-dimension envelopes describing the instrument. + loop_point: The tick the instrument repeats from while its note is held, or ``None`` + where it plays its envelopes once. + nes_frequency: Rate in Hz the envelopes advance at, one item per tick. + tuning: Where concert pitch sits for the envelopes. + """ + + channel: ChannelName + features: Features + loop_point: Optional[int] + nes_frequency: int + tuning: Tuning + + def named(self, name: str) -> InstrumentExport: + """The request a backend writes, under the name its destination gave it. + + Args: + name: The name the written instrument carries. + + Returns: + InstrumentExport: The instrument, ready for a backend. + """ + return InstrumentExport( + name=name, + channel=self.channel, + features=self.features, + loop_point=self.loop_point, + nes_frequency=self.nes_frequency, + tuning=self.tuning, + ) + + @dataclass(frozen=True) class SampleExport: """Every channel slice of one reconstruction. diff --git a/tests/unit/sampletones_application/coordinators/export/__init__.py b/tests/unit/sampletones_application/coordinators/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py new file mode 100644 index 000000000..36de45f03 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -0,0 +1,146 @@ +from pathlib import Path +from typing import Dict, Final, List, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.categories.exports import INSTRUMENT_EXPORT_FORMATS +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.export import instrument as instrument_module +from sampletones_application.coordinators.export.instrument import ( + InstrumentExportCoordinator, +) +from sampletones_application.exports import build_export_backends +from sampletones_application.logic.export.instrument import ( + voice_instrument, + voice_instruments, +) +from sampletones_application.paths import LANG_EN +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.exports.scope import ExportScope +from sampletones_core.project.project import Project +from sampletones_core.project.voices.creation import new_instrument + +REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments") +SUGGESTED_NAME: Final[str] = "Lead" +DESTINATION: Final[Path] = REMEMBERED_DIRECTORY / "Lead.fti" + + +def _source() -> InstrumentSource: + voice = new_instrument(SUGGESTED_NAME) + project = Project.create() + project.voices.append(voice) + return voice_instrument(project, voice_instruments(voice)[0]).source + + +@pytest.fixture +def logic() -> MagicMock: + mock = MagicMock() + mock.suggested_directory = REMEMBERED_DIRECTORY + mock.backends = build_export_backends() + return mock + + +@pytest.fixture +def coordinator(logic: MagicMock) -> InstrumentExportCoordinator: + return InstrumentExportCoordinator(logic, LanguageManager(LANG_EN)) + + +@pytest.fixture +def confirmed(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, object]]: + """The save dialog, answering with a destination and recording how it was opened.""" + opened: List[Dict[str, object]] = [] + + def _save(**kwargs: object) -> Path: + opened.append(kwargs) + return DESTINATION + + monkeypatch.setattr(instrument_module, "save_file_dialog", _save) + return opened + + +@pytest.fixture +def cancelled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(instrument_module, "save_file_dialog", lambda **_kwargs: None) + + +class TestAskingWhereAnInstrumentGoes: + def test_the_dialog_opens_where_the_last_instrument_landed( + self, + coordinator: InstrumentExportCoordinator, + confirmed: List[Dict[str, object]], + ) -> None: + coordinator.request(_source(), SUGGESTED_NAME) + + assert confirmed[0]["initial_directory"] == REMEMBERED_DIRECTORY + + def test_the_instrument_names_the_file_it_is_offered_as( + self, + coordinator: InstrumentExportCoordinator, + confirmed: List[Dict[str, object]], + ) -> None: + coordinator.request(_source(), SUGGESTED_NAME) + + assert confirmed[0]["default_filename"] == SUGGESTED_NAME + + def test_every_format_writing_one_instrument_is_offered_at_once( + self, + coordinator: InstrumentExportCoordinator, + confirmed: List[Dict[str, object]], + ) -> None: + """Choosing the format is the dialog's own type selector, not the menu that reached it.""" + coordinator.request(_source(), SUGGESTED_NAME) + + filters = confirmed[0]["filters"] + assert isinstance(filters, tuple) + assert len(filters) == len(INSTRUMENT_EXPORT_FORMATS) + + def test_each_type_carries_the_extension_its_format_writes( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + coordinator.request(_source(), SUGGESTED_NAME) + + filters = confirmed[0]["filters"] + assert isinstance(filters, tuple) + offered: Tuple[str, ...] = tuple(pattern for file_filter in filters for pattern in file_filter.patterns) + for export_format in INSTRUMENT_EXPORT_FORMATS: + extension = logic.backends[export_format].extension(ExportScope.INSTRUMENT) + assert any(pattern.endswith(extension) for pattern in offered) + + def test_each_type_is_named_after_the_program_that_reads_it( + self, + coordinator: InstrumentExportCoordinator, + confirmed: List[Dict[str, object]], + ) -> None: + coordinator.request(_source(), SUGGESTED_NAME) + + filters = confirmed[0]["filters"] + assert isinstance(filters, tuple) + assert len({file_filter.name for file_filter in filters}) == len(INSTRUMENT_EXPORT_FORMATS) + + +class TestWritingWhatWasConfirmed: + def test_the_destination_reaches_the_write( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + source = _source() + + coordinator.request(source, SUGGESTED_NAME) + + logic.export.assert_called_once_with(DESTINATION, source) + + def test_a_cancelled_dialog_writes_nothing( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + cancelled: None, + ) -> None: + coordinator.request(_source(), SUGGESTED_NAME) + + logic.export.assert_not_called() diff --git a/tests/unit/sampletones_application/logic/export/test_instrument.py b/tests/unit/sampletones_application/logic/export/test_instrument.py new file mode 100644 index 000000000..f243966c3 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/test_instrument.py @@ -0,0 +1,255 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Final, List +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.exports import build_export_backends +from sampletones_application.logic.export.instrument import ( + InstrumentExportLogic, + voice_instrument, + voice_instruments, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.project.project import Project +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.music import Tuning +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, + EXT_FILE_NSF, +) +from tests.suite.sequencer import sample_reconstruction + +NO_EXTENSION: Final[str] = "" +REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments") + + +@dataclass(frozen=True) +class FormatCase: + extension: str + export_format: ExportFormat + + +FORMAT_CASES: Final[List[FormatCase]] = [ + FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER), + FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE), + FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET), + FormatCase(extension=EXT_FILE_NSF, export_format=ExportFormat.NSF), +] + +UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] + + +@pytest.fixture +def session_manager() -> MagicMock: + mock = MagicMock() + mock.get_instrument_path.return_value = REMEMBERED_DIRECTORY + return mock + + +@pytest.fixture +def export_service() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def export_backends() -> Dict[ExportFormat, MagicMock]: + """Stands in for the real backends while declaring the scopes and extensions they do.""" + backends: Dict[ExportFormat, MagicMock] = {} + for export_format, backend in build_export_backends().items(): + stub = MagicMock() + stub.supported_scopes = backend.supported_scopes + stub.extension.side_effect = backend.extension + backends[export_format] = stub + + return backends + + +@pytest.fixture +def logic( + session_manager: MagicMock, + export_service: MagicMock, + export_backends: Dict[ExportFormat, MagicMock], +) -> InstrumentExportLogic: + return InstrumentExportLogic(session_manager, export_service, export_backends) + + +def _source() -> InstrumentSource: + voice = new_instrument("Lead") + project = Project.create() + project.voices.append(voice) + return voice_instrument(project, voice_instruments(voice)[0]).source + + +class TestWritingOneInstrument: + """One path carries an instrument to disk, whichever surface asked for it.""" + + def test_the_service_is_asked_to_write_it( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + tmp_path: Path, + ) -> None: + logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source()) + + export_service.export_instrument.assert_called_once() + + def test_the_destination_names_the_instrument( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + tmp_path: Path, + ) -> None: + """Renaming the file in the save dialog renames the instrument the file carries.""" + logic.export(tmp_path / f"Clap (pulse1){EXT_FILE_INSTRUMENT}", _source()) + + request = export_service.export_instrument.call_args.args[2] + assert request.name == "Clap (pulse1)" + + @pytest.mark.parametrize("case", FORMAT_CASES, ids=lambda case: case.extension) + def test_the_extension_names_the_backend( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + export_backends: Dict[ExportFormat, MagicMock], + tmp_path: Path, + case: FormatCase, + ) -> None: + logic.export(tmp_path / f"instrument{case.extension}", _source()) + + backend = export_service.export_instrument.call_args.args[1] + assert backend is export_backends[case.export_format] + + def test_everything_the_source_states_reaches_the_request( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + tmp_path: Path, + ) -> None: + source = _source() + + logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", source) + + request = export_service.export_instrument.call_args.args[2] + assert (request.channel, request.features, request.loop_point) == ( + source.channel, + source.features, + source.loop_point, + ) + assert (request.nes_frequency, request.tuning) == (source.nes_frequency, source.tuning) + + def test_the_folder_it_landed_in_is_remembered( + self, + logic: InstrumentExportLogic, + session_manager: MagicMock, + tmp_path: Path, + ) -> None: + logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source()) + + session_manager.set_instrument_path.assert_called_once_with(tmp_path) + + def test_the_dialog_opens_where_the_last_one_landed(self, logic: InstrumentExportLogic) -> None: + assert logic.suggested_directory == REMEMBERED_DIRECTORY + + def test_every_backend_is_reachable_for_the_types_a_dialog_offers( + self, + logic: InstrumentExportLogic, + export_backends: Dict[ExportFormat, MagicMock], + ) -> None: + """The dialog names each format's own file type, which it reads off the backend.""" + assert dict(logic.backends) == export_backends + + @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) + def test_an_extension_no_format_writes_is_refused( + self, + logic: InstrumentExportLogic, + tmp_path: Path, + extension: str, + ) -> None: + """The dialog answers with one of the types it offered, so an extension naming no + format is a broken invariant rather than a choice to report. + """ + with pytest.raises(ValueError): + logic.export(tmp_path / f"instrument{extension}", _source()) + + +class TestWhatAVoiceOffers: + """One rule answers what a voice contributes, so both kinds are exported the same way.""" + + @staticmethod + def _project(*voices: object) -> Project: + project = Project.create() + for voice in voices: + project.voices.append(voice) + + return project + + def test_a_written_instrument_offers_one(self) -> None: + """One set of envelopes every channel reads is one instrument, as a module holds it.""" + voice = new_instrument("Lead") + + assert len(voice_instruments(voice)) == 1 + + def test_a_written_instrument_is_named_after_itself(self) -> None: + voice = new_instrument("Lead") + + assert voice_instruments(voice)[0].name == "Lead" + + def test_a_written_instrument_is_sounded_through_the_first_channel_it_reaches(self) -> None: + """A file holding the instrument alone needs one channel to sound it on.""" + voice = new_instrument("Lead") + + assert voice_instruments(voice)[0].export_channel is ChannelName.PULSE1 + + def test_a_sample_offers_one_per_channel_that_plays( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + playing = sample.reconstruction.playing_channels + + assert [entry.export_channel for entry in voice_instruments(sample)] == list(playing) + + def test_a_samples_slice_is_named_for_its_channel( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + + assert all(entry.name.startswith("Bass") for entry in voice_instruments(sample)) + + def test_both_kinds_answer_with_the_same_shape( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """Whatever produced the envelopes is settled here, so one export path takes both.""" + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + written = new_instrument("Lead") + project = self._project(sample, written) + + sources = [voice_instrument(project, entry).source for entry in voice_instruments(sample)] + sources += [voice_instrument(project, entry).source for entry in voice_instruments(written)] + + assert all(isinstance(source, InstrumentSource) for source in sources) + + def test_the_project_states_the_rate_and_the_tuning(self) -> None: + voice = new_instrument("Lead") + project = self._project(voice) + + source = voice_instrument(project, voice_instruments(voice)[0]).source + + assert source.nes_frequency == project.settings.nes_frequency + assert source.tuning == Tuning() + + def test_a_voice_with_nothing_written_offers_nothing(self) -> None: + sample = Sample(name="Silent", reconstruction=sample_reconstruction(set())) + + assert voice_instruments(sample) == () diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index eb3823f67..de4bdd7ff 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -599,164 +599,97 @@ def test_set_selected_generators_with_no_data_skips_waveform( class TestReconstructionPanelLogicExportInstrument: - def test_request_export_instrument_dialog_with_no_data_raises_assertion_error( + """What the tab offers to an export: the slice one channel holds, ready for a destination.""" + + def test_with_no_data_raises_assertion_error( self, panel_logic: ReconstructionPanelLogic, ) -> None: with pytest.raises(AssertionError): - panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) + panel_logic.exportable_instrument(ChannelName.PULSE1) - def test_request_export_instrument_dialog_fires_dialog_callback( + def test_a_playing_channel_offers_its_slice( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: mock_reconstruction_manager.current_reconstruction = loaded_data - callback = MagicMock() - panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) - callback.assert_called_once() - def test_request_export_instrument_dialog_suggests_the_slice_name( + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) + + assert exportable is not None + assert exportable.source.features is loaded_data.feature_data[ChannelName.PULSE1] + + def test_the_suggestion_is_the_slice_name( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - """The suggestion is the slice name alone, leaving the tracker to the dialog's own + """The suggestion is the slice name alone, leaving the format to the dialog's own file-type selector. """ mock_reconstruction_manager.current_reconstruction = loaded_data - callback = MagicMock() - panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) - assert callback.call_args.args[0] == "Sample (pulse1)" - def test_request_export_instrument_dialog_for_unknown_generator_is_no_op( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - callback = MagicMock() - panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(ChannelName.TRIANGLE) - callback.assert_not_called() + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) + + assert exportable is not None + assert exportable.name == "Sample (pulse1)" - def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog( + def test_a_channel_standing_by_offers_nothing( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - """The channel travels with the request, so the confirmation names it back.""" mock_reconstruction_manager.current_reconstruction = loaded_data - callback = MagicMock() - panel_logic.on_open_export_instrument_dialog = callback - panel_logic.request_export_instrument_dialog(ChannelName.PULSE1) - assert callback.call_args.args[2] == ChannelName.PULSE1 - def test_handle_export_instrument_confirmed_with_no_data_does_not_export( - self, - panel_logic: ReconstructionPanelLogic, - mock_export_service: MagicMock, - tmp_path: Path, - ) -> None: - panel_logic.handle_export_instrument_confirmed( - tmp_path / "instrument.fti", - ChannelName.PULSE1, - ) - mock_export_service.export_instrument.assert_not_called() + assert panel_logic.exportable_instrument(ChannelName.TRIANGLE) is None - def test_handle_export_instrument_confirmed_calls_export_service( + def test_the_slice_names_the_channel_it_was_reconstructed_for( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, - mock_export_service: MagicMock, - tmp_path: Path, ) -> None: + """A backend sounding the slice on its own plays it through the channel it names.""" mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instrument_confirmed( - tmp_path / "instrument.fti", - ChannelName.PULSE1, - ) - mock_export_service.export_instrument.assert_called_once() - def test_handle_export_instrument_confirmed_names_the_instrument_after_the_destination( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - mock_export_service: MagicMock, - tmp_path: Path, - ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instrument_confirmed( - tmp_path / "Clap (pulse1).fti", - ChannelName.PULSE1, - ) - request = mock_export_service.export_instrument.call_args.args[2] - assert request.name == "Clap (pulse1)" + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) + + assert exportable is not None + assert exportable.source.channel == ChannelName.PULSE1 - @pytest.mark.parametrize("case", INSTRUMENT_FORMAT_CASES, ids=lambda case: case.extension) - def test_handle_export_instrument_confirmed_selects_the_backend_the_extension_names( + def test_a_reconstruction_slice_plays_its_envelopes_once( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, - mock_export_service: MagicMock, - mock_export_backends: Dict[ExportFormat, MagicMock], - tmp_path: Path, - case: FormatCase, ) -> None: + """A loop belongs to a sample placed in a project, so a reconstruction states none.""" mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic.handle_export_instrument_confirmed( - tmp_path / f"instrument{case.extension}", - ChannelName.PULSE1, - ) - backend = mock_export_service.export_instrument.call_args.args[1] - assert backend is mock_export_backends[case.export_format] - def test_handle_export_instrument_confirmed_carries_the_reconstructions_tuning( + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) + + assert exportable is not None + assert exportable.source.loop_point is None + + def test_the_slice_carries_the_reconstructions_tuning( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, retuned_data: ReconstructionData, - mock_export_service: MagicMock, - tmp_path: Path, ) -> None: """A backend sounding the export itself measures its pitches from the tuning the - reconstruction was built with, so the request states that tuning rather than the standard. + reconstruction was built with, so the slice states that tuning rather than the standard. """ mock_reconstruction_manager.current_reconstruction = retuned_data - panel_logic.handle_export_instrument_confirmed( - tmp_path / "instrument.fti", - ChannelName.PULSE1, - ) - request = mock_export_service.export_instrument.call_args.args[2] - assert request.tuning == Tuning(a4_frequency=RETUNED_A4_FREQUENCY) - @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) - def test_handle_export_instrument_confirmed_refuses_an_extension_no_format_writes( - self, - panel_logic: ReconstructionPanelLogic, - mock_reconstruction_manager: MagicMock, - loaded_data: ReconstructionData, - tmp_path: Path, - extension: str, - ) -> None: - """The dialog answers with one of the types it offered, so an extension naming no - format is a broken invariant rather than a choice to report. - """ - mock_reconstruction_manager.current_reconstruction = loaded_data - with pytest.raises(ValueError): - panel_logic.handle_export_instrument_confirmed( - tmp_path / f"instrument{extension}", - ChannelName.PULSE1, - ) + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) + + assert exportable is not None + assert exportable.source.tuning == Tuning(a4_frequency=RETUNED_A4_FREQUENCY) class TestReconstructionPanelLogicExportInstruments: diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index 3ff6977b3..dd86de372 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -1,11 +1,15 @@ from typing import List, Sequence import numpy as np +import pytest from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.slices import ( + FIRST_INSTRUMENT_INDEX, + InstrumentEntry, iterate_instrument_entries, iterate_voice_slices, + voice_instrument_entries, ) from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings @@ -153,3 +157,71 @@ def test_an_instrument_writing_nothing_takes_no_place_in_the_table(self) -> None assert [entry.index for entry in entries] == [0] assert entries[0].name == "pad (triangle)" + + +class TestOneVoicesInstruments: + """The instruments one voice offers, which is the rule a whole-project export also reads.""" + + @staticmethod + def _entries(voice: VoiceUnion) -> List[InstrumentEntry]: + return list(voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX)) + + def test_a_sample_offers_one_per_playing_channel(self) -> None: + sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE]) + + assert [entry.export_channel for entry in self._entries(sample)] == [ + ChannelName.PULSE1, + ChannelName.NOISE, + ] + + def test_a_written_instrument_offers_one(self) -> None: + """One set of envelopes every channel reads is one instrument, however many it sounds on.""" + assert len(self._entries(_instrument("lead"))) == 1 + + def test_a_written_instrument_is_sounded_through_the_first_channel_it_reaches(self) -> None: + """A file holding the instrument alone needs one channel to sound it on.""" + assert self._entries(_instrument("lead"))[0].export_channel is ChannelName.PULSE1 + + def test_a_samples_slice_is_sounded_through_the_channel_it_was_reconstructed_for(self) -> None: + sample = _sample("bass", [ChannelName.TRIANGLE]) + + assert self._entries(sample)[0].export_channel is ChannelName.TRIANGLE + + def test_a_voice_writing_nothing_offers_nothing(self) -> None: + assert self._entries(_instrument_writing_nothing()) == [] + + def test_the_project_walk_reads_the_same_rule(self) -> None: + """A reader is offered exactly the instruments a module would have held for that voice.""" + sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE]) + instrument = _instrument("lead") + project = _project([sample, instrument]) + + walked = list(iterate_instrument_entries(project)) + per_voice = self._entries(sample) + self._entries(instrument) + + assert [entry.name for entry in walked] == [entry.name for entry in per_voice] + + def test_an_instrument_reaching_no_channel_can_be_sounded_nowhere(self) -> None: + """A file holds one instrument by sounding it, so an entry answering for nothing refuses.""" + entry = InstrumentEntry( + index=FIRST_INSTRUMENT_INDEX, + voice_id="lead-id", + name="lead", + features=_instrument("lead").instrument_features(), + loop_point=None, + slots={}, + ) + + with pytest.raises(ValueError): + _ = entry.export_channel + + def test_the_numbering_starts_where_it_is_told_to(self) -> None: + sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE]) + + entries = list(voice_instrument_entries(sample, start_index=7)) + + assert [entry.index for entry in entries] == [7, 8] + + +def _instrument_writing_nothing() -> Instrument: + return Instrument(name="silent", envelopes=InstrumentEnvelopes()) From 10f0a6998609e241fc90969edf5a69050d897c26 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 21:12:30 +0200 Subject: [PATCH 093/142] Added: exporting a voice as an instrument file --- docs/development/bugs-and-todos.md | 1 - src/sampletones_application/application.py | 2 + .../categories/elements/sequencer.py | 1 + .../categories/instrument.py | 12 +- .../coordinators/export/instrument.py | 38 +++- .../coordinators/tabs/reconstruction.py | 8 +- .../coordinators/tabs/sequencer.py | 5 + .../logic/export/instrument.py | 123 ++++++++++-- .../logic/reconstruction/instruments.py | 4 +- .../reconstruction/instruments/instruments.py | 4 - .../ui/panels/sequencer/voices.py | 63 ++++++ src/sampletones_config/lang/en.yaml | 1 + src/sampletones_core/exporters/slices.py | 187 ------------------ .../exporters/slices/__init__.py | 33 ++++ .../exporters/slices/instrument.py | 172 ++++++++++++++++ .../exporters/slices/voice.py | 105 ++++++++++ src/sampletones_core/exporters/truncation.py | 12 +- .../coordinators/export/test_instrument.py | 58 +++++- .../coordinators/tabs/test_reconstruction.py | 47 +++++ .../logic/export/test_instrument.py | 148 ++++++++++++-- .../logic/reconstruction/test_instruments.py | 27 +++ .../ui/panels/sequencer/test_voices_menu.py | 98 ++++++++- .../sampletones_core/exporters/test_slices.py | 31 +-- 23 files changed, 924 insertions(+), 256 deletions(-) delete mode 100644 src/sampletones_core/exporters/slices.py create mode 100644 src/sampletones_core/exporters/slices/__init__.py create mode 100644 src/sampletones_core/exporters/slices/instrument.py create mode 100644 src/sampletones_core/exporters/slices/voice.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index dd9ccbdf4..f0440802e 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -26,7 +26,6 @@ * A loop point per envelope: a voice states one point, applied to every populated sequence. * A sample's loop point is offered as a switch in the voice list, though the model carries the point for both kinds of voice. -* Exporting a hand-written instrument as an instrument file from the Reconstructions tab. * `SubColumn.INSTRUMENT` names the first slot of both tracker column kinds, and the two hold different things: the voice id under the Voice column, and the note on a channel column. One name for both is wrong half the time, and splitting it reaches the layout keys diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 877140b18..2c5143742 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -421,6 +421,7 @@ def __init__( self._instrument_exports = InstrumentExportCoordinator( InstrumentExportLogic( + self.project_controller, self.session_manager, self.export_service, self.export_backends, @@ -501,6 +502,7 @@ def __init__( project_controller=self.project_controller, history=self.history, original_audio_locator=self._original_audio_locator, + instrument_exports=self._instrument_exports, tab_active=self._is_sequencer_tab_current, layout=SequencerTabParameters.from_config(self.layout), language_manager=self.language_manager, diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 0d6e3ef9f..19ea9d132 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -87,6 +87,7 @@ class SequencerVoicesElements(AbstractElement): CONTEXT_MOVE_DOWN = "context_move_down" CONTEXT_MOVE_TOP = "context_move_top" CONTEXT_MOVE_BOTTOM = "context_move_bottom" + CONTEXT_EXPORT_INSTRUMENT = "context_export_instrument" OMISSION_PITCH = "omission_pitch" OMISSION_HI_PITCH = "omission_hi_pitch" OMISSION_RELEASE_POINT = "omission_release_point" diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py index b7decd6fb..17ae7deab 100644 --- a/src/sampletones_application/categories/instrument.py +++ b/src/sampletones_application/categories/instrument.py @@ -93,5 +93,13 @@ def notice( if not omissions: return None - listed = "\n".join(f"{OMISSION_BULLET}{self.omissions[omission]}" for omission in omissions) - return f"{self.template.format(name=name)}\n{listed}" + return "\n".join( + ( + self.template.format(name=name), + self._listed(omissions), + ), + ) + + def _listed(self, omissions: Tuple[InstrumentOmission, ...]) -> str: + """The dimensions a file states past the voice, one to a line under its own mark.""" + return "\n".join(f"{OMISSION_BULLET}{self.omissions[omission]}" for omission in omissions) diff --git a/src/sampletones_application/coordinators/export/instrument.py b/src/sampletones_application/coordinators/export/instrument.py index 9b68293a8..fc349ea5e 100644 --- a/src/sampletones_application/coordinators/export/instrument.py +++ b/src/sampletones_application/coordinators/export/instrument.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Tuple +from typing import Dict, Optional, Tuple from sampletones_application.categories.elements.global_ import FileFilterElements from sampletones_application.categories.exports import ( @@ -12,6 +12,7 @@ from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.request import InstrumentSource from sampletones_core.exports.scope import ExportScope @@ -39,6 +40,41 @@ def __init__( for export_format, element in EXPORT_INSTRUMENT_FILTERS.items() } + def voice_instruments(self, voice_id: str) -> Tuple[Optional[ChannelName], ...]: + """What one voice offers to an export, each named by the channel it is stated for. + + A menu offering an export asks the exporter itself what a voice holds, so what the menu + prints and what a click writes are the same answer. + + Args: + voice_id: The voice whose instruments are offered. + + Returns: + Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, ``None`` + where the voice holds the one set of envelopes every channel reads. + """ + return self._logic.voice_instruments(voice_id) + + def request_voice( + self, + voice_id: str, + channel_name: Optional[ChannelName], + ) -> None: + """Asks where one of a project voice's instruments goes, then writes it there. + + Both surfaces naming a voice — the sequencer's voice menu and the Reconstructions tab's + export button — reach a file this way, so the same voice is written the same bytes + whichever of them asked. + + Args: + voice_id: The voice the instrument belongs to. + channel_name: The channel the instrument is stated for, ``None`` where the voice + holds the one set of envelopes every channel reads. + """ + exportable = self._logic.voice_instrument(voice_id, channel_name) + if exportable is not None: + self.request(exportable.source, exportable.name) + def request(self, source: InstrumentSource, suggested_name: str) -> None: """Asks where one instrument goes, then writes it there. diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 50b0817fb..1279102e4 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -397,8 +397,14 @@ def _export_instrument(self, channel_name: ChannelName) -> None: An instrument reaches a file the same way whichever surface asked for it, so the whole gesture from here on belongs to the shared exporter; what the tab contributes is which - instrument it has in front of it. + instrument it has in front of it. A voice written by hand is one the pool holds, so it is + written by voice and the sequencer's voice menu writes the same file for it. """ + instrument = self._instrument_editor.instrument + if instrument is not None: + self._instrument_exports.request_voice(instrument.id, None) + return + exportable = self._reconstruction_panel_logic.exportable_instrument(channel_name) if exportable is not None: self._instrument_exports.request(exportable.source, exportable.name) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index a2678fc56..543b34f6e 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -13,6 +13,7 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.constants.playback import FollowMode from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol +from sampletones_application.coordinators.export import InstrumentExportCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -162,6 +163,7 @@ def __init__( project_controller: ProjectController, history: HistoryManager, original_audio_locator: OriginalAudioLocator, + instrument_exports: InstrumentExportCoordinator, *, tab_active: ActivePredicate, layout: SequencerTabParameters, @@ -179,6 +181,7 @@ def __init__( self._session_manager = session_manager self._history = history self._original_audio_locator = original_audio_locator + self._instrument_exports = instrument_exports self._on_edit_sample_requested = on_edit_sample_requested self._on_favorite_changed = on_favorite_changed self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced @@ -651,6 +654,8 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument + self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments + self._sequencer_voices_panel.on_export_instrument_requested = self._instrument_exports.request_voice def add_instrument(self) -> None: """Appends a hand-written voice, named for the position it takes in the list. diff --git a/src/sampletones_application/logic/export/instrument.py b/src/sampletones_application/logic/export/instrument.py index ebe91a4c1..de00a3e3f 100644 --- a/src/sampletones_application/logic/export/instrument.py +++ b/src/sampletones_application/logic/export/instrument.py @@ -1,8 +1,13 @@ +# TODO: refactor into a subpackage + from dataclasses import dataclass from pathlib import Path -from typing import Dict, Mapping, Protocol, Tuple +from typing import Dict, Mapping, Optional, Protocol, Tuple from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +from sampletones_application.logic.project.controller import ProjectController +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.slices import ( FIRST_INSTRUMENT_INDEX, InstrumentEntry, @@ -32,7 +37,7 @@ class ExportableInstrument: source: InstrumentSource -def voice_instruments(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]: +def voice_entries(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]: """The instruments one voice offers to an export. A module writing every voice and a file writing one read the same rule, so a reader is offered @@ -46,26 +51,36 @@ def voice_instruments(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]: Returns: Tuple[InstrumentEntry, ...]: Its instruments, in channel order. """ - return tuple(voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX)) + return tuple( + voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX), + ) -def voice_instrument(project: Project, entry: InstrumentEntry) -> ExportableInstrument: - """One of a voice's instruments, ready to be given a destination. +def sounding_channel(entry: InstrumentEntry) -> ChannelName: + """The channel a file holding one instrument alone sounds it on. + + A sample's slice states the channel it was reconstructed for, and that is the channel it is + sounded on. A voice written by hand states one set of envelopes for no channel in particular, + and a file must still play it somewhere, so it is sounded where the app reads it: the channel + its own editor shows it under, whose reference is the tonal root its envelopes are measured + against. Args: - project: The project the voice belongs to, which states the rate and the tuning. entry: The instrument being written. Returns: - ExportableInstrument: The instrument and the name to suggest for it. - - Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. + ChannelName: The channel the written file plays the instrument through. """ - return ExportableInstrument(name=entry.name, source=instrument_source(project, entry)) + if entry.channel is None: + return INSTRUMENT_CHANNEL + return entry.channel -def instrument_source(project: Project, entry: InstrumentEntry) -> InstrumentSource: + +def instrument_source( + project: Project, + entry: InstrumentEntry, +) -> InstrumentSource: """One of a voice's instruments, measured at the rate and tuning its project plays it at. Args: @@ -79,7 +94,7 @@ def instrument_source(project: Project, entry: InstrumentEntry) -> InstrumentSou ValueError: If the project's samples were reconstructed against tunings that differ. """ return InstrumentSource( - channel=entry.export_channel, + channel=sounding_channel(entry), features=entry.features, loop_point=entry.loop_point, nes_frequency=project.settings.nes_frequency, @@ -87,6 +102,28 @@ def instrument_source(project: Project, entry: InstrumentEntry) -> InstrumentSou ) +def exportable_instrument( + project: Project, + entry: InstrumentEntry, +) -> ExportableInstrument: + """One of a voice's instruments, ready to be given a destination. + + Args: + project: The project the voice belongs to, which states the rate and the tuning. + entry: The instrument being written. + + Returns: + ExportableInstrument: The instrument and the name to suggest for it. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + return ExportableInstrument( + name=entry.name, + source=instrument_source(project, entry), + ) + + class InstrumentExportServiceProtocol(Protocol): """The slice of the export service one instrument's export drives. @@ -110,6 +147,10 @@ class InstrumentExportLogic: sample, or a voice written by hand — is settled before anything here, so every surface answers with an :class:`InstrumentSource` and reaches the same write. + A voice in the pool is answered for here as well, so a menu asks what one offers and a click + asks for one of them by the channel it names, and both the sequencer's voice menu and the + Reconstructions tab's export button write the same file for the same voice. + The destination carries the last two decisions: its extension names the format, and its stem names the instrument the file holds, so renaming a file in the save dialog renames what is written into it. @@ -117,10 +158,12 @@ class InstrumentExportLogic: def __init__( self, + project_controller: ProjectController, session_manager: SessionManager, export_service: InstrumentExportServiceProtocol, export_backends: Dict[ExportFormat, ExportBackend], ) -> None: + self._controller = project_controller self._session_manager = session_manager self._export_service = export_service self._export_backends = export_backends @@ -135,6 +178,60 @@ def suggested_directory(self) -> Path: """The folder the save dialog opens on, which is where the last instrument landed.""" return self._session_manager.get_instrument_path() + def voice_instruments(self, voice_id: str) -> Tuple[Optional[ChannelName], ...]: + """What one voice offers to an export, each named by the channel it is stated for. + + A sample answers with the channels its reconstruction found frames for; a voice written by + hand answers with a single ``None``, since its one set of envelopes belongs to no channel + in particular. A menu reads this to decide whether it offers an item or a choice of + channels, and hands one entry back to :meth:`voice_instrument`. + + Args: + voice_id: The voice whose instruments are offered. + + Returns: + Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, empty + while the pool holds no such voice or the voice writes nothing. + """ + voice = self._controller.project.voices.get(voice_id) + if voice is None: + return () + + return tuple(entry.channel for entry in voice_entries(voice)) + + def voice_instrument( + self, + voice_id: str, + channel_name: Optional[ChannelName], + ) -> Optional[ExportableInstrument]: + """One of a voice's instruments, ready to be given a destination. + + Args: + voice_id: The voice the instrument belongs to. + channel_name: The channel the instrument is stated for, as + :meth:`voice_instruments` named it. + + Returns: + Optional[ExportableInstrument]: The instrument and the name to suggest for it, or + ``None`` where the voice holds no instrument answering to that channel. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + project = self._controller.project + voice = project.voices.get(voice_id) + if voice is None: + return None + + entry = next( + (candidate for candidate in voice_entries(voice) if candidate.channel == channel_name), + None, + ) + if entry is None: + return None + + return exportable_instrument(project, entry) + def export(self, destination: Path, source: InstrumentSource) -> None: """Writes one instrument to ``destination``, in the format its extension names. diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 37ca555d0..99121e75b 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -99,7 +99,9 @@ def _build_view_model( if instrument is not None: return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - playing_channels=frozenset({INSTRUMENT_CHANNEL}), + playing_channels=frozenset( + {INSTRUMENT_CHANNEL} if instrument.features.has_frames else () # TODO: deserves a helper function + ), footprint=SampleFootprintViewModel.from_instrument( features_footprint(instrument.features, loop_point=instrument.loop_point) ), diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index d6a7458aa..36df086ab 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -551,10 +551,6 @@ def update_view( channel_name in view_model.playing_channels, ) - export_button = self._export_buttons.get(INSTRUMENT_CHANNEL) - if export_button is not None and instrument is not None: - export_button.set_enabled(False) - if instrument is not None: self._apply_instrument_fields(instrument) diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices.py index 4ebb79714..40dd74b44 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices.py @@ -1,3 +1,5 @@ +# TODO: refactor into a subpackage + from dataclasses import dataclass from typing import Callable, Dict, Final, List, Optional, Tuple @@ -57,6 +59,8 @@ FROZEN_HEADER_ROWS: Final[int] = 1 +NO_INSTRUMENTS: Final[Tuple[Optional[ChannelName], ...]] = () + @dataclass(frozen=True) class SampleMove: @@ -126,6 +130,7 @@ def __init__( self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT) self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None + self.voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None self.on_loop_changed: Optional[Callable[[str, bool], None]] = None @@ -137,6 +142,7 @@ def __init__( self.on_new_instrument_requested: Optional[VoidCallback] = None self.on_add_sample_requested: Optional[VoidCallback] = None self.on_import_instrument_requested: Optional[VoidCallback] = None + self.on_export_instrument_requested: Optional[Callable[[str, Optional[ChannelName]], None]] = None super().__init__( tag=TAG_SEQUENCER_VOICES_PANEL, @@ -812,6 +818,63 @@ def add_action_items(self, target: VoiceSelection) -> None: for move in VOICE_MOVES: self._add_move_item(move, target) + dpg.add_separator() + self._add_export_items(target) + + def _add_export_items(self, target: VoiceSelection) -> None: + """Offers the instruments the voice would be written as, however many of them it holds. + + The menu asks how many instruments the voice contains rather than which kind of voice it + is, so one item stands where there is nothing to choose and a channel submenu stands where + a reader picks between slices. A voice writing nothing offers the item unreachable, which + says an export exists without pretending this voice has one. + """ + label = self._label(self._language_manager, SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT) + channels = self.query(self.voice_instruments, target.voice_id, default=NO_INSTRUMENTS) + if not channels: + dpg.add_menu_item(label=label, enabled=False) + return + + if len(channels) > 1: + with dpg.menu(label=label): + for channel_name in channels: + self._add_export_channel_item(target, channel_name) + return + + dpg.add_menu_item( + label=label, + callback=lambda: self._request_export(target.voice_id, channels[0]), + ) + + def _add_export_channel_item( + self, + target: VoiceSelection, + channel_name: Optional[ChannelName], + ) -> None: + """Offers one of a voice's instruments, under the name that instrument carries.""" + dpg.add_menu_item( + label=self._instrument_label(target, channel_name), + callback=lambda: self._request_export(target.voice_id, channel_name), + ) + + def _instrument_label( + self, + target: VoiceSelection, + channel_name: Optional[ChannelName], + ) -> str: + """The name one of a voice's instruments is listed under. + + A slice is named by the channel it was reconstructed for, which is what tells a voice's + slices apart; an instrument stated for every channel alike is named after the voice. + """ + if channel_name is None: + return target.name + + return channel_label(self._language_manager, channel_name) + + def _request_export(self, voice_id: str, channel_name: Optional[ChannelName]) -> None: + self.call(self.on_export_instrument_requested, voice_id, channel_name) + def _add_move_item( self, move: SampleMove, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 269b7e5a4..e8cb0cc54 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -614,6 +614,7 @@ sequencer.voices.label.context_move_up: "Move up" sequencer.voices.label.context_move_down: "Move down" sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" +sequencer.voices.label.context_export_instrument: "Export instrument..." sequencer.voices.label.omission_pitch: "a pitch envelope" sequencer.voices.label.omission_hi_pitch: "a hi-pitch envelope" sequencer.voices.label.omission_release_point: "a release point" diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py deleted file mode 100644 index 2cdcb9c23..000000000 --- a/src/sampletones_core/exporters/slices.py +++ /dev/null @@ -1,187 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Dict, Final, Iterator, Optional, Tuple - -from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.feature import Features -from sampletones_core.exporters.naming import instrument_slice_name -from sampletones_core.project.project import Project -from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.voice import VoiceUnion, voice_channels - - -@dataclass(frozen=True) -class InstrumentSlot: - """Where a row naming a voice on one channel lands: the instrument it plays and its reference.""" - - index: int - initial_pitch: int - - -InstrumentTable = Dict[Tuple[str, ChannelName], InstrumentSlot] - -FIRST_INSTRUMENT_INDEX: Final[int] = 0 - - -@dataclass(frozen=True) -class VoiceSlice: - """What one channel of one voice plays, in the envelope terms every backend reads. - - Attributes: - voice: The voice the slice came from. - channel: The NES channel the slice covers. - features: The per-dimension envelopes describing the slice. - """ - - voice: VoiceUnion - channel: ChannelName - features: Features - - @property - def instrument_name(self) -> str: - """The exported instrument's name, naming both its voice and its channel.""" - return instrument_slice_name(self.voice.name, self.channel) - - @property - def key(self) -> Tuple[str, ChannelName]: - """The identity a pattern row references the slice by.""" - return (self.voice.id, self.channel) - - -@dataclass(frozen=True) -class InstrumentEntry: - """One instrument an export writes, and the channels whose rows reach it. - - A sample's channels each carry frames of their own, so each becomes an instrument answering - for that channel alone. An instrument carries one set of envelopes every channel reads, so it - becomes one instrument answering for every channel it sounds on, each against its own root — - which is the instrument model FamiTracker itself uses. - - Attributes: - index: Position the instrument takes in the exported table. - voice_id: The voice a row names to reach it. - name: The name the tracker lists it by. - features: The envelopes written into it. - loop_point: The tick its envelopes repeat from, or ``None`` where they play once. - slots: Per channel it answers for, the table position and the reference that channel reads. - """ - - index: int - voice_id: str - name: str - features: Features - loop_point: Optional[int] - slots: Dict[ChannelName, InstrumentSlot] - - @property - def export_channel(self) -> ChannelName: - """The channel a backend sounding this instrument on its own plays it through. - - A slice carries the channel it was reconstructed for, and a hand-written voice carries one - set of envelopes every channel reads, so the first channel it answers for — in channel - order — is the one a file holding this instrument alone is sounded on. - - Raises: - ValueError: If the instrument answers for no channel at all. - """ - for channel in ChannelName.items(): - if channel in self.slots: - return channel - - raise ValueError(f"Instrument '{self.name}' answers for no channel to be sounded on") - - -def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: - """Walks what every channel of every voice plays, in voice order then channel order. - - A voice contributes one slice per channel it sounds on, so a sample yields one to four and an - instrument yields one per channel its envelopes make a frame for. Each voice is read once, so a - caller reads a reconstruction's envelopes at a single cost. - - Args: - project: The project whose voices are exported. - - Yields: - VoiceSlice: What each channel of each voice plays. - """ - for voice in project.voices: - match voice: - case Sample(): - features_by_channel = voice.reconstruction.export() - for channel in ChannelName.items(): - features = features_by_channel[channel] - if features.has_frames: - yield VoiceSlice(voice=voice, channel=channel, features=features) - case Instrument(): - for channel in voice_channels(voice): - yield VoiceSlice(voice=voice, channel=channel, features=voice.features(channel)) - - -def voice_instrument_entries( - voice: VoiceUnion, - *, - start_index: int, -) -> Iterator[InstrumentEntry]: - """The instruments an export writes for one voice, numbered from ``start_index``. - - This is the whole rule for what a voice contributes to an export, so a module writing every - voice and a file writing one read the same thing: a reader is offered exactly the instruments - a module would have held. - - Args: - voice: The voice being exported. - start_index: The slot the first of its instruments is numbered under. - - Yields: - InstrumentEntry: Each instrument alongside the channels whose rows reach it. - """ - match voice: - case Sample(): - features_by_channel = voice.reconstruction.export() - index = start_index - for channel in ChannelName.items(): - features = features_by_channel[channel] - if not features.has_frames: - continue - - yield InstrumentEntry( - index=index, - voice_id=voice.id, - name=instrument_slice_name(voice.name, channel), - features=features, - loop_point=voice.loop_point, - slots={channel: InstrumentSlot(index=index, initial_pitch=features.initial_pitch)}, - ) - index += 1 - case Instrument(): - channels = voice_channels(voice) - if channels: - yield InstrumentEntry( - index=start_index, - voice_id=voice.id, - name=voice.name, - features=voice.instrument_features(), - loop_point=voice.loop_point, - slots={ - channel: InstrumentSlot(index=start_index, initial_pitch=voice.reference(channel)) - for channel in channels - }, - ) - - -def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]: - """Walks the instruments an export writes, numbered in voice order then channel order. - - Args: - project: The project whose voices are exported. - - Yields: - InstrumentEntry: Each instrument alongside the channels whose rows reach it. - """ - index = FIRST_INSTRUMENT_INDEX - for voice in project.voices: - for entry in voice_instrument_entries(voice, start_index=index): - yield entry - index += 1 diff --git a/src/sampletones_core/exporters/slices/__init__.py b/src/sampletones_core/exporters/slices/__init__.py new file mode 100644 index 000000000..a763fbac2 --- /dev/null +++ b/src/sampletones_core/exporters/slices/__init__.py @@ -0,0 +1,33 @@ +from .instrument import ( + FIRST_INSTRUMENT_INDEX, + InstrumentEntry, + InstrumentSlot, + InstrumentTable, + instrument_entries, + iterate_instrument_entries, + sample_instrument_entries, + voice_instrument_entries, +) +from .voice import ( + VoiceSlice, + instrument_slices, + iterate_voice_slices, + sample_slices, + voice_slices, +) + +__all__ = [ + "FIRST_INSTRUMENT_INDEX", + "InstrumentEntry", + "InstrumentSlot", + "InstrumentTable", + "VoiceSlice", + "instrument_entries", + "instrument_slices", + "iterate_instrument_entries", + "iterate_voice_slices", + "sample_instrument_entries", + "sample_slices", + "voice_instrument_entries", + "voice_slices", +] diff --git a/src/sampletones_core/exporters/slices/instrument.py b/src/sampletones_core/exporters/slices/instrument.py new file mode 100644 index 000000000..963689d39 --- /dev/null +++ b/src/sampletones_core/exporters/slices/instrument.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass +from typing import Dict, Final, Iterator, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.project.project import Project +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels + +FIRST_INSTRUMENT_INDEX: Final[int] = 0 + + +@dataclass(frozen=True) +class InstrumentSlot: + """Where a row naming a voice on one channel lands: the instrument it plays and its reference.""" + + index: int + initial_pitch: int + + +InstrumentTable = Dict[Tuple[str, ChannelName], InstrumentSlot] + + +@dataclass(frozen=True) +class InstrumentEntry: + """One instrument an export writes, and the channels whose rows reach it. + + A sample's channels each carry frames of their own, so each becomes an instrument answering + for that channel alone. An instrument carries one set of envelopes every channel reads, so it + becomes one instrument answering for every channel it sounds on, each against its own root — + which is the instrument model FamiTracker itself uses. + + Attributes: + index: Position the instrument takes in the exported table. + voice_id: The voice a row names to reach it. + name: The name the tracker lists it by. + features: The envelopes written into it. + channel: The channel ``features`` are stated for, or ``None`` where they are the one set + every channel reads and belong to no channel in particular. + loop_point: The tick its envelopes repeat from, or ``None`` where they play once. + slots: Per channel it answers for, the table position and the reference that channel reads. + """ + + index: int + voice_id: str + name: str + features: Features + channel: Optional[ChannelName] + loop_point: Optional[int] + slots: Dict[ChannelName, InstrumentSlot] + + +def sample_instrument_entries( + sample: Sample, + *, + start_index: int, +) -> Iterator[InstrumentEntry]: + """The instruments a recording contributes: one per channel its conversion found frames for. + + Each carries the frames of one channel alone, so it is named after that channel and answers + for it by itself. + + Args: + sample: The sample being exported. + start_index: The slot the first of its instruments is numbered under. + + Yields: + InstrumentEntry: One instrument per playing channel. + """ + features_by_channel = sample.reconstruction.export() + index = start_index + for channel in ChannelName.items(): + features = features_by_channel[channel] + if not features.has_frames: + continue + + yield InstrumentEntry( + index=index, + voice_id=sample.id, + name=instrument_slice_name(sample.name, channel), + features=features, + channel=channel, + loop_point=sample.loop_point, + slots={ + channel: InstrumentSlot( + index=index, + initial_pitch=features.initial_pitch, + ), + }, + ) + index += 1 + + +def instrument_entries( + instrument: Instrument, + *, + start_index: int, +) -> Iterator[InstrumentEntry]: + """The instrument a hand-written voice contributes, which is its one envelope set. + + Every channel it sounds on reaches that set, each against the root it reads, so the entry + answers for all of them and states its envelopes for none of them in particular. + + Args: + instrument: The voice being exported. + start_index: The slot the instrument is numbered under. + + Yields: + InstrumentEntry: The one instrument, where its envelopes make a frame anywhere. + """ + channels = voice_channels(instrument) + if not channels: + return + + yield InstrumentEntry( + index=start_index, + voice_id=instrument.id, + name=instrument.name, + features=instrument.instrument_features(), + channel=None, + loop_point=instrument.loop_point, + slots={ + channel: InstrumentSlot( + index=start_index, + initial_pitch=instrument.reference(channel), + ) + for channel in channels + }, + ) + + +def voice_instrument_entries( + voice: VoiceUnion, + *, + start_index: int, +) -> Iterator[InstrumentEntry]: + """The instruments an export writes for one voice, numbered from ``start_index``. + + This is the whole rule for what a voice contributes to an export, so a module writing every + voice and a file writing one read the same thing: a reader is offered exactly the instruments + a module would have held. + + Args: + voice: The voice being exported. + start_index: The slot the first of its instruments is numbered under. + + Yields: + InstrumentEntry: Each instrument alongside the channels whose rows reach it. + """ + match voice: + case Sample(): + yield from sample_instrument_entries(voice, start_index=start_index) + case Instrument(): + yield from instrument_entries(voice, start_index=start_index) + + +def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]: + """Walks the instruments an export writes, numbered in voice order then channel order. + + Args: + project: The project whose voices are exported. + + Yields: + InstrumentEntry: Each instrument alongside the channels whose rows reach it. + """ + index = FIRST_INSTRUMENT_INDEX + for voice in project.voices: + for entry in voice_instrument_entries(voice, start_index=index): + yield entry + index += 1 diff --git a/src/sampletones_core/exporters/slices/voice.py b/src/sampletones_core/exporters/slices/voice.py new file mode 100644 index 000000000..4d0ec2129 --- /dev/null +++ b/src/sampletones_core/exporters/slices/voice.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass +from typing import Iterator, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.project.project import Project +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels + + +@dataclass(frozen=True) +class VoiceSlice: + """What one channel of one voice plays, in the envelope terms every backend reads. + + Attributes: + voice: The voice the slice came from. + channel: The NES channel the slice covers. + features: The per-dimension envelopes describing the slice. + """ + + voice: VoiceUnion + channel: ChannelName + features: Features + + @property + def instrument_name(self) -> str: + """The exported instrument's name, naming both its voice and its channel.""" + return instrument_slice_name(self.voice.name, self.channel) + + @property + def key(self) -> Tuple[str, ChannelName]: + """The identity a pattern row references the slice by.""" + return (self.voice.id, self.channel) + + +def sample_slices(sample: Sample) -> Iterator[VoiceSlice]: + """What each channel of a recording plays, one slice per channel its conversion found frames for. + + Args: + sample: The sample being exported. + + Yields: + VoiceSlice: What each of its playing channels plays. + """ + features_by_channel = sample.reconstruction.export() + for channel in ChannelName.items(): + features = features_by_channel[channel] + if features.has_frames: + yield VoiceSlice( + voice=sample, + channel=channel, + features=features, + ) + + +def instrument_slices(instrument: Instrument) -> Iterator[VoiceSlice]: + """What each channel of a hand-written voice plays, each reading the one envelope set its way. + + Args: + instrument: The voice being exported. + + Yields: + VoiceSlice: What each channel its envelopes make a frame for plays. + """ + for channel in voice_channels(instrument): + yield VoiceSlice( + voice=instrument, + channel=channel, + features=instrument.features(channel), + ) + + +def voice_slices(voice: VoiceUnion) -> Iterator[VoiceSlice]: + """What each channel of one voice plays, whichever kind the voice is. + + Args: + voice: The voice being exported. + + Yields: + VoiceSlice: What each of its playing channels plays. + """ + match voice: + case Sample(): + yield from sample_slices(voice) + case Instrument(): + yield from instrument_slices(voice) + + +def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]: + """Walks what every channel of every voice plays, in voice order then channel order. + + A voice contributes one slice per channel it sounds on, so a sample yields one to four and an + instrument yields one per channel its envelopes make a frame for. Each voice is read once, so a + caller reads a reconstruction's envelopes at a single cost. + + Args: + project: The project whose voices are exported. + + Yields: + VoiceSlice: What each channel of each voice plays. + """ + for voice in project.voices: + yield from voice_slices(voice) diff --git a/src/sampletones_core/exporters/truncation.py b/src/sampletones_core/exporters/truncation.py index 726819e5a..269f5834d 100644 --- a/src/sampletones_core/exporters/truncation.py +++ b/src/sampletones_core/exporters/truncation.py @@ -19,7 +19,11 @@ class EnvelopeTruncation: instruments: int @classmethod - def measure(cls, source_frames: int, limit: Optional[int]) -> Optional[EnvelopeTruncation]: + def measure( + cls, + source_frames: int, + limit: Optional[int], + ) -> Optional[EnvelopeTruncation]: """Reports what an export of one instrument's envelopes keeps. Args: @@ -33,7 +37,11 @@ def measure(cls, source_frames: int, limit: Optional[int]) -> Optional[EnvelopeT if limit is None or source_frames <= limit: return None - return cls(frames=limit, source_frames=source_frames, instruments=1) + return cls( + frames=limit, + source_frames=source_frames, + instruments=1, + ) @classmethod def summarize( diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py index 36de45f03..613d8ae5e 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_instrument.py +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -12,10 +12,12 @@ ) from sampletones_application.exports import build_export_backends from sampletones_application.logic.export.instrument import ( - voice_instrument, - voice_instruments, + ExportableInstrument, + exportable_instrument, + voice_entries, ) from sampletones_application.paths import LANG_EN +from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.request import InstrumentSource from sampletones_core.exports.scope import ExportScope from sampletones_core.project.project import Project @@ -30,7 +32,7 @@ def _source() -> InstrumentSource: voice = new_instrument(SUGGESTED_NAME) project = Project.create() project.voices.append(voice) - return voice_instrument(project, voice_instruments(voice)[0]).source + return exportable_instrument(project, voice_entries(voice)[0]).source @pytest.fixture @@ -122,6 +124,56 @@ def test_each_type_is_named_after_the_program_that_reads_it( assert len({file_filter.name for file_filter in filters}) == len(INSTRUMENT_EXPORT_FORMATS) +class TestAskingForOneOfAVoicesInstruments: + """A voice named by a menu reaches the same dialog as a slice handed over whole.""" + + def test_the_voices_instrument_is_offered_under_its_own_name( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + logic.voice_instrument.return_value = ExportableInstrument(name=SUGGESTED_NAME, source=_source()) + + coordinator.request_voice("lead-id", None) + + assert confirmed[0]["default_filename"] == SUGGESTED_NAME + + def test_the_channel_the_menu_named_is_the_one_asked_for( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + logic.voice_instrument.return_value = ExportableInstrument(name=SUGGESTED_NAME, source=_source()) + + coordinator.request_voice("bass-id", ChannelName.TRIANGLE) + + logic.voice_instrument.assert_called_once_with("bass-id", ChannelName.TRIANGLE) + + def test_a_voice_holding_no_such_instrument_opens_nothing( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + logic.voice_instrument.return_value = None + + coordinator.request_voice("lead-id", None) + + assert confirmed == [] + + def test_what_a_voice_offers_is_the_exporters_own_answer( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + ) -> None: + """What a menu prints and what a click writes come from one place.""" + logic.voice_instruments.return_value = (ChannelName.PULSE1, ChannelName.NOISE) + + assert coordinator.voice_instruments("bass-id") == (ChannelName.PULSE1, ChannelName.NOISE) + + class TestWritingWhatWasConfirmed: def test_the_destination_reaches_the_write( self, diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index d8fe9fab9..218ab024f 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -13,6 +13,7 @@ from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat from sampletones_shared.exceptions import ( @@ -273,6 +274,52 @@ def _shown_message(coordinator: ReconstructionTabCoordinator) -> str: return message +class TestExportingTheInstrumentInFront: + """Whatever the tab holds reaches a file the same way, so a pool voice is written by voice.""" + + @staticmethod + def _coordinator( + instrument: object, + exportable: object, + ) -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._instrument_editor = MagicMock() + instance._instrument_editor.instrument = instrument + instance._instrument_exports = MagicMock() + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_panel_logic.exportable_instrument.return_value = exportable + return instance + + def test_a_hand_written_voice_is_written_by_the_voice_it_is(self) -> None: + """The sequencer's menu and this button name the same voice, so they write the same file.""" + instrument = MagicMock() + instrument.id = "lead-id" + coordinator = self._coordinator(instrument, MagicMock()) + + coordinator._export_instrument(ChannelName.PULSE1) + + coordinator._instrument_exports.request_voice.assert_called_once_with("lead-id", None) + coordinator._reconstruction_panel_logic.exportable_instrument.assert_not_called() + + def test_a_reconstructions_slice_is_written_as_the_tab_holds_it(self) -> None: + exportable = MagicMock() + coordinator = self._coordinator(None, exportable) + + coordinator._export_instrument(ChannelName.TRIANGLE) + + coordinator._instrument_exports.request.assert_called_once_with( + exportable.source, + exportable.name, + ) + + def test_a_channel_describing_no_frame_is_written_nowhere(self) -> None: + coordinator = self._coordinator(None, None) + + coordinator._export_instrument(ChannelName.NOISE) + + coordinator._instrument_exports.request.assert_not_called() + + class TestExportResultReportsTruncation: def test_a_complete_instrument_export_shows_the_success_message( self, diff --git a/tests/unit/sampletones_application/logic/export/test_instrument.py b/tests/unit/sampletones_application/logic/export/test_instrument.py index f243966c3..1754f1e2e 100644 --- a/tests/unit/sampletones_application/logic/export/test_instrument.py +++ b/tests/unit/sampletones_application/logic/export/test_instrument.py @@ -5,11 +5,13 @@ import pytest +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.exports import build_export_backends from sampletones_application.logic.export.instrument import ( InstrumentExportLogic, - voice_instrument, - voice_instruments, + exportable_instrument, + sounding_channel, + voice_entries, ) from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.format import ExportFormat @@ -73,20 +75,28 @@ def export_backends() -> Dict[ExportFormat, MagicMock]: return backends +@pytest.fixture +def project_controller() -> MagicMock: + mock = MagicMock() + mock.project = Project.create() + return mock + + @pytest.fixture def logic( + project_controller: MagicMock, session_manager: MagicMock, export_service: MagicMock, export_backends: Dict[ExportFormat, MagicMock], ) -> InstrumentExportLogic: - return InstrumentExportLogic(session_manager, export_service, export_backends) + return InstrumentExportLogic(project_controller, session_manager, export_service, export_backends) def _source() -> InstrumentSource: voice = new_instrument("Lead") project = Project.create() project.voices.append(voice) - return voice_instrument(project, voice_instruments(voice)[0]).source + return exportable_instrument(project, voice_entries(voice)[0]).source class TestWritingOneInstrument: @@ -196,18 +206,12 @@ def test_a_written_instrument_offers_one(self) -> None: """One set of envelopes every channel reads is one instrument, as a module holds it.""" voice = new_instrument("Lead") - assert len(voice_instruments(voice)) == 1 + assert len(voice_entries(voice)) == 1 def test_a_written_instrument_is_named_after_itself(self) -> None: voice = new_instrument("Lead") - assert voice_instruments(voice)[0].name == "Lead" - - def test_a_written_instrument_is_sounded_through_the_first_channel_it_reaches(self) -> None: - """A file holding the instrument alone needs one channel to sound it on.""" - voice = new_instrument("Lead") - - assert voice_instruments(voice)[0].export_channel is ChannelName.PULSE1 + assert voice_entries(voice)[0].name == "Lead" def test_a_sample_offers_one_per_channel_that_plays( self, @@ -216,7 +220,7 @@ def test_a_sample_offers_one_per_channel_that_plays( sample = Sample(name="Bass", reconstruction=reconstruction_factory()) playing = sample.reconstruction.playing_channels - assert [entry.export_channel for entry in voice_instruments(sample)] == list(playing) + assert [entry.channel for entry in voice_entries(sample)] == list(playing) def test_a_samples_slice_is_named_for_its_channel( self, @@ -224,7 +228,7 @@ def test_a_samples_slice_is_named_for_its_channel( ) -> None: sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - assert all(entry.name.startswith("Bass") for entry in voice_instruments(sample)) + assert all(entry.name.startswith("Bass") for entry in voice_entries(sample)) def test_both_kinds_answer_with_the_same_shape( self, @@ -235,8 +239,8 @@ def test_both_kinds_answer_with_the_same_shape( written = new_instrument("Lead") project = self._project(sample, written) - sources = [voice_instrument(project, entry).source for entry in voice_instruments(sample)] - sources += [voice_instrument(project, entry).source for entry in voice_instruments(written)] + sources = [exportable_instrument(project, entry).source for entry in voice_entries(sample)] + sources += [exportable_instrument(project, entry).source for entry in voice_entries(written)] assert all(isinstance(source, InstrumentSource) for source in sources) @@ -244,7 +248,7 @@ def test_the_project_states_the_rate_and_the_tuning(self) -> None: voice = new_instrument("Lead") project = self._project(voice) - source = voice_instrument(project, voice_instruments(voice)[0]).source + source = exportable_instrument(project, voice_entries(voice)[0]).source assert source.nes_frequency == project.settings.nes_frequency assert source.tuning == Tuning() @@ -252,4 +256,112 @@ def test_the_project_states_the_rate_and_the_tuning(self) -> None: def test_a_voice_with_nothing_written_offers_nothing(self) -> None: sample = Sample(name="Silent", reconstruction=sample_reconstruction(set())) - assert voice_instruments(sample) == () + assert voice_entries(sample) == () + + +class TestWhichChannelAFileSoundsItOn: + """A written file plays its instrument somewhere, and the entry states where or leaves it open.""" + + def test_a_samples_slice_sounds_on_the_channel_it_was_reconstructed_for( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + + for entry in voice_entries(sample): + assert sounding_channel(entry) is entry.channel + + def test_a_written_instrument_sounds_where_the_app_reads_it(self) -> None: + """Its envelopes name no channel, so the file plays them where its editor shows them.""" + voice = new_instrument("Lead") + + assert sounding_channel(voice_entries(voice)[0]) is INSTRUMENT_CHANNEL + + def test_a_written_instrument_is_measured_against_the_root_that_channel_reads(self) -> None: + """The channel it is sounded on and the reference its envelopes carry are one answer.""" + voice = new_instrument("Lead") + entry = voice_entries(voice)[0] + + assert entry.features.initial_pitch == voice.reference(sounding_channel(entry)) + + +class TestWhatOneVoiceIsAskedFor: + """A menu asks what a voice offers, and a click asks for one of them by the channel it named.""" + + @staticmethod + def _logic(project: Project, session_manager: MagicMock) -> InstrumentExportLogic: + controller = MagicMock() + controller.project = project + return InstrumentExportLogic(controller, session_manager, MagicMock(), {}) + + def test_a_sample_offers_each_channel_that_plays( + self, + session_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + project = Project.create() + project.voices.append(sample) + + offered = self._logic(project, session_manager).voice_instruments(sample.id) + + assert offered == tuple(sample.reconstruction.playing_channels) + + def test_a_written_instrument_offers_one_naming_no_channel( + self, + session_manager: MagicMock, + ) -> None: + voice = new_instrument("Lead") + project = Project.create() + project.voices.append(voice) + + assert self._logic(project, session_manager).voice_instruments(voice.id) == (None,) + + def test_a_voice_the_pool_lost_offers_nothing(self, session_manager: MagicMock) -> None: + logic = self._logic(Project.create(), session_manager) + + assert logic.voice_instruments("gone") == () + + def test_the_instrument_asked_for_is_the_one_that_channel_names( + self, + session_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + project = Project.create() + project.voices.append(sample) + channel = sample.reconstruction.playing_channels[0] + + exportable = self._logic(project, session_manager).voice_instrument(sample.id, channel) + + assert exportable is not None + assert exportable.source.channel is channel + + def test_a_written_instrument_is_asked_for_by_naming_no_channel( + self, + session_manager: MagicMock, + ) -> None: + voice = new_instrument("Lead") + project = Project.create() + project.voices.append(voice) + + exportable = self._logic(project, session_manager).voice_instrument(voice.id, None) + + assert exportable is not None + assert exportable.name == "Lead" + + def test_a_written_instrument_is_reached_by_naming_no_channel_alone( + self, + session_manager: MagicMock, + ) -> None: + """It offers one instrument for every channel at once, so no channel names it by itself.""" + voice = new_instrument("Lead") + project = Project.create() + project.voices.append(voice) + + assert self._logic(project, session_manager).voice_instrument(voice.id, ChannelName.NOISE) is None + + def test_a_voice_the_pool_lost_is_written_nowhere(self, session_manager: MagicMock) -> None: + logic = self._logic(Project.create(), session_manager) + + assert logic.voice_instrument("gone", None) is None diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index e2e710667..1e618a82e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -350,6 +350,33 @@ def test_the_view_names_the_instrument_it_shows( assert received[-1].instrument is not None assert received[-1].instrument.name == "lead" + def test_the_tab_it_is_shown_under_plays_it( + self, + instrument_logic: ReconstructionInstrumentsLogic, + ) -> None: + """The tab that plays is the tab an export is offered from, so the button is reachable.""" + received: List[ReconstructionInstrumentsViewModel] = [] + instrument_logic.on_view_changed = received.append + + instrument_logic.update_display() + + assert received[-1].playing_channels == frozenset({INSTRUMENT_CHANNEL}) + + def test_an_instrument_with_nothing_written_stands_by( + self, + instrument_logic: ReconstructionInstrumentsLogic, + project_controller: ProjectController, + ) -> None: + """An export writes what has frames, so a voice holding none is offered no export.""" + instrument = project_controller.project.voices[0] + project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, ()) + received: List[ReconstructionInstrumentsViewModel] = [] + instrument_logic.on_view_changed = received.append + + instrument_logic.update_display() + + assert received[-1].playing_channels == frozenset() + def test_the_envelopes_are_drawn_under_the_channel_that_reads_them_all( self, instrument_logic: ReconstructionInstrumentsLogic, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py index 44be436a3..f82dec196 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py @@ -55,6 +55,15 @@ MOVE_DOWN_ITEM = 5 MOVE_TOP_ITEM = 6 MOVE_BOTTOM_ITEM = 7 +EXPORT_ITEM = 8 + +ONE_INSTRUMENT: Tuple[Optional[ChannelName], ...] = (ChannelName.PULSE1,) +TWO_INSTRUMENTS: Tuple[Optional[ChannelName], ...] = (ChannelName.PULSE1, ChannelName.NOISE) +NO_INSTRUMENTS: Tuple[Optional[ChannelName], ...] = () + + +def _unreachable() -> None: + """Stands where a greyed-out item would carry a callback, which a reader never fires.""" @dataclass @@ -77,11 +86,18 @@ class Requests: removed: List[str] = field(default_factory=list) moved: List[Tuple[str, Optional[int]]] = field(default_factory=list) pool: List[str] = field(default_factory=list) + exported: List[Tuple[str, Optional[ChannelName]]] = field(default_factory=list) class _MenuRecorder: def __init__(self) -> None: self.items: List[MenuItem] = [] + self.submenus: List[str] = [] + + @contextlib.contextmanager + def menu(self, **kwargs: Any) -> Iterator[None]: + self.submenus.append(kwargs["label"]) + yield def add_menu_item(self, **kwargs: Any) -> int: self.items.append( @@ -89,7 +105,7 @@ def add_menu_item(self, **kwargs: Any) -> int: label=kwargs["label"], shortcut=kwargs.get("shortcut", ""), enabled=kwargs.get("enabled", True), - callback=kwargs["callback"], + callback=kwargs.get("callback", _unreachable), ) ) return 0 @@ -100,6 +116,7 @@ def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: recorded = _MenuRecorder() monkeypatch.setattr(voices_module.dpg, "add_menu_item", recorded.add_menu_item) monkeypatch.setattr(voices_module.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(voices_module.dpg, "menu", recorded.menu) return recorded @@ -120,6 +137,7 @@ def _panel( field_focused: bool = False, footprint: Optional[SampleFootprintViewModel] = FOOTPRINT, footprint_wired: bool = True, + instruments: Tuple[Optional[ChannelName], ...] = ONE_INSTRUMENT, ) -> VoicesPanelFixture: """A samples panel whose menu builder can run with no DearPyGui context behind it.""" panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) @@ -137,6 +155,7 @@ def _panel( panel._tpl_size_bytes = SIZE_TEMPLATE panel._tip_size_bytes = SIZE_TOOLTIP panel.sample_footprint = (lambda _voice_id: footprint) if footprint_wired else None + panel.voice_instruments = lambda _voice_id: instruments requests = Requests() panel.on_sample_edit_requested = requests.edited.append @@ -146,6 +165,7 @@ def _panel( panel.on_new_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.NEW_INSTRUMENT.value) panel.on_add_sample_requested = lambda: requests.pool.append(SequencerVoicesElements.ADD_SAMPLE.value) panel.on_import_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.IMPORT_INSTRUMENT.value) + panel.on_export_instrument_requested = lambda voice_id, channel: requests.exported.append((voice_id, channel)) monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) return VoicesPanelFixture(panel=panel, requests=requests) @@ -188,6 +208,11 @@ def add_menu_item(self, **kwargs: Any) -> int: self.widgets.append(MenuWidget(kind="item", text=kwargs["label"])) return 0 + @contextlib.contextmanager + def menu(self, **kwargs: Any) -> Iterator[None]: + self.widgets.append(MenuWidget(kind="menu", text=kwargs["label"])) + yield + def texts_before_the_first_item(self) -> List[str]: widgets: List[str] = [] for widget in self.widgets: @@ -222,6 +247,7 @@ def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: monkeypatch.setattr(voices_module.dpg, "add_text", recorded.add_text) monkeypatch.setattr(voices_module.dpg, "add_separator", recorded.add_separator) monkeypatch.setattr(voices_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(voices_module.dpg, "menu", recorded.menu) monkeypatch.setattr(voices_module, "context_menu", _null_menu) monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None) monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip) @@ -254,6 +280,7 @@ def test_the_menu_reads_as_the_sample_actions( SequencerVoicesElements.CONTEXT_DUPLICATE.value, SequencerVoicesElements.CONTEXT_REMOVE.value, *(move.element.value for move in VOICE_MOVES), + SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value, ] def test_the_items_print_the_keys_the_panel_answers_to( @@ -267,7 +294,7 @@ def test_the_items_print_the_keys_the_panel_answers_to( assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.VOICES_RENAME_VOICE) assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE) - assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [ + assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM : MOVE_BOTTOM_ITEM + 1]] == [ shortcuts.display(move.shortcut) for move in VOICE_MOVES ] @@ -292,6 +319,7 @@ def test_the_items_act_on_the_sample_they_were_raised_on( (SELECTED_ID, 0), (SELECTED_ID, len(ENTRIES) - 1), ] + assert fixture.requests.exported == [(SELECTED_ID, ChannelName.PULSE1)] def test_a_move_with_nowhere_to_go_is_greyed_out( self, @@ -306,6 +334,72 @@ def test_a_move_with_nowhere_to_go_is_greyed_out( assert recorder.items[MOVE_BOTTOM_ITEM].enabled +class TestExportingTheVoicesInstruments: + """The menu offers what an export would write for the voice, however many instruments that is.""" + + def test_a_voice_holding_one_instrument_offers_a_plain_item( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """There is nothing to choose between, so the item writes the one instrument straight away.""" + _panel(monkeypatch).panel.build_edit_actions() + + assert recorder.submenus == [] + assert recorder.items[EXPORT_ITEM].label == SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value + + def test_a_voice_holding_several_offers_one_item_per_channel( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, instruments=TWO_INSTRUMENTS).panel.build_edit_actions() + + assert recorder.submenus == [SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value] + assert [item.label for item in recorder.items[EXPORT_ITEM:]] == [ + ContextElements.PULSE_1.value, + ContextElements.NOISE.value, + ] + + def test_each_channel_writes_the_instrument_it_names( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + fixture = _panel(monkeypatch, instruments=TWO_INSTRUMENTS) + fixture.panel.build_edit_actions() + + for item in recorder.items[EXPORT_ITEM:]: + item.callback() + + assert fixture.requests.exported == [ + (SELECTED_ID, ChannelName.PULSE1), + (SELECTED_ID, ChannelName.NOISE), + ] + + def test_an_instrument_stated_for_no_channel_is_named_after_its_voice( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """A hand-written voice reads the same envelopes on every channel, so it carries its own name.""" + _panel(monkeypatch, instruments=(None, ChannelName.NOISE)).panel.build_edit_actions() + + assert recorder.items[EXPORT_ITEM].label == "Bass" + + def test_a_voice_writing_nothing_offers_an_item_it_cannot_reach( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """The export exists for every voice, and this one has nothing yet to write.""" + fixture = _panel(monkeypatch, instruments=NO_INSTRUMENTS) + fixture.panel.build_edit_actions() + + assert not recorder.items[EXPORT_ITEM].enabled + assert fixture.requests.exported == [] + + class TestTheSizeRows: """A sample's menu names the bytes it occupies, so what a pool costs is read where it is edited.""" diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index dd86de372..f762ed109 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -1,7 +1,6 @@ from typing import List, Sequence import numpy as np -import pytest from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.slices import ( @@ -169,7 +168,7 @@ def _entries(voice: VoiceUnion) -> List[InstrumentEntry]: def test_a_sample_offers_one_per_playing_channel(self) -> None: sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE]) - assert [entry.export_channel for entry in self._entries(sample)] == [ + assert [entry.channel for entry in self._entries(sample)] == [ ChannelName.PULSE1, ChannelName.NOISE, ] @@ -178,14 +177,18 @@ def test_a_written_instrument_offers_one(self) -> None: """One set of envelopes every channel reads is one instrument, however many it sounds on.""" assert len(self._entries(_instrument("lead"))) == 1 - def test_a_written_instrument_is_sounded_through_the_first_channel_it_reaches(self) -> None: - """A file holding the instrument alone needs one channel to sound it on.""" - assert self._entries(_instrument("lead"))[0].export_channel is ChannelName.PULSE1 + def test_a_written_instrument_states_its_envelopes_for_no_channel(self) -> None: + """One set every channel reads belongs to none of them, so the entry names none.""" + assert self._entries(_instrument("lead"))[0].channel is None - def test_a_samples_slice_is_sounded_through_the_channel_it_was_reconstructed_for(self) -> None: + def test_a_written_instrument_still_answers_for_every_channel_it_sounds_on(self) -> None: + """Naming no channel of its own leaves the rows of every channel reaching it.""" + assert list(self._entries(_instrument("lead"))[0].slots) == ChannelName.items() + + def test_a_samples_slice_names_the_channel_it_was_reconstructed_for(self) -> None: sample = _sample("bass", [ChannelName.TRIANGLE]) - assert self._entries(sample)[0].export_channel is ChannelName.TRIANGLE + assert self._entries(sample)[0].channel is ChannelName.TRIANGLE def test_a_voice_writing_nothing_offers_nothing(self) -> None: assert self._entries(_instrument_writing_nothing()) == [] @@ -201,20 +204,6 @@ def test_the_project_walk_reads_the_same_rule(self) -> None: assert [entry.name for entry in walked] == [entry.name for entry in per_voice] - def test_an_instrument_reaching_no_channel_can_be_sounded_nowhere(self) -> None: - """A file holds one instrument by sounding it, so an entry answering for nothing refuses.""" - entry = InstrumentEntry( - index=FIRST_INSTRUMENT_INDEX, - voice_id="lead-id", - name="lead", - features=_instrument("lead").instrument_features(), - loop_point=None, - slots={}, - ) - - with pytest.raises(ValueError): - _ = entry.export_channel - def test_the_numbering_starts_where_it_is_told_to(self) -> None: sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE]) From ac7e6dfb3fe3ccc680229ea927f3496b220d5e20 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 22:02:51 +0200 Subject: [PATCH 094/142] Added: a new instrument from a sample's channel --- src/sampletones_application/application.py | 2 +- .../categories/elements/sequencer.py | 1 + .../coordinators/export/instrument.py | 2 +- .../coordinators/tabs/sequencer.py | 32 +- .../logic/export/instrument.py | 263 ------------- .../logic/export/instrument/__init__.py | 0 .../logic/export/instrument/logic.py | 125 ++++++ .../logic/export/instrument/protocol.py | 20 + .../logic/export/instrument/source.py | 175 +++++++++ .../logic/reconstruction/instruments.py | 76 ++-- .../logic/reconstruction/reconstruction.py | 35 +- .../logic/sequencer/voices.py | 90 ++++- .../ui/panels/sequencer/voices/__init__.py | 0 .../ui/panels/sequencer/voices/menu.py | 345 ++++++++++++++++ .../ui/panels/sequencer/voices/moves.py | 43 ++ .../sequencer/{voices.py => voices/panel.py} | 290 ++------------ src/sampletones_config/lang/en.yaml | 1 + src/sampletones_core/exporters/__init__.py | 3 +- src/sampletones_core/exporters/feature.py | 302 +++++++------- .../project/voices/creation.py | 72 +++- .../coordinators/export/test_instrument.py | 2 +- .../coordinators/tabs/test_sequencer.py | 66 ++++ .../logic/export/instrument/__init__.py | 0 .../logic/export/instrument/test_logic.py | 214 ++++++++++ .../logic/export/instrument/test_source.py | 164 ++++++++ .../logic/export/test_instrument.py | 367 ------------------ .../logic/sequencer/test_voices.py | 85 ++++ .../panels/sequencer/test_panel_tab_gate.py | 2 +- .../ui/panels/sequencer/voices/__init__.py | 0 .../test_keys.py} | 4 +- .../test_menu.py} | 166 ++++++-- .../test_selection.py} | 2 +- .../exporters/test_feature.py | 30 +- .../project/voices/test_creation.py | 128 +++++- 34 files changed, 1984 insertions(+), 1123 deletions(-) delete mode 100644 src/sampletones_application/logic/export/instrument.py create mode 100644 src/sampletones_application/logic/export/instrument/__init__.py create mode 100644 src/sampletones_application/logic/export/instrument/logic.py create mode 100644 src/sampletones_application/logic/export/instrument/protocol.py create mode 100644 src/sampletones_application/logic/export/instrument/source.py create mode 100644 src/sampletones_application/ui/panels/sequencer/voices/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/voices/menu.py create mode 100644 src/sampletones_application/ui/panels/sequencer/voices/moves.py rename src/sampletones_application/ui/panels/sequencer/{voices.py => voices/panel.py} (70%) create mode 100644 tests/unit/sampletones_application/logic/export/instrument/__init__.py create mode 100644 tests/unit/sampletones_application/logic/export/instrument/test_logic.py create mode 100644 tests/unit/sampletones_application/logic/export/instrument/test_source.py delete mode 100644 tests/unit/sampletones_application/logic/export/test_instrument.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/voices/__init__.py rename tests/unit/sampletones_application/ui/panels/sequencer/{test_voices_keys.py => voices/test_keys.py} (96%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_voices_menu.py => voices/test_menu.py} (79%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_voices_selection.py => voices/test_selection.py} (95%) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 2c5143742..a89c3d95a 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -40,7 +40,7 @@ from sampletones_application.exports import build_export_backends from sampletones_application.layout import LayoutConfig, load_layout_config from sampletones_application.logic.export import SongExportLogic -from sampletones_application.logic.export.instrument import InstrumentExportLogic +from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.instruction.library_manager import ( diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 19ea9d132..269565c56 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -87,6 +87,7 @@ class SequencerVoicesElements(AbstractElement): CONTEXT_MOVE_DOWN = "context_move_down" CONTEXT_MOVE_TOP = "context_move_top" CONTEXT_MOVE_BOTTOM = "context_move_bottom" + CONTEXT_INSTRUMENT_FROM = "context_instrument_from" CONTEXT_EXPORT_INSTRUMENT = "context_export_instrument" OMISSION_PITCH = "omission_pitch" OMISSION_HI_PITCH = "omission_hi_pitch" diff --git a/src/sampletones_application/coordinators/export/instrument.py b/src/sampletones_application/coordinators/export/instrument.py index fc349ea5e..4e662c2d4 100644 --- a/src/sampletones_application/coordinators/export/instrument.py +++ b/src/sampletones_application/coordinators/export/instrument.py @@ -8,7 +8,7 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.logic.export.instrument import InstrumentExportLogic +from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 543b34f6e..e6fb802ad 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -90,7 +90,7 @@ from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel -from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.file_dialogs.api import open_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter @@ -656,6 +656,8 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments self._sequencer_voices_panel.on_export_instrument_requested = self._instrument_exports.request_voice + self._sequencer_voices_panel.instrument_channels = self._sequencer_voices_logic.instrument_channels + self._sequencer_voices_panel.on_instrument_from_channel_requested = self.add_instrument_from_channel def add_instrument(self) -> None: """Appends a hand-written voice, named for the position it takes in the list. @@ -673,6 +675,34 @@ def add_instrument(self) -> None: ): self._sequencer_voices_logic.add_new_instrument(name) + def add_instrument_from_channel( + self, + voice_id: str, + channel_name: ChannelName, + ) -> None: + """Takes what one channel of a voice plays as an instrument of its own, then opens it. + + A recording states its channels as frames, and this reads one of them back as envelopes, + so what the conversion found becomes a voice the reader edits by hand. The new voice is + brought up where it is edited, since seeing those envelopes is what taking the channel out + was for. + + Args: + voice_id: The voice the channel belongs to. + channel_name: The channel whose envelopes the instrument takes. + """ + instrument = self._sequencer_voices_logic.instrument_from_channel(voice_id, channel_name) + if instrument is None: + return + + with self._history.transaction( + HistoryAction.ADD_INSTRUMENT, + detail=self._history_detail.add_instrument(instrument.name), + ): + self._sequencer_voices_logic.add_instrument(instrument) + + self._sequencer_voices_logic.request_edit(instrument.id) + def add_sample_from_file(self) -> None: """Brings a reconstruction saved anywhere on disk into the pool as a sample. diff --git a/src/sampletones_application/logic/export/instrument.py b/src/sampletones_application/logic/export/instrument.py deleted file mode 100644 index de00a3e3f..000000000 --- a/src/sampletones_application/logic/export/instrument.py +++ /dev/null @@ -1,263 +0,0 @@ -# TODO: refactor into a subpackage - -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, Mapping, Optional, Protocol, Tuple - -from sampletones_application.config.managers.session import SessionManager -from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL -from sampletones_application.logic.project.controller import ProjectController -from sampletones_core.constants.enums import ChannelName -from sampletones_core.exporters.slices import ( - FIRST_INSTRUMENT_INDEX, - InstrumentEntry, - voice_instrument_entries, -) -from sampletones_core.exports.backend import ExportBackend -from sampletones_core.exports.extensions import format_for_extension -from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.request import InstrumentExport, InstrumentSource -from sampletones_core.exports.scope import ExportScope -from sampletones_core.project.project import Project -from sampletones_core.project.tuning import tuning_from_project -from sampletones_core.project.voices.voice import VoiceUnion - - -@dataclass(frozen=True) -class ExportableInstrument: - """One instrument ready to be asked for a destination. - - Attributes: - name: The name the save dialog suggests, which the written instrument keeps unless the - reader renames the file. - source: The instrument itself, awaiting the name its destination gives it. - """ - - name: str - source: InstrumentSource - - -def voice_entries(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]: - """The instruments one voice offers to an export. - - A module writing every voice and a file writing one read the same rule, so a reader is offered - exactly the instruments a module would have held: a sample yields one per channel its - reconstruction found frames for, and a voice written by hand yields the single set of envelopes - every channel reads. - - Args: - voice: The voice whose instruments are offered. - - Returns: - Tuple[InstrumentEntry, ...]: Its instruments, in channel order. - """ - return tuple( - voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX), - ) - - -def sounding_channel(entry: InstrumentEntry) -> ChannelName: - """The channel a file holding one instrument alone sounds it on. - - A sample's slice states the channel it was reconstructed for, and that is the channel it is - sounded on. A voice written by hand states one set of envelopes for no channel in particular, - and a file must still play it somewhere, so it is sounded where the app reads it: the channel - its own editor shows it under, whose reference is the tonal root its envelopes are measured - against. - - Args: - entry: The instrument being written. - - Returns: - ChannelName: The channel the written file plays the instrument through. - """ - if entry.channel is None: - return INSTRUMENT_CHANNEL - - return entry.channel - - -def instrument_source( - project: Project, - entry: InstrumentEntry, -) -> InstrumentSource: - """One of a voice's instruments, measured at the rate and tuning its project plays it at. - - Args: - project: The project the voice belongs to, which states the rate and the tuning. - entry: The instrument being written. - - Returns: - InstrumentSource: The instrument, awaiting the name its destination gives it. - - Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. - """ - return InstrumentSource( - channel=sounding_channel(entry), - features=entry.features, - loop_point=entry.loop_point, - nes_frequency=project.settings.nes_frequency, - tuning=tuning_from_project(project), - ) - - -def exportable_instrument( - project: Project, - entry: InstrumentEntry, -) -> ExportableInstrument: - """One of a voice's instruments, ready to be given a destination. - - Args: - project: The project the voice belongs to, which states the rate and the tuning. - entry: The instrument being written. - - Returns: - ExportableInstrument: The instrument and the name to suggest for it. - - Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. - """ - return ExportableInstrument( - name=entry.name, - source=instrument_source(project, entry), - ) - - -class InstrumentExportServiceProtocol(Protocol): - """The slice of the export service one instrument's export drives. - - Typing the collaborator structurally keeps the logic layer independent of the service - implementation; the composition root supplies the real service. - """ - - def export_instrument( - self, - destination: Path, - backend: ExportBackend, - request: InstrumentExport, - ) -> None: ... - - -class InstrumentExportLogic: - """The one way an instrument reaches a file, whichever surface asked for one. - - An instrument export is a set of envelopes, the channel they are read for and the rate they - advance at. What produced them — a channel of the open reconstruction, a channel of a project - sample, or a voice written by hand — is settled before anything here, so every surface answers - with an :class:`InstrumentSource` and reaches the same write. - - A voice in the pool is answered for here as well, so a menu asks what one offers and a click - asks for one of them by the channel it names, and both the sequencer's voice menu and the - Reconstructions tab's export button write the same file for the same voice. - - The destination carries the last two decisions: its extension names the format, and its stem - names the instrument the file holds, so renaming a file in the save dialog renames what is - written into it. - """ - - def __init__( - self, - project_controller: ProjectController, - session_manager: SessionManager, - export_service: InstrumentExportServiceProtocol, - export_backends: Dict[ExportFormat, ExportBackend], - ) -> None: - self._controller = project_controller - self._session_manager = session_manager - self._export_service = export_service - self._export_backends = export_backends - - @property - def backends(self) -> Mapping[ExportFormat, ExportBackend]: - """Every backend an instrument can be written through, keyed by its format.""" - return self._export_backends - - @property - def suggested_directory(self) -> Path: - """The folder the save dialog opens on, which is where the last instrument landed.""" - return self._session_manager.get_instrument_path() - - def voice_instruments(self, voice_id: str) -> Tuple[Optional[ChannelName], ...]: - """What one voice offers to an export, each named by the channel it is stated for. - - A sample answers with the channels its reconstruction found frames for; a voice written by - hand answers with a single ``None``, since its one set of envelopes belongs to no channel - in particular. A menu reads this to decide whether it offers an item or a choice of - channels, and hands one entry back to :meth:`voice_instrument`. - - Args: - voice_id: The voice whose instruments are offered. - - Returns: - Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, empty - while the pool holds no such voice or the voice writes nothing. - """ - voice = self._controller.project.voices.get(voice_id) - if voice is None: - return () - - return tuple(entry.channel for entry in voice_entries(voice)) - - def voice_instrument( - self, - voice_id: str, - channel_name: Optional[ChannelName], - ) -> Optional[ExportableInstrument]: - """One of a voice's instruments, ready to be given a destination. - - Args: - voice_id: The voice the instrument belongs to. - channel_name: The channel the instrument is stated for, as - :meth:`voice_instruments` named it. - - Returns: - Optional[ExportableInstrument]: The instrument and the name to suggest for it, or - ``None`` where the voice holds no instrument answering to that channel. - - Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. - """ - project = self._controller.project - voice = project.voices.get(voice_id) - if voice is None: - return None - - entry = next( - (candidate for candidate in voice_entries(voice) if candidate.channel == channel_name), - None, - ) - if entry is None: - return None - - return exportable_instrument(project, entry) - - def export(self, destination: Path, source: InstrumentSource) -> None: - """Writes one instrument to ``destination``, in the format its extension names. - - Args: - destination: The file the save dialog was confirmed with. - source: The instrument to write, awaiting the name the destination gives it. - - Raises: - ValueError: If no format writing a single instrument claims the destination's - extension, which a dialog offering those formats alone never yields. - """ - export_format = self._format(destination) - self._session_manager.set_instrument_path(destination.parent) - self._export_service.export_instrument( - destination, - self._export_backends[export_format], - source.named(destination.stem), - ) - - def _format(self, destination: Path) -> ExportFormat: - export_format = format_for_extension( - self._export_backends, - ExportScope.INSTRUMENT, - destination.suffix, - ) - if export_format is None: - raise ValueError(f"No export format writes '{destination.suffix}' for an instrument export") - - return export_format diff --git a/src/sampletones_application/logic/export/instrument/__init__.py b/src/sampletones_application/logic/export/instrument/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/export/instrument/logic.py b/src/sampletones_application/logic/export/instrument/logic.py new file mode 100644 index 000000000..f03624018 --- /dev/null +++ b/src/sampletones_application/logic/export/instrument/logic.py @@ -0,0 +1,125 @@ +from pathlib import Path +from typing import Dict, Mapping, Optional, Tuple + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.instrument.protocol import ( + InstrumentExportServiceProtocol, +) +from sampletones_application.logic.export.instrument.source import ( + ExportableInstrument, + voice_instrument, + voice_instrument_channels, +) +from sampletones_application.logic.project.controller import ProjectController +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.extensions import format_for_extension +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.exports.scope import ExportScope + + +class InstrumentExportLogic: + """The one way an instrument reaches a file, whichever surface asked for one. + + An instrument export is a set of envelopes, the channel they are read for and the rate they + advance at. What produced them — a channel of the open reconstruction, a channel of a project + sample, or a voice written by hand — is settled before anything here, so every surface answers + with an :class:`InstrumentSource` and reaches the same write. + + A voice in the pool is answered for here as well, so a menu asks what one offers and a click + asks for one of them by the channel it names, and both the sequencer's voice menu and the + Reconstructions tab's export button write the same file for the same voice. + + The destination carries the last two decisions: its extension names the format, and its stem + names the instrument the file holds, so renaming a file in the save dialog renames what is + written into it. + """ + + def __init__( + self, + project_controller: ProjectController, + session_manager: SessionManager, + export_service: InstrumentExportServiceProtocol, + export_backends: Dict[ExportFormat, ExportBackend], + ) -> None: + self._controller = project_controller + self._session_manager = session_manager + self._export_service = export_service + self._export_backends = export_backends + + @property + def backends(self) -> Mapping[ExportFormat, ExportBackend]: + """Every backend an instrument can be written through, keyed by its format.""" + return self._export_backends + + @property + def suggested_directory(self) -> Path: + """The folder the save dialog opens on, which is where the last instrument landed.""" + return self._session_manager.get_instrument_path() + + def voice_instruments(self, voice_id: str) -> Tuple[Optional[ChannelName], ...]: + """What one voice offers to an export, each named by the channel it is stated for. + + A menu reads this to decide whether it offers an item or a choice of channels, and hands + one entry back to :meth:`voice_instrument`. + + Args: + voice_id: The voice whose instruments are offered. + + Returns: + Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, empty + while the pool holds no such voice or the voice writes nothing. + """ + return voice_instrument_channels(self._controller.project, voice_id) + + def voice_instrument( + self, + voice_id: str, + channel_name: Optional[ChannelName], + ) -> Optional[ExportableInstrument]: + """One of a voice's instruments, ready to be given a destination. + + Args: + voice_id: The voice the instrument belongs to. + channel_name: The channel the instrument is stated for, as + :meth:`voice_instruments` named it. + + Returns: + Optional[ExportableInstrument]: The instrument and the name to suggest for it, or + ``None`` where the voice holds no instrument answering to that channel. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + return voice_instrument(self._controller.project, voice_id, channel_name) + + def export(self, destination: Path, source: InstrumentSource) -> None: + """Writes one instrument to ``destination``, in the format its extension names. + + Args: + destination: The file the save dialog was confirmed with. + source: The instrument to write, awaiting the name the destination gives it. + + Raises: + ValueError: If no format writing a single instrument claims the destination's + extension, which a dialog offering those formats alone never yields. + """ + export_format = self._format(destination) + self._session_manager.set_instrument_path(destination.parent) + self._export_service.export_instrument( + destination, + self._export_backends[export_format], + source.named(destination.stem), + ) + + def _format(self, destination: Path) -> ExportFormat: + export_format = format_for_extension( + self._export_backends, + ExportScope.INSTRUMENT, + destination.suffix, + ) + if export_format is None: + raise ValueError(f"No export format writes '{destination.suffix}' for an instrument export") + + return export_format diff --git a/src/sampletones_application/logic/export/instrument/protocol.py b/src/sampletones_application/logic/export/instrument/protocol.py new file mode 100644 index 000000000..e417f6ea5 --- /dev/null +++ b/src/sampletones_application/logic/export/instrument/protocol.py @@ -0,0 +1,20 @@ +from pathlib import Path +from typing import Protocol + +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import InstrumentExport + + +class InstrumentExportServiceProtocol(Protocol): + """The slice of the export service one instrument's export drives. + + Typing the collaborator structurally keeps the logic layer independent of the service + implementation; the composition root supplies the real service. + """ + + def export_instrument( + self, + destination: Path, + backend: ExportBackend, + request: InstrumentExport, + ) -> None: ... diff --git a/src/sampletones_application/logic/export/instrument/source.py b/src/sampletones_application/logic/export/instrument/source.py new file mode 100644 index 000000000..9fa337888 --- /dev/null +++ b/src/sampletones_application/logic/export/instrument/source.py @@ -0,0 +1,175 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.slices import ( + FIRST_INSTRUMENT_INDEX, + InstrumentEntry, + voice_instrument_entries, +) +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.project.project import Project +from sampletones_core.project.tuning import tuning_from_project +from sampletones_core.project.voices.voice import VoiceUnion + + +@dataclass(frozen=True) +class ExportableInstrument: + """One instrument ready to be asked for a destination. + + Attributes: + name: The name the save dialog suggests, which the written instrument keeps unless the + reader renames the file. + source: The instrument itself, awaiting the name its destination gives it. + """ + + name: str + source: InstrumentSource + + +def voice_entries(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]: + """The instruments one voice offers to an export. + + A module writing every voice and a file writing one read the same rule, so a reader is offered + exactly the instruments a module would have held: a sample yields one per channel its + reconstruction found frames for, and a voice written by hand yields the single set of envelopes + every channel reads. + + Args: + voice: The voice whose instruments are offered. + + Returns: + Tuple[InstrumentEntry, ...]: Its instruments, in channel order. + """ + return tuple( + voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX), + ) + + +def sounding_channel(entry: InstrumentEntry) -> ChannelName: + """The channel a file holding one instrument alone sounds it on. + + A sample's slice states the channel it was reconstructed for, and that is the channel it is + sounded on. A voice written by hand states one set of envelopes for no channel in particular, + and a file must still play it somewhere, so it is sounded where the app reads it: the channel + its own editor shows it under, whose reference is the tonal root its envelopes are measured + against. + + Args: + entry: The instrument being written. + + Returns: + ChannelName: The channel the written file plays the instrument through. + """ + if entry.channel is None: + return INSTRUMENT_CHANNEL + + return entry.channel + + +def instrument_source( + project: Project, + entry: InstrumentEntry, +) -> InstrumentSource: + """One of a voice's instruments, measured at the rate and tuning its project plays it at. + + Args: + project: The project the voice belongs to, which states the rate and the tuning. + entry: The instrument being written. + + Returns: + InstrumentSource: The instrument, awaiting the name its destination gives it. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + return InstrumentSource( + channel=sounding_channel(entry), + features=entry.features, + loop_point=entry.loop_point, + nes_frequency=project.settings.nes_frequency, + tuning=tuning_from_project(project), + ) + + +def exportable_instrument( + project: Project, + entry: InstrumentEntry, +) -> ExportableInstrument: + """One of a voice's instruments, ready to be given a destination. + + Args: + project: The project the voice belongs to, which states the rate and the tuning. + entry: The instrument being written. + + Returns: + ExportableInstrument: The instrument and the name to suggest for it. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + return ExportableInstrument( + name=entry.name, + source=instrument_source(project, entry), + ) + + +def voice_instrument_channels( + project: Project, + voice_id: str, +) -> Tuple[Optional[ChannelName], ...]: + """What one project voice offers to an export, each named by the channel it is stated for. + + A sample answers with the channels its reconstruction found frames for; a voice written by hand + answers with a single ``None``, since its one set of envelopes belongs to no channel in + particular. + + Args: + project: The project the voice belongs to. + voice_id: The voice whose instruments are offered. + + Returns: + Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, empty while + the pool holds no such voice or the voice writes nothing. + """ + voice = project.voices.get(voice_id) + if voice is None: + return () + + return tuple(entry.channel for entry in voice_entries(voice)) + + +def voice_instrument( + project: Project, + voice_id: str, + channel_name: Optional[ChannelName], +) -> Optional[ExportableInstrument]: + """One of a project voice's instruments, named by the channel it is stated for. + + Args: + project: The project the voice belongs to. + voice_id: The voice the instrument belongs to. + channel_name: The channel the instrument is stated for, ``None`` where the voice holds the + one set of envelopes every channel reads. + + Returns: + Optional[ExportableInstrument]: The instrument and the name to suggest for it, or ``None`` + where the pool holds no such voice or the voice holds no instrument answering to that + channel. + + Raises: + ValueError: If the project's samples were reconstructed against tunings that differ. + """ + voice = project.voices.get(voice_id) + if voice is None: + return None + + entry = next( + (candidate for candidate in voice_entries(voice) if candidate.channel == channel_name), + None, + ) + if entry is None: + return None + + return exportable_instrument(project, entry) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 99121e75b..48ada58a1 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, FrozenSet, Optional +from typing import Callable, Dict, Optional import numpy as np @@ -21,7 +21,7 @@ ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.exporters import Features +from sampletones_core.exporters import Features, playing_channels from sampletones_core.formats.famitracker.footprint import features_footprint from sampletones_core.types.feature import FeatureValue from sampletones_shared.utils.callbacks import CallbackMixin @@ -61,10 +61,17 @@ def _displayed_features(self) -> Optional[Dict[ChannelName, Features]]: """ instrument = self.instrument_edit if instrument is not None: - return {INSTRUMENT_CHANNEL: instrument.features} + return self._instrument_channels(instrument) return self._current_generators() + @staticmethod + def _instrument_channels( + instrument: InstrumentEdit, + ) -> Dict[ChannelName, Features]: + """An instrument's envelopes under the channel the panel shows it on.""" + return {INSTRUMENT_CHANNEL: instrument.features} + def refresh_view(self) -> None: """Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are. @@ -72,7 +79,10 @@ def refresh_view(self) -> None: channels settle on it. The envelopes themselves are left to the edit that started the regeneration, so a field the user is still typing in keeps what they wrote. """ - self.call(self.on_view_changed, self._build_view_model(self._current_generators())) + self.call( + self.on_view_changed, + self._build_view_model(self._current_generators()), + ) def _current_generators(self) -> Optional[Dict[ChannelName, Features]]: """The channels of the reconstruction in front of the panel, where one is.""" @@ -97,21 +107,7 @@ def _build_view_model( ) -> ReconstructionInstrumentsViewModel: instrument = self.instrument_edit if instrument is not None: - return ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - playing_channels=frozenset( - {INSTRUMENT_CHANNEL} if instrument.features.has_frames else () # TODO: deserves a helper function - ), - footprint=SampleFootprintViewModel.from_instrument( - features_footprint(instrument.features, loop_point=instrument.loop_point) - ), - instrument=InstrumentViewModel( - name=instrument.name, - root_pitch=instrument.root_pitch, - root_period=instrument.root_period, - loop_point=instrument.loop_point, - ), - ) + return self._instrument_view_model(instrument) if channels is None: return ReconstructionInstrumentsViewModel( @@ -120,15 +116,39 @@ def _build_view_model( footprint=None, ) - playing_channels: FrozenSet[ChannelName] = frozenset( - channel_name for channel_name, features in channels.items() if features.has_frames - ) return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - playing_channels=playing_channels, + playing_channels=playing_channels(channels), footprint=self._build_footprint(channels), ) + def _instrument_view_model( + self, + instrument: InstrumentEdit, + ) -> ReconstructionInstrumentsViewModel: + """What the panel shows of an instrument: its envelopes, its roots and what it costs. + + An instrument is shown under one channel, and it plays there once its envelopes describe a + frame, so it stands by the way a reconstruction's silent channel does until the reader + writes one. + """ + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + playing_channels=playing_channels(self._instrument_channels(instrument)), + footprint=SampleFootprintViewModel.from_instrument( + features_footprint( + instrument.features, + loop_point=instrument.loop_point, + ) + ), + instrument=InstrumentViewModel( + name=instrument.name, + root_pitch=instrument.root_pitch, + root_period=instrument.root_period, + loop_point=instrument.loop_point, + ), + ) + def _build_footprint( self, channels: Dict[ChannelName, Features], @@ -155,7 +175,10 @@ def handle_pitch_value_changed( ) -> None: instrument = self.instrument_edit if instrument is not None: - self._editor.write_roots(pitch=value, period=instrument.root_period) + self._editor.write_roots( + pitch=value, + period=instrument.root_period, + ) self.update_display() return @@ -212,7 +235,10 @@ def handle_instrument_root_period_changed(self, value: int) -> None: self._editor.write_roots(pitch=instrument.root_pitch, period=value) self.update_display() - def handle_instrument_loop_point_changed(self, loop_point: Optional[int]) -> None: + def handle_instrument_loop_point_changed( + self, + loop_point: Optional[int], + ) -> None: """Sets the tick the instrument in front of the panel repeats from.""" if self.instrument_edit is None: return diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 1ba340e72..7a4731269 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,10 +1,20 @@ from pathlib import Path -from typing import Callable, Dict, Final, FrozenSet, List, Optional, Protocol, Set, Tuple +from typing import ( + Callable, + Dict, + Final, + FrozenSet, + List, + Optional, + Protocol, + Set, + Tuple, +) import numpy as np from sampletones_application.config.managers.session import SessionManager -from sampletones_application.logic.export.instrument import ExportableInstrument +from sampletones_application.logic.export.instrument.source import ExportableInstrument from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.view_model.reconstruction.paths.path import ( @@ -37,7 +47,9 @@ SampleExport, ) from sampletones_core.exports.scope import ExportScope -from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection +from sampletones_core.reconstructions.reconstruction.stems.selection import ( + StemSelection, +) from sampletones_shared.logger import logger from sampletones_shared.music import Tuning from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -252,7 +264,11 @@ def set_selected_channels(self, channels: List[ChannelName]) -> None: ) self._emit_audio_data() - def set_stem_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> None: + def set_stem_channels( + self, + stem_id: int, + channels: FrozenSet[ChannelName], + ) -> None: """Adopts the channels one recording is heard on and re-answers playback and the waveform. The choice is listening state, so it filters what plays and what the waveform @@ -274,7 +290,10 @@ def set_stem_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> N ) self._emit_audio_data() - def _adopt_stem_channels(self, offered: Dict[int, FrozenSet[ChannelName]]) -> None: + def _adopt_stem_channels( + self, + offered: Dict[int, FrozenSet[ChannelName]], + ) -> None: """Carries the reader's per-channel stem choice across an edit. A channel a stem keeps holding frames on keeps whatever the reader chose for it, and @@ -441,7 +460,11 @@ def request_export_wav_dialog(self) -> None: default_filename = reconstruction_data.name default_path = str(self._session_manager.get_audio_path()) - self.call(self.on_open_export_wav_dialog, default_filename, default_path) + self.call( + self.on_open_export_wav_dialog, + default_filename, + default_path, + ) def handle_export_instruments_confirmed( self, diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 90f40bca6..9373483ca 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Final, Optional +from typing import Callable, Final, Optional, Tuple import numpy as np @@ -19,14 +19,21 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.slices import VoiceSlice, sample_slices from sampletones_core.formats.famitracker.footprint import ( features_footprint, reconstruction_footprints, ) from sampletones_core.formats.famitracker.instrument import read_fti -from sampletones_core.formats.famitracker.voice import ImportedVoice, instrument_to_voice +from sampletones_core.formats.famitracker.voice import ( + ImportedVoice, + instrument_to_voice, +) from sampletones_core.generators.render import render_instructions -from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.creation import ( + instrument_from_features, + new_instrument, +) from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample @@ -119,13 +126,72 @@ def read_instrument(self, filepath: Path) -> ImportedVoice: return imported + def instrument_channels(self, voice_id: str) -> Tuple[ChannelName, ...]: + """The channels of one voice a new instrument can be written from. + + A recording's channel carries frames of its own, so each of them makes a voice of + envelopes the reader edits directly. A voice written by hand already is that, so it offers + none and the menu says so. + + Args: + voice_id: The voice a new instrument would be taken from. + + Returns: + Tuple[ChannelName, ...]: The channels it offers, in channel order. + """ + return tuple(voice_slice.channel for voice_slice in self._channel_slices(voice_id)) + + def instrument_from_channel( + self, + voice_id: str, + channel_name: ChannelName, + ) -> Optional[Instrument]: + """Writes what one channel of a voice plays into an instrument, leaving the pool as it stands. + + The new voice carries the channel's envelopes and the reference they were measured + against, and it is named after the channel it came from, so the list says where it came + from the way an exported slice does. + + Args: + voice_id: The voice the channel belongs to. + channel_name: The channel whose envelopes the instrument takes. + + Returns: + Optional[Instrument]: The voice those envelopes describe, or ``None`` where the pool + holds no such voice or it plays nothing on that channel. + """ + voice_slice = next( + (candidate for candidate in self._channel_slices(voice_id) if candidate.channel is channel_name), + None, + ) + if voice_slice is None: + return None + + return instrument_from_features( + voice_slice.instrument_name, + voice_slice.features, + channel_name, + loop_point=voice_slice.voice.loop_point, + ) + + def _channel_slices(self, voice_id: str) -> Tuple[VoiceSlice, ...]: + """What each channel of a recording plays, which is what an instrument is written from.""" + match self._controller.project.voices.get(voice_id): + case Sample() as sample: + return tuple(sample_slices(sample)) + case _: + return () + def rename_voice(self, voice_id: str, name: str) -> None: self._controller.rename_voice(voice_id, name) def is_voice_used(self, voice_id: str) -> bool: return self._controller.is_voice_used(voice_id) - def build_voice_footprint(self, voice_id: str) -> Optional[SampleFootprintViewModel]: + def build_voice_footprint( + self, + voice_id: str, + ) -> Optional[SampleFootprintViewModel]: """Measures one voice's instruments as the module export writes them. A voice carries its own loop point, and a looping instrument is compiled to one shared @@ -144,11 +210,17 @@ def build_voice_footprint(self, voice_id: str) -> Optional[SampleFootprintViewMo match self._controller.project.voices.get(voice_id): case Sample() as sample: return SampleFootprintViewModel.from_footprints( - reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) + reconstruction_footprints( + sample.reconstruction, + loop_point=sample.loop_point, + ) ) case Instrument() as instrument: return SampleFootprintViewModel.from_instrument( - features_footprint(instrument.instrument_features(), loop_point=instrument.loop_point) + features_footprint( + instrument.instrument_features(), + loop_point=instrument.loop_point, + ) ) case _: return None @@ -233,7 +305,11 @@ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: if not instructions: return None - return render_instructions(instructions, PREVIEW_CHANNEL, self._preview_config()) + return render_instructions( + instructions, + PREVIEW_CHANNEL, + self._preview_config(), + ) case _: return None diff --git a/src/sampletones_application/ui/panels/sequencer/voices/__init__.py b/src/sampletones_application/ui/panels/sequencer/voices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/voices/menu.py b/src/sampletones_application/ui/panels/sequencer/voices/menu.py new file mode 100644 index 000000000..3512a6656 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/voices/menu.py @@ -0,0 +1,345 @@ +from typing import Callable, Final, List, Optional, Protocol, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import ( + channel_label, + context_label, + context_text, +) +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.elements.sequencer import ( + SequencerVoicesElements, +) +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.context_menu import ( + add_detail_items, + add_play_menu_item, + context_menu, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.sequencer.voices.moves import ( + VOICE_MOVES, + VoiceMove, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.view_model.sequencer.voices import VoiceSelection +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import StringCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +NO_INSTRUMENTS: Final[Tuple[Optional[ChannelName], ...]] = () + +NO_CHANNELS: Final[Tuple[ChannelName, ...]] = () + + +class VoicesMenuHost(Protocol): + """What the voices panel states to the menus raised over its list. + + The hooks are the panel's own, so the coordinator keeps wiring them where it already does, and + a menu reads whichever answer stands at the moment it opens. + """ + + sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] + voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] + instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] + on_sample_edit_requested: Optional[StringCallback] + on_duplicate_requested: Optional[StringCallback] + on_remove_requested: Optional[StringCallback] + on_play_requested: Optional[StringCallback] + on_move_requested: Optional[Callable[[str, int], None]] + on_new_instrument_requested: Optional[VoidCallback] + on_add_sample_requested: Optional[VoidCallback] + on_import_instrument_requested: Optional[VoidCallback] + on_export_instrument_requested: Optional[Callable[[str, Optional[ChannelName]], None]] + on_instrument_from_channel_requested: Optional[Callable[[str, ChannelName], None]] + + @property + def voice_count(self) -> int: ... + + def start_rename(self, voice_id: str) -> None: ... + + +class VoicesMenu(CallbackMixin): + """Every menu the voice list offers, wherever a reader raised it. + + Two sections make up the whole: the ways a voice comes into the pool, and what the voice a + reader named can do. A door composes the sections it wants — the list's own menu prints the + pool alone, a row prints both, the menu bar's **Edit** menu prints the voice's actions and the + **Voice** group prints the pool above them — so an action is stated once and reaches all of + them. + + What a menu offers about a voice is asked for as it opens: the byte figures, the instruments an + export would write, the channels a new instrument could be written from. Reading them at that + moment keeps what a menu prints and what a click does one answer. + """ + + def __init__( + self, + panel: VoicesMenuHost, + *, + language_manager: LanguageManager, + shortcut_source: ShortcutSource, + detail_color: BaseColor, + ) -> None: + self._panel = panel + self._language_manager = language_manager + self._shortcuts = shortcut_source + self._detail_color = detail_color + self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) + self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) + self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) + + def show_for(self, target: VoiceSelection) -> None: + """Raises the menu of one voice: what it is, what it costs, and what can be done with it. + + A row is where a reader already is, so the ways a voice comes in stay within reach below + the voice's own actions. + """ + with context_menu(): + header = dpg.add_text(target.label) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + add_detail_items( + self._footprint_items(target.voice_id), + color=self._detail_color, + tooltip=self._tip_size_bytes, + ) + dpg.add_separator() + add_play_menu_item( + context_label(self._language_manager, ContextElements.PLAY), + lambda: self.call( + self._panel.on_play_requested, + target.voice_id, + ), + ) + dpg.add_separator() + self.add_action_items(target) + dpg.add_separator() + self.add_pool_items() + + def show_pool(self) -> None: + """Raises the list's own menu, which prints the ways a voice comes in.""" + with context_menu(): + self.add_pool_items() + + def add_pool_items(self) -> None: + """Builds the ways a voice comes into the pool, in the order each menu prints them. + + A voice is written by hand, converted from a recording, or brought from a tracker, and + the three stand apart from the actions a listed voice offers, since each answers with an + entry the list did not hold. Every door onto the list prints this section, so a reader + reaches it from the list and from a row alike. + """ + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.NEW_INSTRUMENT), + shortcut=self._shortcuts.display(ShortcutId.NEW_INSTRUMENT), + callback=lambda: self.call(self._panel.on_new_instrument_requested), + ) + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.ADD_SAMPLE), + shortcut=self._shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE), + callback=lambda: self.call(self._panel.on_add_sample_requested), + ) + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.IMPORT_INSTRUMENT), + shortcut=self._shortcuts.display(ShortcutId.IMPORT_INSTRUMENT), + callback=lambda: self.call(self._panel.on_import_instrument_requested), + ) + + def add_action_items(self, target: VoiceSelection) -> None: + """Builds every action a voice offers, in the order each menu prints them. + + The panel states its actions once, and whoever asks for them decides where they are shown: + the row menu asks for the voice a pointer landed on, and the menu bar asks for the one the + selection holds. An action added here reaches both, printing the key it answers to. + """ + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.CONTEXT_EDIT), + callback=lambda: self.call(self._panel.on_sample_edit_requested, target.voice_id), + ) + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.CONTEXT_RENAME), + shortcut=self._shortcuts.display(ShortcutId.VOICES_RENAME_VOICE), + callback=lambda: self._panel.start_rename(target.voice_id), + ) + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.CONTEXT_DUPLICATE), + callback=lambda: self.call(self._panel.on_duplicate_requested, target.voice_id), + ) + self._add_instrument_from_items(target) + dpg.add_separator() + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.CONTEXT_REMOVE), + shortcut=self._shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE), + callback=lambda: self.call(self._panel.on_remove_requested, target.voice_id), + ) + dpg.add_separator() + for move in VOICE_MOVES: + self._add_move_item(move, target) + + dpg.add_separator() + self._add_export_items(target) + + def _add_instrument_from_items(self, target: VoiceSelection) -> None: + """Offers the channels of the voice a new instrument can be written from. + + A recording's channel carries frames of its own, so each of them makes a voice the reader + can edit as envelopes. The channel names what the new instrument plays, so it is always the + submenu that says which one, however few of them the voice offers. A voice that is already + a set of envelopes offers the item unreachable, which says the action exists while leaving + it where it belongs. + """ + label = self._label(SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM) + channels = self.query(self._panel.instrument_channels, target.voice_id, default=NO_CHANNELS) + if not channels: + dpg.add_menu_item(label=label, enabled=False) + return + + with dpg.menu(label=label): + for channel_name in channels: + self._add_instrument_from_channel_item(target, channel_name) + + def _add_instrument_from_channel_item( + self, + target: VoiceSelection, + channel_name: ChannelName, + ) -> None: + dpg.add_menu_item( + label=channel_label(self._language_manager, channel_name), + callback=lambda: self.call( + self._panel.on_instrument_from_channel_requested, + target.voice_id, + channel_name, + ), + ) + + def _add_export_items(self, target: VoiceSelection) -> None: + """Offers the instruments the voice would be written as, however many of them it holds. + + The menu asks how many instruments the voice contains rather than which kind of voice it + is, so one item stands where there is nothing to choose and a channel submenu stands where + a reader picks between slices. A voice writing nothing offers the item unreachable, which + says an export exists without pretending this voice has one. + """ + label = self._label(SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT) + channels = self.query(self._panel.voice_instruments, target.voice_id, default=NO_INSTRUMENTS) + if not channels: + dpg.add_menu_item(label=label, enabled=False) + return + + if len(channels) > 1: + with dpg.menu(label=label): + for channel_name in channels: + self._add_export_channel_item(target, channel_name) + + return + + dpg.add_menu_item( + label=label, + callback=lambda: self._request_export(target.voice_id, channels[0]), + ) + + def _add_export_channel_item( + self, + target: VoiceSelection, + channel_name: Optional[ChannelName], + ) -> None: + """Offers one of a voice's instruments, under the name that instrument carries.""" + dpg.add_menu_item( + label=self._instrument_label(target, channel_name), + callback=lambda: self._request_export( + target.voice_id, + channel_name, + ), + ) + + def _instrument_label( + self, + target: VoiceSelection, + channel_name: Optional[ChannelName], + ) -> str: + """The name one of a voice's instruments is listed under. + + A slice is named by the channel it was reconstructed for, which is what tells a voice's + slices apart; an instrument stated for every channel alike is named after the voice. + """ + if channel_name is None: + return target.name + + return channel_label(self._language_manager, channel_name) + + def _request_export( + self, + voice_id: str, + channel_name: Optional[ChannelName], + ) -> None: + self.call( + self._panel.on_export_instrument_requested, + voice_id, + channel_name, + ) + + def _add_move_item( + self, + move: VoiceMove, + target: VoiceSelection, + ) -> None: + """Builds one move item, offered while the move carries the voice somewhere new.""" + position = move.direction.target(target.position, self._panel.voice_count) + dpg.add_menu_item( + label=self._label(move.element), + shortcut=self._shortcuts.display(move.shortcut), + enabled=position is not None, + callback=lambda: self.call( + self._panel.on_move_requested, + target.voice_id, + position, + ), + ) + + def _footprint_items( + self, + voice_id: str, + ) -> List[Tuple[str, str]]: + """The byte figures the menu prints for a sample: its total, then each channel that plays. + + The figures are asked for as the menu opens, so they name what the sample occupies at the + moment a reader looks. A channel standing by is written by no export, so it costs nothing + and the menu names the channels that do. + """ + footprint = self.query( + self._panel.sample_footprint, + voice_id, + default=None, + ) + if footprint is None: + return [] + + items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))] + for channel_name in ChannelName.items(): + instrument_bytes = footprint.bytes_for(channel_name) + if instrument_bytes is not None: + items.append( + ( + channel_label(self._language_manager, channel_name), + self._format_size(instrument_bytes), + ) + ) + + return items + + def _format_size(self, byte_count: int) -> str: + return self._tpl_size_bytes.format(bytes=byte_count) + + def _label(self, element: SequencerVoicesElements) -> str: + return self._language_manager[ + Page.SEQUENCER, + Panel.VOICES, + TextType.LABEL, + element, + ] diff --git a/src/sampletones_application/ui/panels/sequencer/voices/moves.py b/src/sampletones_application/ui/panels/sequencer/voices/moves.py new file mode 100644 index 000000000..bc711522c --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/voices/moves.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Dict, Final, Tuple + +from sampletones_application.categories.elements.sequencer import ( + SequencerVoicesElements, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.move import MoveDirection + + +@dataclass(frozen=True) +class VoiceMove: + """One of the four moves, as its key press and its menu item each name it.""" + + element: SequencerVoicesElements + shortcut: ShortcutId + direction: MoveDirection + + +VOICE_MOVES: Final[Tuple[VoiceMove, ...]] = ( + VoiceMove( + element=SequencerVoicesElements.CONTEXT_MOVE_UP, + shortcut=ShortcutId.VOICES_MOVE_VOICE_UP, + direction=MoveDirection.PREVIOUS, + ), + VoiceMove( + element=SequencerVoicesElements.CONTEXT_MOVE_DOWN, + shortcut=ShortcutId.VOICES_MOVE_VOICE_DOWN, + direction=MoveDirection.NEXT, + ), + VoiceMove( + element=SequencerVoicesElements.CONTEXT_MOVE_TOP, + shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_TOP, + direction=MoveDirection.FIRST, + ), + VoiceMove( + element=SequencerVoicesElements.CONTEXT_MOVE_BOTTOM, + shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_BOTTOM, + direction=MoveDirection.LAST, + ), +) + +MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in VOICE_MOVES} diff --git a/src/sampletones_application/ui/panels/sequencer/voices.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py similarity index 70% rename from src/sampletones_application/ui/panels/sequencer/voices.py rename to src/sampletones_application/ui/panels/sequencer/voices/panel.py index 40dd74b44..ebaf08674 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -1,12 +1,7 @@ -# TODO: refactor into a subpackage - -from dataclasses import dataclass -from typing import Callable, Dict, Final, List, Optional, Tuple +from typing import Callable, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import channel_label, context_label, context_text -from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( SequencerVoicesElements, ) @@ -23,14 +18,11 @@ TAG_SEQUENCER_VOICES_THEME_ROW, TAG_SEQUENCER_VOICES_WINDOW, ) -from sampletones_application.ui.elements.context_menu import ( - add_detail_items, - add_play_menu_item, - context_menu, -) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.ui.panels.sequencer.voices.menu import VoicesMenu +from sampletones_application.ui.panels.sequencer.voices.moves import MOVE_DIRECTIONS from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_delete_children, dpg_pointer_within_window from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -44,7 +36,6 @@ from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_application.view_model.sequencer.move import MoveDirection from sampletones_application.view_model.sequencer.voices import ( SequencerVoicesViewModel, VoiceEntryViewModel, @@ -59,43 +50,6 @@ FROZEN_HEADER_ROWS: Final[int] = 1 -NO_INSTRUMENTS: Final[Tuple[Optional[ChannelName], ...]] = () - - -@dataclass(frozen=True) -class SampleMove: - """One of the four moves, as its key press and its menu item each name it.""" - - element: SequencerVoicesElements - shortcut: ShortcutId - direction: MoveDirection - - -VOICE_MOVES: Final[Tuple[SampleMove, ...]] = ( - SampleMove( - element=SequencerVoicesElements.CONTEXT_MOVE_UP, - shortcut=ShortcutId.VOICES_MOVE_VOICE_UP, - direction=MoveDirection.PREVIOUS, - ), - SampleMove( - element=SequencerVoicesElements.CONTEXT_MOVE_DOWN, - shortcut=ShortcutId.VOICES_MOVE_VOICE_DOWN, - direction=MoveDirection.NEXT, - ), - SampleMove( - element=SequencerVoicesElements.CONTEXT_MOVE_TOP, - shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_TOP, - direction=MoveDirection.FIRST, - ), - SampleMove( - element=SequencerVoicesElements.CONTEXT_MOVE_BOTTOM, - shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_BOTTOM, - direction=MoveDirection.LAST, - ), -) - -MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in VOICE_MOVES} - class GUISequencerVoicesPanel(GUIPanel): def __init__( @@ -111,7 +65,6 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout - self._detail_color = detail_color self._router = key_router self._tab_active = tab_active self._shortcuts = shortcut_source @@ -123,14 +76,12 @@ def __init__( self._selected_row: Optional[int] = None self._editing_voice_id: Optional[str] = None self._entries: Tuple[VoiceEntryViewModel, ...] = () - self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) - self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) - self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT) self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT) self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] = None + self.instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None self.on_loop_changed: Optional[Callable[[str, bool], None]] = None @@ -143,6 +94,13 @@ def __init__( self.on_add_sample_requested: Optional[VoidCallback] = None self.on_import_instrument_requested: Optional[VoidCallback] = None self.on_export_instrument_requested: Optional[Callable[[str, Optional[ChannelName]], None]] = None + self.on_instrument_from_channel_requested: Optional[Callable[[str, ChannelName], None]] = None + self._menu = VoicesMenu( + self, + language_manager=language_manager, + shortcut_source=shortcut_source, + detail_color=detail_color, + ) super().__init__( tag=TAG_SEQUENCER_VOICES_PANEL, @@ -505,7 +463,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: case ShortcutId.VOICES_REMOVE_VOICE: self.call(self.on_remove_requested, voice_id) case ShortcutId.VOICES_RENAME_VOICE: - self._start_rename(voice_id) + self.start_rename(voice_id) case _: return False @@ -539,7 +497,7 @@ def _move_voice(self, shortcut_id: ShortcutId) -> bool: return True - def _start_rename(self, voice_id: str) -> None: + def start_rename(self, voice_id: str) -> None: """Turns the sample's name cell into a focused text input.""" if self._entry_for(voice_id) is None: return @@ -650,70 +608,30 @@ def _show_list_menu(self) -> None: return self._list_menu_pending = False - with context_menu(): - self.add_pool_items() + self._menu.show_pool() def _entry_for(self, voice_id: str) -> Optional[VoiceEntryViewModel]: return next((entry for entry in self._entries if entry.voice_id == voice_id), None) def _show_context_menu(self, position: int, voice_id: str) -> None: + """Raises the menu of the row a press landed on, for the voice that row holds.""" entry = self._entry_for(voice_id) if entry is None: return - target = VoiceSelection( - voice_id=voice_id, - position=position, - name=entry.name, - kind=entry.kind, - ) - with context_menu(): - header = dpg.add_text(target.label) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - add_detail_items( - self._footprint_items(voice_id), - color=self._detail_color, - tooltip=self._tip_size_bytes, - ) - dpg.add_separator() - add_play_menu_item( - context_label(self._language_manager, ContextElements.PLAY), - lambda: self.call( - self.on_play_requested, - voice_id, - ), + self._menu.show_for( + VoiceSelection( + voice_id=voice_id, + position=position, + name=entry.name, + kind=entry.kind, ) - dpg.add_separator() - self.add_action_items(target) - dpg.add_separator() - self.add_pool_items() - - def _footprint_items(self, voice_id: str) -> List[Tuple[str, str]]: - """The byte figures the menu prints for a sample: its total, then each channel that plays. + ) - The figures are asked for as the menu opens, so they name what the sample occupies at the - moment a reader looks. A channel standing by is written by no export, so it costs nothing - and the menu names the channels that do. - """ - footprint = self.query(self.sample_footprint, voice_id, default=None) - if footprint is None: - return [] - - items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))] - for channel_name in ChannelName.items(): - instrument_bytes = footprint.bytes_for(channel_name) - if instrument_bytes is not None: - items.append( - ( - channel_label(self._language_manager, channel_name), - self._format_size(instrument_bytes), - ) - ) - - return items - - def _format_size(self, byte_count: int) -> str: - return self._tpl_size_bytes.format(bytes=byte_count) + @property + def voice_count(self) -> int: + """How many voices the list holds, which is what says where a move can carry one.""" + return len(self._entries) def owns_edit_actions(self) -> bool: """Whether the Edit menu states this panel's actions, which it does while it holds a sample. @@ -724,10 +642,10 @@ def owns_edit_actions(self) -> bool: return self._keys_active() and self.selection is not None def build_edit_actions(self) -> None: - """Builds the panel's whole action set for the sample the selection holds.""" + """Builds the panel's whole action set for the voice the selection holds.""" selection = self.selection if selection is not None: - self.add_action_items(selection) + self._menu.add_action_items(selection) def build_voice_actions(self) -> None: """States the actions of the voice the list holds, for a menu listing the pool above them. @@ -741,157 +659,7 @@ def build_voice_actions(self) -> None: return dpg.add_separator() - self.add_action_items(selection) - - def add_pool_items(self) -> None: - """Builds the ways a voice comes into the pool, in the order each menu prints them. - - A voice is written by hand, converted from a recording, or brought from a tracker, and - the three stand apart from the actions a listed voice offers, since each answers with an - entry the list did not hold. Every door onto the list prints this section, so a reader - reaches it from the list and from a row alike. - """ - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.NEW_INSTRUMENT, - ), - shortcut=self._shortcuts.display(ShortcutId.NEW_INSTRUMENT), - callback=lambda: self.call(self.on_new_instrument_requested), - ) - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.ADD_SAMPLE, - ), - shortcut=self._shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE), - callback=lambda: self.call(self.on_add_sample_requested), - ) - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.IMPORT_INSTRUMENT, - ), - shortcut=self._shortcuts.display(ShortcutId.IMPORT_INSTRUMENT), - callback=lambda: self.call(self.on_import_instrument_requested), - ) - - def add_action_items(self, target: VoiceSelection) -> None: - """Builds every action a sample offers, in the order each menu prints them. - - The panel states its actions once, and whoever asks for them decides where they are shown: - the row menu asks for the sample a pointer landed on, and the menu bar asks for the one the - selection holds. An action added here reaches both, printing the key it answers to. - """ - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.CONTEXT_EDIT, - ), - callback=lambda: self.call(self.on_sample_edit_requested, target.voice_id), - ) - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.CONTEXT_RENAME, - ), - shortcut=self._shortcuts.display(ShortcutId.VOICES_RENAME_VOICE), - callback=lambda: self._start_rename(target.voice_id), - ) - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.CONTEXT_DUPLICATE, - ), - callback=lambda: self.call(self.on_duplicate_requested, target.voice_id), - ) - dpg.add_separator() - dpg.add_menu_item( - label=self._label( - self._language_manager, - SequencerVoicesElements.CONTEXT_REMOVE, - ), - shortcut=self._shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE), - callback=lambda: self.call(self.on_remove_requested, target.voice_id), - ) - dpg.add_separator() - for move in VOICE_MOVES: - self._add_move_item(move, target) - - dpg.add_separator() - self._add_export_items(target) - - def _add_export_items(self, target: VoiceSelection) -> None: - """Offers the instruments the voice would be written as, however many of them it holds. - - The menu asks how many instruments the voice contains rather than which kind of voice it - is, so one item stands where there is nothing to choose and a channel submenu stands where - a reader picks between slices. A voice writing nothing offers the item unreachable, which - says an export exists without pretending this voice has one. - """ - label = self._label(self._language_manager, SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT) - channels = self.query(self.voice_instruments, target.voice_id, default=NO_INSTRUMENTS) - if not channels: - dpg.add_menu_item(label=label, enabled=False) - return - - if len(channels) > 1: - with dpg.menu(label=label): - for channel_name in channels: - self._add_export_channel_item(target, channel_name) - return - - dpg.add_menu_item( - label=label, - callback=lambda: self._request_export(target.voice_id, channels[0]), - ) - - def _add_export_channel_item( - self, - target: VoiceSelection, - channel_name: Optional[ChannelName], - ) -> None: - """Offers one of a voice's instruments, under the name that instrument carries.""" - dpg.add_menu_item( - label=self._instrument_label(target, channel_name), - callback=lambda: self._request_export(target.voice_id, channel_name), - ) - - def _instrument_label( - self, - target: VoiceSelection, - channel_name: Optional[ChannelName], - ) -> str: - """The name one of a voice's instruments is listed under. - - A slice is named by the channel it was reconstructed for, which is what tells a voice's - slices apart; an instrument stated for every channel alike is named after the voice. - """ - if channel_name is None: - return target.name - - return channel_label(self._language_manager, channel_name) - - def _request_export(self, voice_id: str, channel_name: Optional[ChannelName]) -> None: - self.call(self.on_export_instrument_requested, voice_id, channel_name) - - def _add_move_item( - self, - move: SampleMove, - target: VoiceSelection, - ) -> None: - """Builds one move item, offered while the move carries the sample somewhere new.""" - position = move.direction.target(target.position, len(self._entries)) - dpg.add_menu_item( - label=self._label(self._language_manager, move.element), - shortcut=self._shortcuts.display(move.shortcut), - enabled=position is not None, - callback=lambda: self.call( - self.on_move_requested, - target.voice_id, - position, - ), - ) + self._menu.add_action_items(selection) @staticmethod def _label( diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index e8cb0cc54..2d289b2bc 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -614,6 +614,7 @@ sequencer.voices.label.context_move_up: "Move up" sequencer.voices.label.context_move_down: "Move down" sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" +sequencer.voices.label.context_instrument_from: "New instrument from" sequencer.voices.label.context_export_instrument: "Export instrument..." sequencer.voices.label.omission_pitch: "a pitch envelope" sequencer.voices.label.omission_hi_pitch: "a hi-pitch envelope" diff --git a/src/sampletones_core/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py index af15dee4a..a454db288 100644 --- a/src/sampletones_core/exporters/__init__.py +++ b/src/sampletones_core/exporters/__init__.py @@ -1,5 +1,5 @@ from .exporter import Exporter -from .feature import Features +from .feature import Features, playing_channels from .implementation.noise import NoiseExporter from .implementation.pulse import PulseExporter from .implementation.triangle import TriangleExporter @@ -18,4 +18,5 @@ "NoiseExporter", "PulseExporter", "TriangleExporter", + "playing_channels", ] diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 54f1bba48..67459c718 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -1,143 +1,159 @@ -from __future__ import annotations - -from typing import Any, Dict, Iterable, List, Optional, Tuple, cast - -import numpy as np -from pydantic import BaseModel, ConfigDict - -from sampletones_core.constants.enums import FeatureKey -from sampletones_core.types.feature import FeatureMap, FeatureValue - - -class Features(BaseModel): - """ - The per-dimension envelopes describing one FamiTracker instrument. - - Each field is the frame-by-frame envelope for one dimension — volume, arpeggio, - pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio - envelope is relative to. A dimension the channel offers is an array, ``None`` for - one it lacks; an array of no items marks a dimension the instrument leaves to the - channel, which keeps the value it holds. The mapping interface (subscript, ``get``, - ``keys``/``items``/``values``, ``in``) exposes the envelopes keyed by - :class:`FeatureKey`, listing the dimensions the channel offers. - - Attributes: - initial_pitch: Reference pitch the arpeggio envelope is measured against. - volume: Volume envelope. - arpeggio: Arpeggio (relative pitch) envelope. - pitch: Pitch envelope, or ``None`` when unused. - hi_pitch: Fine-pitch envelope, or ``None`` when unused. - duty_cycle: Duty-cycle envelope, or ``None`` when unused. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True) - - initial_pitch: int - volume: np.ndarray - arpeggio: np.ndarray - pitch: Optional[np.ndarray] - hi_pitch: Optional[np.ndarray] - duty_cycle: Optional[np.ndarray] - - @classmethod - def from_feature_map( - cls, - feature_map: FeatureMap, - ) -> Features: - """Builds features from a raw feature map. - - Args: - feature_map: The per-dimension arrays keyed by :class:`FeatureKey`. - - Returns: - Features: The features carrying those envelopes. - """ - return cls( - initial_pitch=cast(int, feature_map[FeatureKey.INITIAL_PITCH]), - volume=cast(np.ndarray, feature_map[FeatureKey.VOLUME]), - arpeggio=cast(np.ndarray, feature_map[FeatureKey.ARPEGGIO]), - pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.PITCH)), - hi_pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.HI_PITCH)), - duty_cycle=cast(Optional[np.ndarray], feature_map.get(FeatureKey.DUTY_CYCLE)), - ) - - @property - def feature_map(self) -> Dict[FeatureKey, Optional[FeatureValue]]: - return { - FeatureKey.INITIAL_PITCH: self.initial_pitch, - FeatureKey.VOLUME: self.volume, - FeatureKey.ARPEGGIO: self.arpeggio, - FeatureKey.PITCH: self.pitch, - FeatureKey.HI_PITCH: self.hi_pitch, - FeatureKey.DUTY_CYCLE: self.duty_cycle, - } - - def __getitem__(self, feature_key: FeatureKey) -> FeatureValue: - value = self.feature_map.get(feature_key) - if value is None: - raise KeyError(feature_key) - return value - - def __setitem__(self, feature_key: FeatureKey, value: FeatureValue) -> None: - if feature_key == FeatureKey.INITIAL_PITCH: - if not isinstance(value, int): - raise TypeError(f"Expected int for {feature_key}, got {type(value)}") - else: - if not isinstance(value, np.ndarray): - raise TypeError(f"Expected np.ndarray for {feature_key}, got {type(value)}") - - setattr(self, feature_key.name.lower(), value) - - def __contains__(self, feature_key: FeatureKey) -> bool: - return feature_key in self.feature_map and self.feature_map[feature_key] is not None - - def get(self, feature_key: FeatureKey, default: Optional[Any] = None) -> Optional[FeatureValue]: - return self.feature_map.get(feature_key, default) - - def keys(self) -> List[FeatureKey]: - return [key for key, value in self.feature_map.items() if value is not None] - - def items(self) -> List[Tuple[FeatureKey, FeatureValue]]: - return [(key, value) for key, value in self.feature_map.items() if value is not None] - - def values(self) -> List[FeatureValue]: - return [value for value in self.feature_map.values() if value is not None] - - @property - def frame_count(self) -> int: - """The frame count the envelopes describe, taken from the longest populated dimension.""" - arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) - return max((len(array) for array in arrays if array is not None), default=0) - - @property - def has_frames(self) -> bool: - """Whether the envelopes describe a frame, which is what a channel plays. - - Every dimension left to the channel leaves an instrument describing nothing, so this - is what tells a channel that sounds from one that stands by: an export writes the - instruments that have frames, and the driver stores only those. - """ - return self.frame_count > 0 - - @property - def held_features(self) -> Tuple[FeatureKey, ...]: - """The dimensions the channel governs, whose envelopes carry no item. - - An instrument writes the dimensions it describes and leaves the rest to the channel, - which keeps the value it already holds for as long as the instrument sounds. These - are the dimensions it leaves, listed in the order the model declares them. - """ - return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) - - def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None: - """Empties the envelope of each named dimension the channel offers, so the channel governs it. - - The dimensions a channel offers are the ones it can hold a value for, so the record acts - on those and leaves the shape of the features as the channel defines it. - - Args: - feature_keys: The dimensions the instrument leaves to the channel. - """ - for feature_key in feature_keys: - if feature_key in self: - self[feature_key] = np.array([], dtype=np.int8) +from __future__ import annotations + +from typing import Any, Dict, FrozenSet, Iterable, List, Mapping, Optional, Tuple, cast + +import numpy as np +from pydantic import BaseModel, ConfigDict + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.types.feature import FeatureMap, FeatureValue + + +class Features(BaseModel): + """ + The per-dimension envelopes describing one FamiTracker instrument. + + Each field is the frame-by-frame envelope for one dimension — volume, arpeggio, + pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio + envelope is relative to. A dimension the channel offers is an array, ``None`` for + one it lacks; an array of no items marks a dimension the instrument leaves to the + channel, which keeps the value it holds. The mapping interface (subscript, ``get``, + ``keys``/``items``/``values``, ``in``) exposes the envelopes keyed by + :class:`FeatureKey`, listing the dimensions the channel offers. + + Attributes: + initial_pitch: Reference pitch the arpeggio envelope is measured against. + volume: Volume envelope. + arpeggio: Arpeggio (relative pitch) envelope. + pitch: Pitch envelope, or ``None`` when unused. + hi_pitch: Fine-pitch envelope, or ``None`` when unused. + duty_cycle: Duty-cycle envelope, or ``None`` when unused. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + initial_pitch: int + volume: np.ndarray + arpeggio: np.ndarray + pitch: Optional[np.ndarray] + hi_pitch: Optional[np.ndarray] + duty_cycle: Optional[np.ndarray] + + @classmethod + def from_feature_map( + cls, + feature_map: FeatureMap, + ) -> Features: + """Builds features from a raw feature map. + + Args: + feature_map: The per-dimension arrays keyed by :class:`FeatureKey`. + + Returns: + Features: The features carrying those envelopes. + """ + return cls( + initial_pitch=cast(int, feature_map[FeatureKey.INITIAL_PITCH]), + volume=cast(np.ndarray, feature_map[FeatureKey.VOLUME]), + arpeggio=cast(np.ndarray, feature_map[FeatureKey.ARPEGGIO]), + pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.PITCH)), + hi_pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.HI_PITCH)), + duty_cycle=cast(Optional[np.ndarray], feature_map.get(FeatureKey.DUTY_CYCLE)), + ) + + @property + def feature_map(self) -> Dict[FeatureKey, Optional[FeatureValue]]: + return { + FeatureKey.INITIAL_PITCH: self.initial_pitch, + FeatureKey.VOLUME: self.volume, + FeatureKey.ARPEGGIO: self.arpeggio, + FeatureKey.PITCH: self.pitch, + FeatureKey.HI_PITCH: self.hi_pitch, + FeatureKey.DUTY_CYCLE: self.duty_cycle, + } + + def __getitem__(self, feature_key: FeatureKey) -> FeatureValue: + value = self.feature_map.get(feature_key) + if value is None: + raise KeyError(feature_key) + return value + + def __setitem__(self, feature_key: FeatureKey, value: FeatureValue) -> None: + if feature_key == FeatureKey.INITIAL_PITCH: + if not isinstance(value, int): + raise TypeError(f"Expected int for {feature_key}, got {type(value)}") + else: + if not isinstance(value, np.ndarray): + raise TypeError(f"Expected np.ndarray for {feature_key}, got {type(value)}") + + setattr(self, feature_key.name.lower(), value) + + def __contains__(self, feature_key: FeatureKey) -> bool: + return feature_key in self.feature_map and self.feature_map[feature_key] is not None + + def get(self, feature_key: FeatureKey, default: Optional[Any] = None) -> Optional[FeatureValue]: + return self.feature_map.get(feature_key, default) + + def keys(self) -> List[FeatureKey]: + return [key for key, value in self.feature_map.items() if value is not None] + + def items(self) -> List[Tuple[FeatureKey, FeatureValue]]: + return [(key, value) for key, value in self.feature_map.items() if value is not None] + + def values(self) -> List[FeatureValue]: + return [value for value in self.feature_map.values() if value is not None] + + @property + def frame_count(self) -> int: + """The frame count the envelopes describe, taken from the longest populated dimension.""" + arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) + return max((len(array) for array in arrays if array is not None), default=0) + + @property + def has_frames(self) -> bool: + """Whether the envelopes describe a frame, which is what a channel plays. + + Every dimension left to the channel leaves an instrument describing nothing, so this + is what tells a channel that sounds from one that stands by: an export writes the + instruments that have frames, and the driver stores only those. + """ + return self.frame_count > 0 + + @property + def held_features(self) -> Tuple[FeatureKey, ...]: + """The dimensions the channel governs, whose envelopes carry no item. + + An instrument writes the dimensions it describes and leaves the rest to the channel, + which keeps the value it already holds for as long as the instrument sounds. These + are the dimensions it leaves, listed in the order the model declares them. + """ + return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) + + def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None: + """Empties the envelope of each named dimension the channel offers, so the channel governs it. + + The dimensions a channel offers are the ones it can hold a value for, so the record acts + on those and leaves the shape of the features as the channel defines it. + + Args: + feature_keys: The dimensions the instrument leaves to the channel. + """ + for feature_key in feature_keys: + if feature_key in self: + self[feature_key] = np.array([], dtype=np.int8) + + +def playing_channels(channels: Mapping[ChannelName, Features]) -> FrozenSet[ChannelName]: + """The channels among ``channels`` whose envelopes describe a frame. + + Describing a frame is what puts a channel in play: those are the ones an export writes, the + ones a footprint measures and the ones a panel offers, while the rest stand by. Stating the + rule once has every reader of a channel's envelopes agree on which of them sound. + + Args: + channels: The envelopes each channel plays. + + Returns: + FrozenSet[ChannelName]: The channels that sound. + """ + return frozenset(channel_name for channel_name, features in channels.items() if features.has_frames) diff --git a/src/sampletones_core/project/voices/creation.py b/src/sampletones_core/project/voices/creation.py index bf1521fa3..cd34b6170 100644 --- a/src/sampletones_core/project/voices/creation.py +++ b/src/sampletones_core/project/voices/creation.py @@ -1,6 +1,15 @@ -from typing import Final +from typing import Final, Optional, Tuple +import numpy as np + +from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.exporters.feature import Features +from sampletones_core.features import ( + RESTING_REFERENCE_PERIOD, + RESTING_REFERENCE_PITCH, + speaks_in_periods, +) from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT @@ -26,3 +35,64 @@ def new_instrument(name: str) -> Instrument: envelopes=SUSTAINING_ENVELOPES, loop_point=WHOLE_LOOP_POINT, ) + + +def instrument_from_features( + name: str, + features: Features, + channel_name: ChannelName, + *, + loop_point: Optional[int], +) -> Instrument: + """An instrument carrying what one channel plays, in envelopes the reader can edit. + + The envelopes come across as they stand, so the voice sounds on the channel it came from what + that channel sounded; a dimension the channel governs stays governed wherever the voice is + placed next. The channel's own reference becomes the root it was measured against — a period on + noise and a note elsewhere — and the root the other channels read rests where a voice added by + hand rests, so the voice stands somewhere sensible on all of them. + + Args: + name: The name the voice list shows. + features: The envelopes the channel plays. + channel_name: The channel those envelopes were measured for. + loop_point: The tick the envelopes repeat from, or ``None`` where they play once. + + Returns: + Instrument: The voice those envelopes describe. + """ + return Instrument( + name=name, + envelopes=InstrumentEnvelopes( + volume=_envelope(features.volume), + arpeggio=_envelope(features.arpeggio), + duty_cycle=_envelope(features.duty_cycle), + ), + root_pitch=_root_pitch(channel_name, features.initial_pitch), + root_period=_root_period(channel_name, features.initial_pitch), + loop_point=loop_point, + ) + + +def _root_pitch(channel_name: ChannelName, reference: int) -> int: + """The note the tonal channels measure the arpeggio against, taken from ``reference`` where it is one.""" + if speaks_in_periods(channel_name): + return RESTING_REFERENCE_PITCH + + return reference + + +def _root_period(channel_name: ChannelName, reference: int) -> int: + """The period the noise channel measures the arpeggio against, taken from ``reference`` where it is one.""" + if speaks_in_periods(channel_name): + return reference + + return RESTING_REFERENCE_PERIOD + + +def _envelope(items: Optional[np.ndarray]) -> Tuple[int, ...]: + """One dimension as a voice states it, empty where the channel governs it.""" + if items is None: + return () + + return tuple(int(item) for item in items) diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py index 613d8ae5e..a57e4919b 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_instrument.py +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -11,7 +11,7 @@ InstrumentExportCoordinator, ) from sampletones_application.exports import build_export_backends -from sampletones_application.logic.export.instrument import ( +from sampletones_application.logic.export.instrument.source import ( ExportableInstrument, exportable_instrument, voice_entries, diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index e61a9f96b..d4f210ae4 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -333,6 +333,72 @@ def test_used_sample_prompts_confirmation_before_removing( logic.remove_voice.assert_called_once_with("abc") +class TestTakingAChannelAsAnInstrument: + """A channel of a recording becomes a voice of its own, recorded and brought up to edit.""" + + @staticmethod + def _taken(samples_coordinator: SequencerTabCoordinator) -> Instrument: + instrument = Instrument(name="Bass (triangle)", envelopes=InstrumentEnvelopes(volume=(15,))) + samples_coordinator._sequencer_voices_logic.instrument_from_channel.return_value = instrument + return instrument + + def test_the_channel_named_is_the_one_taken( + self, + samples_coordinator: SequencerTabCoordinator, + ) -> None: + self._taken(samples_coordinator) + + samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + + samples_coordinator._sequencer_voices_logic.instrument_from_channel.assert_called_once_with( + "bass-id", + ChannelName.TRIANGLE, + ) + + def test_the_new_voice_lands_in_the_pool( + self, + samples_coordinator: SequencerTabCoordinator, + ) -> None: + instrument = self._taken(samples_coordinator) + + samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + + samples_coordinator._sequencer_voices_logic.add_instrument.assert_called_once_with(instrument) + + def test_the_history_names_the_voice_that_arrived( + self, + samples_coordinator: SequencerTabCoordinator, + ) -> None: + instrument = self._taken(samples_coordinator) + + samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + + samples_coordinator._history_detail.add_instrument.assert_called_once_with(instrument.name) + assert samples_coordinator._history.transaction.call_args.args[0] is HistoryAction.ADD_INSTRUMENT + + def test_the_new_voice_is_brought_up_where_it_is_edited( + self, + samples_coordinator: SequencerTabCoordinator, + ) -> None: + """Seeing the envelopes that came across is what taking the channel out was for.""" + instrument = self._taken(samples_coordinator) + + samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + + samples_coordinator._sequencer_voices_logic.request_edit.assert_called_once_with(instrument.id) + + def test_a_channel_that_plays_nothing_leaves_the_pool_as_it_stands( + self, + samples_coordinator: SequencerTabCoordinator, + ) -> None: + samples_coordinator._sequencer_voices_logic.instrument_from_channel.return_value = None + + samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.NOISE) + + samples_coordinator._sequencer_voices_logic.add_instrument.assert_not_called() + samples_coordinator._history.transaction.assert_not_called() + + class TestSubmitRename: def test_submit_rename_trims_whitespace( self, diff --git a/tests/unit/sampletones_application/logic/export/instrument/__init__.py b/tests/unit/sampletones_application/logic/export/instrument/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/export/instrument/test_logic.py b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py new file mode 100644 index 000000000..442db1d82 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Final, List +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.exports import build_export_backends +from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic +from sampletones_application.logic.export.instrument.source import ( + exportable_instrument, + voice_entries, +) +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.project.project import Project +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, + EXT_FILE_NSF, +) + +NO_EXTENSION: Final[str] = "" +REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments") + + +@dataclass(frozen=True) +class FormatCase: + extension: str + export_format: ExportFormat + + +FORMAT_CASES: Final[List[FormatCase]] = [ + FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER), + FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE), + FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET), + FormatCase(extension=EXT_FILE_NSF, export_format=ExportFormat.NSF), +] + +UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] + + +@pytest.fixture +def session_manager() -> MagicMock: + mock = MagicMock() + mock.get_instrument_path.return_value = REMEMBERED_DIRECTORY + return mock + + +@pytest.fixture +def export_service() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def export_backends() -> Dict[ExportFormat, MagicMock]: + """Stands in for the real backends while declaring the scopes and extensions they do.""" + backends: Dict[ExportFormat, MagicMock] = {} + for export_format, backend in build_export_backends().items(): + stub = MagicMock() + stub.supported_scopes = backend.supported_scopes + stub.extension.side_effect = backend.extension + backends[export_format] = stub + + return backends + + +@pytest.fixture +def project_controller() -> MagicMock: + mock = MagicMock() + mock.project = Project.create() + return mock + + +@pytest.fixture +def logic( + project_controller: MagicMock, + session_manager: MagicMock, + export_service: MagicMock, + export_backends: Dict[ExportFormat, MagicMock], +) -> InstrumentExportLogic: + return InstrumentExportLogic(project_controller, session_manager, export_service, export_backends) + + +def _source() -> InstrumentSource: + voice = new_instrument("Lead") + project = Project.create() + project.voices.append(voice) + return exportable_instrument(project, voice_entries(voice)[0]).source + + +class TestWritingOneInstrument: + """One path carries an instrument to disk, whichever surface asked for it.""" + + def test_the_service_is_asked_to_write_it( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + tmp_path: Path, + ) -> None: + logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source()) + + export_service.export_instrument.assert_called_once() + + def test_the_destination_names_the_instrument( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + tmp_path: Path, + ) -> None: + """Renaming the file in the save dialog renames the instrument the file carries.""" + logic.export(tmp_path / f"Clap (pulse1){EXT_FILE_INSTRUMENT}", _source()) + + request = export_service.export_instrument.call_args.args[2] + assert request.name == "Clap (pulse1)" + + @pytest.mark.parametrize("case", FORMAT_CASES, ids=lambda case: case.extension) + def test_the_extension_names_the_backend( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + export_backends: Dict[ExportFormat, MagicMock], + tmp_path: Path, + case: FormatCase, + ) -> None: + logic.export(tmp_path / f"instrument{case.extension}", _source()) + + backend = export_service.export_instrument.call_args.args[1] + assert backend is export_backends[case.export_format] + + def test_everything_the_source_states_reaches_the_request( + self, + logic: InstrumentExportLogic, + export_service: MagicMock, + tmp_path: Path, + ) -> None: + source = _source() + + logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", source) + + request = export_service.export_instrument.call_args.args[2] + assert (request.channel, request.features, request.loop_point) == ( + source.channel, + source.features, + source.loop_point, + ) + assert (request.nes_frequency, request.tuning) == (source.nes_frequency, source.tuning) + + def test_the_folder_it_landed_in_is_remembered( + self, + logic: InstrumentExportLogic, + session_manager: MagicMock, + tmp_path: Path, + ) -> None: + logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source()) + + session_manager.set_instrument_path.assert_called_once_with(tmp_path) + + def test_the_dialog_opens_where_the_last_one_landed(self, logic: InstrumentExportLogic) -> None: + assert logic.suggested_directory == REMEMBERED_DIRECTORY + + def test_every_backend_is_reachable_for_the_types_a_dialog_offers( + self, + logic: InstrumentExportLogic, + export_backends: Dict[ExportFormat, MagicMock], + ) -> None: + """The dialog names each format's own file type, which it reads off the backend.""" + assert dict(logic.backends) == export_backends + + @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) + def test_an_extension_no_format_writes_is_refused( + self, + logic: InstrumentExportLogic, + tmp_path: Path, + extension: str, + ) -> None: + """The dialog answers with one of the types it offered, so an extension naming no + format is a broken invariant rather than a choice to report. + """ + with pytest.raises(ValueError): + logic.export(tmp_path / f"instrument{extension}", _source()) + + +class TestWhatTheOpenProjectOffers: + """A menu names a voice alone, so the pool it belongs to is the one the logic holds.""" + + def test_a_sample_of_the_open_project_offers_each_channel_that_plays( + self, + logic: InstrumentExportLogic, + project_controller: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + project_controller.project.voices.append(sample) + + assert logic.voice_instruments(sample.id) == tuple(sample.reconstruction.playing_channels) + + def test_the_instrument_asked_for_comes_from_the_open_project( + self, + logic: InstrumentExportLogic, + project_controller: MagicMock, + ) -> None: + voice = new_instrument("Lead") + project_controller.project.voices.append(voice) + + exportable = logic.voice_instrument(voice.id, None) + + assert exportable is not None + assert exportable.name == "Lead" diff --git a/tests/unit/sampletones_application/logic/export/instrument/test_source.py b/tests/unit/sampletones_application/logic/export/instrument/test_source.py new file mode 100644 index 000000000..a27b75cc4 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/instrument/test_source.py @@ -0,0 +1,164 @@ +from typing import Callable + +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +from sampletones_application.logic.export.instrument.source import ( + exportable_instrument, + sounding_channel, + voice_entries, + voice_instrument, + voice_instrument_channels, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.request import InstrumentSource +from sampletones_core.project.project import Project +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.music import Tuning +from tests.suite.sequencer import sample_reconstruction + + +def _project(*voices: VoiceUnion) -> Project: + project = Project.create() + for voice in voices: + project.voices.append(voice) + + return project + + +class TestWhatAVoiceOffers: + """One rule answers what a voice contributes, so both kinds are exported the same way.""" + + def test_a_written_instrument_offers_one(self) -> None: + """One set of envelopes every channel reads is one instrument, as a module holds it.""" + voice = new_instrument("Lead") + + assert len(voice_entries(voice)) == 1 + + def test_a_written_instrument_is_named_after_itself(self) -> None: + voice = new_instrument("Lead") + + assert voice_entries(voice)[0].name == "Lead" + + def test_a_sample_offers_one_per_channel_that_plays( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + playing = sample.reconstruction.playing_channels + + assert [entry.channel for entry in voice_entries(sample)] == list(playing) + + def test_a_samples_slice_is_named_for_its_channel( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + + assert all(entry.name.startswith("Bass") for entry in voice_entries(sample)) + + def test_both_kinds_answer_with_the_same_shape( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """Whatever produced the envelopes is settled here, so one export path takes both.""" + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + written = new_instrument("Lead") + project = _project(sample, written) + + sources = [exportable_instrument(project, entry).source for entry in voice_entries(sample)] + sources += [exportable_instrument(project, entry).source for entry in voice_entries(written)] + + assert all(isinstance(source, InstrumentSource) for source in sources) + + def test_the_project_states_the_rate_and_the_tuning(self) -> None: + voice = new_instrument("Lead") + project = _project(voice) + + source = exportable_instrument(project, voice_entries(voice)[0]).source + + assert source.nes_frequency == project.settings.nes_frequency + assert source.tuning == Tuning() + + def test_a_voice_with_nothing_written_offers_nothing(self) -> None: + sample = Sample(name="Silent", reconstruction=sample_reconstruction(set())) + + assert voice_entries(sample) == () + + +class TestWhichChannelAFileSoundsItOn: + """A written file plays its instrument somewhere, and the entry states where or leaves it open.""" + + def test_a_samples_slice_sounds_on_the_channel_it_was_reconstructed_for( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + + for entry in voice_entries(sample): + assert sounding_channel(entry) is entry.channel + + def test_a_written_instrument_sounds_where_the_app_reads_it(self) -> None: + """Its envelopes name no channel, so the file plays them where its editor shows them.""" + voice = new_instrument("Lead") + + assert sounding_channel(voice_entries(voice)[0]) is INSTRUMENT_CHANNEL + + def test_a_written_instrument_is_measured_against_the_root_that_channel_reads(self) -> None: + """The channel it is sounded on and the reference its envelopes carry are one answer.""" + voice = new_instrument("Lead") + entry = voice_entries(voice)[0] + + assert entry.features.initial_pitch == voice.reference(sounding_channel(entry)) + + +class TestWhatOneVoiceIsAskedFor: + """A menu asks what a voice offers, and a click asks for one of them by the channel it named.""" + + def test_a_sample_offers_each_channel_that_plays( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + project = _project(sample) + + assert voice_instrument_channels(project, sample.id) == tuple(sample.reconstruction.playing_channels) + + def test_a_written_instrument_offers_one_naming_no_channel(self) -> None: + voice = new_instrument("Lead") + + assert voice_instrument_channels(_project(voice), voice.id) == (None,) + + def test_a_voice_the_pool_lost_offers_nothing(self) -> None: + assert voice_instrument_channels(Project.create(), "gone") == () + + def test_the_instrument_asked_for_is_the_one_that_channel_names( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = Sample(name="Bass", reconstruction=reconstruction_factory()) + project = _project(sample) + channel = sample.reconstruction.playing_channels[0] + + exportable = voice_instrument(project, sample.id, channel) + + assert exportable is not None + assert exportable.source.channel is channel + + def test_a_written_instrument_is_asked_for_by_naming_no_channel(self) -> None: + voice = new_instrument("Lead") + + exportable = voice_instrument(_project(voice), voice.id, None) + + assert exportable is not None + assert exportable.name == "Lead" + + def test_a_written_instrument_is_reached_by_naming_no_channel_alone(self) -> None: + """It offers one instrument for every channel at once, so no channel names it by itself.""" + voice = new_instrument("Lead") + + assert voice_instrument(_project(voice), voice.id, ChannelName.NOISE) is None + + def test_a_voice_the_pool_lost_is_written_nowhere(self) -> None: + assert voice_instrument(Project.create(), "gone", None) is None diff --git a/tests/unit/sampletones_application/logic/export/test_instrument.py b/tests/unit/sampletones_application/logic/export/test_instrument.py deleted file mode 100644 index 1754f1e2e..000000000 --- a/tests/unit/sampletones_application/logic/export/test_instrument.py +++ /dev/null @@ -1,367 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Dict, Final, List -from unittest.mock import MagicMock - -import pytest - -from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL -from sampletones_application.exports import build_export_backends -from sampletones_application.logic.export.instrument import ( - InstrumentExportLogic, - exportable_instrument, - sounding_channel, - voice_entries, -) -from sampletones_core.constants.enums import ChannelName -from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.request import InstrumentSource -from sampletones_core.project.project import Project -from sampletones_core.project.voices.creation import new_instrument -from sampletones_core.project.voices.sample import Sample -from sampletones_core.reconstructions import Reconstruction -from sampletones_shared.music import Tuning -from sampletones_shared.paths.extensions import ( - EXT_FILE_BITPHASE, - EXT_FILE_INSTRUMENT, - EXT_FILE_JSON, - EXT_FILE_MODULE, - EXT_FILE_NSF, -) -from tests.suite.sequencer import sample_reconstruction - -NO_EXTENSION: Final[str] = "" -REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments") - - -@dataclass(frozen=True) -class FormatCase: - extension: str - export_format: ExportFormat - - -FORMAT_CASES: Final[List[FormatCase]] = [ - FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER), - FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE), - FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET), - FormatCase(extension=EXT_FILE_NSF, export_format=ExportFormat.NSF), -] - -UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION] - - -@pytest.fixture -def session_manager() -> MagicMock: - mock = MagicMock() - mock.get_instrument_path.return_value = REMEMBERED_DIRECTORY - return mock - - -@pytest.fixture -def export_service() -> MagicMock: - return MagicMock() - - -@pytest.fixture -def export_backends() -> Dict[ExportFormat, MagicMock]: - """Stands in for the real backends while declaring the scopes and extensions they do.""" - backends: Dict[ExportFormat, MagicMock] = {} - for export_format, backend in build_export_backends().items(): - stub = MagicMock() - stub.supported_scopes = backend.supported_scopes - stub.extension.side_effect = backend.extension - backends[export_format] = stub - - return backends - - -@pytest.fixture -def project_controller() -> MagicMock: - mock = MagicMock() - mock.project = Project.create() - return mock - - -@pytest.fixture -def logic( - project_controller: MagicMock, - session_manager: MagicMock, - export_service: MagicMock, - export_backends: Dict[ExportFormat, MagicMock], -) -> InstrumentExportLogic: - return InstrumentExportLogic(project_controller, session_manager, export_service, export_backends) - - -def _source() -> InstrumentSource: - voice = new_instrument("Lead") - project = Project.create() - project.voices.append(voice) - return exportable_instrument(project, voice_entries(voice)[0]).source - - -class TestWritingOneInstrument: - """One path carries an instrument to disk, whichever surface asked for it.""" - - def test_the_service_is_asked_to_write_it( - self, - logic: InstrumentExportLogic, - export_service: MagicMock, - tmp_path: Path, - ) -> None: - logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source()) - - export_service.export_instrument.assert_called_once() - - def test_the_destination_names_the_instrument( - self, - logic: InstrumentExportLogic, - export_service: MagicMock, - tmp_path: Path, - ) -> None: - """Renaming the file in the save dialog renames the instrument the file carries.""" - logic.export(tmp_path / f"Clap (pulse1){EXT_FILE_INSTRUMENT}", _source()) - - request = export_service.export_instrument.call_args.args[2] - assert request.name == "Clap (pulse1)" - - @pytest.mark.parametrize("case", FORMAT_CASES, ids=lambda case: case.extension) - def test_the_extension_names_the_backend( - self, - logic: InstrumentExportLogic, - export_service: MagicMock, - export_backends: Dict[ExportFormat, MagicMock], - tmp_path: Path, - case: FormatCase, - ) -> None: - logic.export(tmp_path / f"instrument{case.extension}", _source()) - - backend = export_service.export_instrument.call_args.args[1] - assert backend is export_backends[case.export_format] - - def test_everything_the_source_states_reaches_the_request( - self, - logic: InstrumentExportLogic, - export_service: MagicMock, - tmp_path: Path, - ) -> None: - source = _source() - - logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", source) - - request = export_service.export_instrument.call_args.args[2] - assert (request.channel, request.features, request.loop_point) == ( - source.channel, - source.features, - source.loop_point, - ) - assert (request.nes_frequency, request.tuning) == (source.nes_frequency, source.tuning) - - def test_the_folder_it_landed_in_is_remembered( - self, - logic: InstrumentExportLogic, - session_manager: MagicMock, - tmp_path: Path, - ) -> None: - logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source()) - - session_manager.set_instrument_path.assert_called_once_with(tmp_path) - - def test_the_dialog_opens_where_the_last_one_landed(self, logic: InstrumentExportLogic) -> None: - assert logic.suggested_directory == REMEMBERED_DIRECTORY - - def test_every_backend_is_reachable_for_the_types_a_dialog_offers( - self, - logic: InstrumentExportLogic, - export_backends: Dict[ExportFormat, MagicMock], - ) -> None: - """The dialog names each format's own file type, which it reads off the backend.""" - assert dict(logic.backends) == export_backends - - @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS) - def test_an_extension_no_format_writes_is_refused( - self, - logic: InstrumentExportLogic, - tmp_path: Path, - extension: str, - ) -> None: - """The dialog answers with one of the types it offered, so an extension naming no - format is a broken invariant rather than a choice to report. - """ - with pytest.raises(ValueError): - logic.export(tmp_path / f"instrument{extension}", _source()) - - -class TestWhatAVoiceOffers: - """One rule answers what a voice contributes, so both kinds are exported the same way.""" - - @staticmethod - def _project(*voices: object) -> Project: - project = Project.create() - for voice in voices: - project.voices.append(voice) - - return project - - def test_a_written_instrument_offers_one(self) -> None: - """One set of envelopes every channel reads is one instrument, as a module holds it.""" - voice = new_instrument("Lead") - - assert len(voice_entries(voice)) == 1 - - def test_a_written_instrument_is_named_after_itself(self) -> None: - voice = new_instrument("Lead") - - assert voice_entries(voice)[0].name == "Lead" - - def test_a_sample_offers_one_per_channel_that_plays( - self, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - playing = sample.reconstruction.playing_channels - - assert [entry.channel for entry in voice_entries(sample)] == list(playing) - - def test_a_samples_slice_is_named_for_its_channel( - self, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - - assert all(entry.name.startswith("Bass") for entry in voice_entries(sample)) - - def test_both_kinds_answer_with_the_same_shape( - self, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - """Whatever produced the envelopes is settled here, so one export path takes both.""" - sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - written = new_instrument("Lead") - project = self._project(sample, written) - - sources = [exportable_instrument(project, entry).source for entry in voice_entries(sample)] - sources += [exportable_instrument(project, entry).source for entry in voice_entries(written)] - - assert all(isinstance(source, InstrumentSource) for source in sources) - - def test_the_project_states_the_rate_and_the_tuning(self) -> None: - voice = new_instrument("Lead") - project = self._project(voice) - - source = exportable_instrument(project, voice_entries(voice)[0]).source - - assert source.nes_frequency == project.settings.nes_frequency - assert source.tuning == Tuning() - - def test_a_voice_with_nothing_written_offers_nothing(self) -> None: - sample = Sample(name="Silent", reconstruction=sample_reconstruction(set())) - - assert voice_entries(sample) == () - - -class TestWhichChannelAFileSoundsItOn: - """A written file plays its instrument somewhere, and the entry states where or leaves it open.""" - - def test_a_samples_slice_sounds_on_the_channel_it_was_reconstructed_for( - self, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - - for entry in voice_entries(sample): - assert sounding_channel(entry) is entry.channel - - def test_a_written_instrument_sounds_where_the_app_reads_it(self) -> None: - """Its envelopes name no channel, so the file plays them where its editor shows them.""" - voice = new_instrument("Lead") - - assert sounding_channel(voice_entries(voice)[0]) is INSTRUMENT_CHANNEL - - def test_a_written_instrument_is_measured_against_the_root_that_channel_reads(self) -> None: - """The channel it is sounded on and the reference its envelopes carry are one answer.""" - voice = new_instrument("Lead") - entry = voice_entries(voice)[0] - - assert entry.features.initial_pitch == voice.reference(sounding_channel(entry)) - - -class TestWhatOneVoiceIsAskedFor: - """A menu asks what a voice offers, and a click asks for one of them by the channel it named.""" - - @staticmethod - def _logic(project: Project, session_manager: MagicMock) -> InstrumentExportLogic: - controller = MagicMock() - controller.project = project - return InstrumentExportLogic(controller, session_manager, MagicMock(), {}) - - def test_a_sample_offers_each_channel_that_plays( - self, - session_manager: MagicMock, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - project = Project.create() - project.voices.append(sample) - - offered = self._logic(project, session_manager).voice_instruments(sample.id) - - assert offered == tuple(sample.reconstruction.playing_channels) - - def test_a_written_instrument_offers_one_naming_no_channel( - self, - session_manager: MagicMock, - ) -> None: - voice = new_instrument("Lead") - project = Project.create() - project.voices.append(voice) - - assert self._logic(project, session_manager).voice_instruments(voice.id) == (None,) - - def test_a_voice_the_pool_lost_offers_nothing(self, session_manager: MagicMock) -> None: - logic = self._logic(Project.create(), session_manager) - - assert logic.voice_instruments("gone") == () - - def test_the_instrument_asked_for_is_the_one_that_channel_names( - self, - session_manager: MagicMock, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = Sample(name="Bass", reconstruction=reconstruction_factory()) - project = Project.create() - project.voices.append(sample) - channel = sample.reconstruction.playing_channels[0] - - exportable = self._logic(project, session_manager).voice_instrument(sample.id, channel) - - assert exportable is not None - assert exportable.source.channel is channel - - def test_a_written_instrument_is_asked_for_by_naming_no_channel( - self, - session_manager: MagicMock, - ) -> None: - voice = new_instrument("Lead") - project = Project.create() - project.voices.append(voice) - - exportable = self._logic(project, session_manager).voice_instrument(voice.id, None) - - assert exportable is not None - assert exportable.name == "Lead" - - def test_a_written_instrument_is_reached_by_naming_no_channel_alone( - self, - session_manager: MagicMock, - ) -> None: - """It offers one instrument for every channel at once, so no channel names it by itself.""" - voice = new_instrument("Lead") - project = Project.create() - project.voices.append(voice) - - assert self._logic(project, session_manager).voice_instrument(voice.id, ChannelName.NOISE) is None - - def test_a_voice_the_pool_lost_is_written_nowhere(self, session_manager: MagicMock) -> None: - logic = self._logic(Project.create(), session_manager) - - assert logic.voice_instrument("gone", None) is None diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 8ac11415c..0d995046e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -12,6 +12,7 @@ from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.formats.famitracker.footprint import ( features_footprint, reconstruction_footprints, @@ -246,6 +247,90 @@ def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: assert logic.build_voice_footprint("missing") is None +class TestTakingAChannelAsAnInstrument: + """A recording's channel is read back as envelopes, so what it played becomes a voice to edit.""" + + def test_every_channel_that_plays_is_offered(self) -> None: + controller, logic = _logic() + channels = (ChannelName.PULSE1, ChannelName.TRIANGLE) + sample = controller.add_sample(sample_reconstruction(channels), name="bell") + + assert logic.instrument_channels(sample.id) == channels + + def test_a_voice_already_written_as_envelopes_offers_none(self) -> None: + """It is what this would make of it, so there is nothing to take out of it.""" + controller, logic = _logic() + instrument = logic.add_new_instrument("lead") + + assert logic.instrument_channels(instrument.id) == () + + def test_a_voice_the_pool_has_dropped_offers_none(self) -> None: + _, logic = _logic() + + assert logic.instrument_channels("missing") == () + + def test_the_envelopes_come_across_as_the_channel_played_them(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction({ChannelName.TRIANGLE}), name="bell") + played = sample.reconstruction.export()[ChannelName.TRIANGLE] + + instrument = logic.instrument_from_channel(sample.id, ChannelName.TRIANGLE) + + assert instrument is not None + assert instrument.envelopes.volume == tuple(int(item) for item in played.volume) + assert instrument.envelopes.arpeggio == tuple(int(item) for item in played.arpeggio) + + def test_the_instrument_is_measured_against_the_reference_that_channel_read(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction({ChannelName.NOISE}), name="bell") + played = sample.reconstruction.export()[ChannelName.NOISE] + + instrument = logic.instrument_from_channel(sample.id, ChannelName.NOISE) + + assert instrument is not None + assert instrument.reference(ChannelName.NOISE) == played.initial_pitch + + def test_the_instrument_is_named_after_the_channel_it_came_from(self) -> None: + """A slice carries the name an export gives it, so the list says where the voice came from.""" + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction({ChannelName.NOISE}), name="bell") + + instrument = logic.instrument_from_channel(sample.id, ChannelName.NOISE) + + assert instrument is not None + assert instrument.name == instrument_slice_name("bell", ChannelName.NOISE) + + def test_the_instrument_repeats_the_way_the_sample_does(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell") + controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) + + instrument = logic.instrument_from_channel(sample.id, ChannelName.PULSE1) + + assert instrument is not None + assert instrument.loop_point == WHOLE_LOOP_POINT + + def test_taking_a_channel_leaves_the_pool_as_it_stands(self) -> None: + """The instrument is written here and added by whoever asked, inside a history entry.""" + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell") + + logic.instrument_from_channel(sample.id, ChannelName.PULSE1) + + assert controller.voice_count == 1 + + def test_a_channel_standing_by_makes_nothing(self) -> None: + controller, logic = _logic() + sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell") + + assert logic.instrument_from_channel(sample.id, ChannelName.NOISE) is None + + def test_a_voice_the_pool_has_dropped_makes_nothing(self) -> None: + _, logic = _logic() + + assert logic.instrument_from_channel("missing", ChannelName.PULSE1) is None + + class TestPlaySample: def test_plays_reconstruction_regardless_of_autoplay( self, reconstruction_factory: Callable[[], Reconstruction] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py index 1c6ed98c0..8d5c34f4a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -10,7 +10,7 @@ from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel -from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter, focus from sampletones_application.view_model.sequencer.subcolumn import SubColumn from tests.suite.base import BaseTestSuite diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py similarity index 96% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py rename to tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py index 97f52e2d7..bd90b106b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py @@ -3,7 +3,7 @@ import pytest -from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind @@ -44,7 +44,7 @@ def voices(monkeypatch: pytest.MonkeyPatch) -> VoicesPanelFixture: fixture = VoicesPanelFixture(panel=panel) panel.on_remove_requested = fixture.removed.append panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target)) - monkeypatch.setattr(panel, "_start_rename", fixture.renamed.append) + monkeypatch.setattr(panel, "start_rename", fixture.renamed.append) monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None)) return fixture diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py similarity index 79% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py rename to tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index f82dec196..1d62197d8 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -8,8 +8,11 @@ from sampletones_application.categories.elements.sequencer import SequencerVoicesElements from sampletones_application.ui.elements import context_menu as context_menu_module from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.panels.sequencer import voices as voices_module -from sampletones_application.ui.panels.sequencer.voices import VOICE_MOVES, GUISequencerVoicesPanel +from sampletones_application.ui.panels.sequencer.voices import menu as menu_module +from sampletones_application.ui.panels.sequencer.voices import panel as panel_module +from sampletones_application.ui.panels.sequencer.voices.menu import VoicesMenu +from sampletones_application.ui.panels.sequencer.voices.moves import VOICE_MOVES +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind @@ -50,17 +53,22 @@ EDIT_ITEM = 0 RENAME_ITEM = 1 DUPLICATE_ITEM = 2 -REMOVE_ITEM = 3 -MOVE_UP_ITEM = 4 -MOVE_DOWN_ITEM = 5 -MOVE_TOP_ITEM = 6 -MOVE_BOTTOM_ITEM = 7 -EXPORT_ITEM = 8 +INSTRUMENT_FROM_ITEM = 3 +REMOVE_ITEM = 4 +MOVE_UP_ITEM = 5 +MOVE_DOWN_ITEM = 6 +MOVE_TOP_ITEM = 7 +MOVE_BOTTOM_ITEM = 8 +EXPORT_ITEM = 9 ONE_INSTRUMENT: Tuple[Optional[ChannelName], ...] = (ChannelName.PULSE1,) TWO_INSTRUMENTS: Tuple[Optional[ChannelName], ...] = (ChannelName.PULSE1, ChannelName.NOISE) NO_INSTRUMENTS: Tuple[Optional[ChannelName], ...] = () +TWO_CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.NOISE) +ONE_CHANNEL: Tuple[ChannelName, ...] = (ChannelName.TRIANGLE,) +NO_CHANNELS: Tuple[ChannelName, ...] = () + def _unreachable() -> None: """Stands where a greyed-out item would carry a callback, which a reader never fires.""" @@ -78,7 +86,7 @@ class MenuItem: @dataclass class Requests: - """What each sample hook was handed when its menu item fired.""" + """What each voice hook was handed when its menu item fired.""" edited: List[str] = field(default_factory=list) renamed: List[str] = field(default_factory=list) @@ -87,6 +95,7 @@ class Requests: moved: List[Tuple[str, Optional[int]]] = field(default_factory=list) pool: List[str] = field(default_factory=list) exported: List[Tuple[str, Optional[ChannelName]]] = field(default_factory=list) + taken: List[Tuple[str, ChannelName]] = field(default_factory=list) class _MenuRecorder: @@ -114,9 +123,9 @@ def add_menu_item(self, **kwargs: Any) -> int: @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: recorded = _MenuRecorder() - monkeypatch.setattr(voices_module.dpg, "add_menu_item", recorded.add_menu_item) - monkeypatch.setattr(voices_module.dpg, "add_separator", lambda **_kwargs: 0) - monkeypatch.setattr(voices_module.dpg, "menu", recorded.menu) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(menu_module.dpg, "menu", recorded.menu) return recorded @@ -125,6 +134,7 @@ class VoicesPanelFixture: """A panel holding a selection, with the calls each menu item makes recorded.""" panel: GUISequencerVoicesPanel + menu: VoicesMenu requests: Requests @@ -138,8 +148,13 @@ def _panel( footprint: Optional[SampleFootprintViewModel] = FOOTPRINT, footprint_wired: bool = True, instruments: Tuple[Optional[ChannelName], ...] = ONE_INSTRUMENT, + channels: Tuple[ChannelName, ...] = NO_CHANNELS, ) -> VoicesPanelFixture: - """A samples panel whose menu builder can run with no DearPyGui context behind it.""" + """A voices panel whose menu builder can run with no DearPyGui context behind it. + + Each action stands as a single item by default — one instrument to export and no channel to + take — so a case naming a position names the same one however the voice is stocked. + """ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) panel._language_manager = _Labels() panel._shortcuts = shipped_source() @@ -150,12 +165,9 @@ def _panel( panel._list_menu_pending = False panel._tab_active = lambda: tab_active panel._router = _Router(field_focused=field_focused) - panel._detail_color = DETAIL_COLOR - panel._lbl_sample_size = SAMPLE_SIZE_LABEL - panel._tpl_size_bytes = SIZE_TEMPLATE - panel._tip_size_bytes = SIZE_TOOLTIP panel.sample_footprint = (lambda _voice_id: footprint) if footprint_wired else None panel.voice_instruments = lambda _voice_id: instruments + panel.instrument_channels = lambda _voice_id: channels requests = Requests() panel.on_sample_edit_requested = requests.edited.append @@ -166,8 +178,20 @@ def _panel( panel.on_add_sample_requested = lambda: requests.pool.append(SequencerVoicesElements.ADD_SAMPLE.value) panel.on_import_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.IMPORT_INSTRUMENT.value) panel.on_export_instrument_requested = lambda voice_id, channel: requests.exported.append((voice_id, channel)) - monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) - return VoicesPanelFixture(panel=panel, requests=requests) + panel.on_instrument_from_channel_requested = lambda voice_id, channel: requests.taken.append((voice_id, channel)) + monkeypatch.setattr(panel, "start_rename", requests.renamed.append) + + menu = VoicesMenu( + panel, + language_manager=_Labels(), + shortcut_source=shipped_source(), + detail_color=DETAIL_COLOR, + ) + menu._lbl_sample_size = SAMPLE_SIZE_LABEL + menu._tpl_size_bytes = SIZE_TEMPLATE + menu._tip_size_bytes = SIZE_TOOLTIP + panel._menu = menu + return VoicesPanelFixture(panel=panel, menu=menu, requests=requests) class _Labels: @@ -233,7 +257,7 @@ def _deferred_calls(monkeypatch: pytest.MonkeyPatch) -> List[VoidCallback]: """The callbacks handed to the next frame, which is where the list's menu waits.""" deferred: List[VoidCallback] = [] monkeypatch.setattr( - voices_module.FrameCallbackManager, + panel_module.FrameCallbackManager, "set_frame_callback", lambda callback: deferred.append(callback), ) @@ -244,11 +268,11 @@ def _deferred_calls(monkeypatch: pytest.MonkeyPatch) -> List[VoidCallback]: def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: """Records a whole context-menu build, with the DearPyGui calls behind it stood down.""" recorded = _MenuBuildRecorder() - monkeypatch.setattr(voices_module.dpg, "add_text", recorded.add_text) - monkeypatch.setattr(voices_module.dpg, "add_separator", recorded.add_separator) - monkeypatch.setattr(voices_module.dpg, "add_menu_item", recorded.add_menu_item) - monkeypatch.setattr(voices_module.dpg, "menu", recorded.menu) - monkeypatch.setattr(voices_module, "context_menu", _null_menu) + monkeypatch.setattr(menu_module.dpg, "add_text", recorded.add_text) + monkeypatch.setattr(menu_module.dpg, "add_separator", recorded.add_separator) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "menu", recorded.menu) + monkeypatch.setattr(menu_module, "context_menu", _null_menu) monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None) monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip) monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None) @@ -267,7 +291,7 @@ def is_field_focused(self) -> bool: class TestActionItems: - def test_the_menu_reads_as_the_sample_actions( + def test_the_menu_reads_as_the_voice_actions( self, monkeypatch: pytest.MonkeyPatch, recorder: _MenuRecorder, @@ -278,6 +302,7 @@ def test_the_menu_reads_as_the_sample_actions( SequencerVoicesElements.CONTEXT_EDIT.value, SequencerVoicesElements.CONTEXT_RENAME.value, SequencerVoicesElements.CONTEXT_DUPLICATE.value, + SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM.value, SequencerVoicesElements.CONTEXT_REMOVE.value, *(move.element.value for move in VOICE_MOVES), SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value, @@ -298,7 +323,7 @@ def test_the_items_print_the_keys_the_panel_answers_to( shortcuts.display(move.shortcut) for move in VOICE_MOVES ] - def test_the_items_act_on_the_sample_they_were_raised_on( + def test_the_items_act_on_the_voice_they_were_raised_on( self, monkeypatch: pytest.MonkeyPatch, recorder: _MenuRecorder, @@ -334,6 +359,62 @@ def test_a_move_with_nowhere_to_go_is_greyed_out( assert recorder.items[MOVE_BOTTOM_ITEM].enabled +class TestTakingAChannelAsAnInstrument: + """A recording's channel becomes a voice of envelopes, so the menu names the channels it holds.""" + + def test_every_channel_that_plays_is_offered( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, channels=TWO_CHANNELS).panel.build_edit_actions() + + assert recorder.submenus == [SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM.value] + assert [item.label for item in recorder.items[INSTRUMENT_FROM_ITEM : INSTRUMENT_FROM_ITEM + 2]] == [ + ContextElements.PULSE_1.value, + ContextElements.NOISE.value, + ] + + def test_a_single_channel_still_says_which_one_it_is( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """What the new voice plays is the channel it came from, so the channel is always named.""" + _panel(monkeypatch, channels=ONE_CHANNEL).panel.build_edit_actions() + + assert recorder.submenus == [SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM.value] + assert recorder.items[INSTRUMENT_FROM_ITEM].label == ContextElements.TRIANGLE.value + + def test_each_channel_asks_for_the_instrument_it_names( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + fixture = _panel(monkeypatch, channels=TWO_CHANNELS) + fixture.panel.build_edit_actions() + + for item in recorder.items[INSTRUMENT_FROM_ITEM : INSTRUMENT_FROM_ITEM + 2]: + item.callback() + + assert fixture.requests.taken == [ + (SELECTED_ID, ChannelName.PULSE1), + (SELECTED_ID, ChannelName.NOISE), + ] + + def test_a_voice_already_written_as_envelopes_offers_an_item_it_cannot_reach( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """It is what this would make of it, so the action stands named and out of reach.""" + fixture = _panel(monkeypatch, channels=NO_CHANNELS) + fixture.panel.build_edit_actions() + + assert not recorder.items[INSTRUMENT_FROM_ITEM].enabled + assert fixture.requests.taken == [] + + class TestExportingTheVoicesInstruments: """The menu offers what an export would write for the voice, however many instruments that is.""" @@ -404,7 +485,7 @@ class TestTheSizeRows: """A sample's menu names the bytes it occupies, so what a pool costs is read where it is edited.""" def test_the_rows_read_as_the_total_then_each_playing_channel(self, monkeypatch: pytest.MonkeyPatch) -> None: - items = _panel(monkeypatch).panel._footprint_items(SELECTED_ID) + items = _panel(monkeypatch).menu._footprint_items(SELECTED_ID) assert items == [ (SAMPLE_SIZE_LABEL, f"{PULSE_1_BYTES + NOISE_BYTES} B"), @@ -414,12 +495,12 @@ def test_the_rows_read_as_the_total_then_each_playing_channel(self, monkeypatch: def test_a_channel_standing_by_is_named_nowhere(self, monkeypatch: pytest.MonkeyPatch) -> None: """A channel that does not play is written by no export, so it costs nothing to name.""" - labels = [label for label, _value in _panel(monkeypatch).panel._footprint_items(SELECTED_ID)] + labels = [label for label, _value in _panel(monkeypatch).menu._footprint_items(SELECTED_ID)] assert ContextElements.PULSE_2.value not in labels assert ContextElements.TRIANGLE.value not in labels - def test_the_figures_name_the_sample_the_pointer_landed_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_the_figures_name_the_voice_the_pointer_landed_on(self, monkeypatch: pytest.MonkeyPatch) -> None: """The figures are asked for as the menu opens, so they answer for the row right-clicked.""" measured: List[str] = [] @@ -430,20 +511,20 @@ def _measure(voice_id: str) -> SampleFootprintViewModel: fixture = _panel(monkeypatch) fixture.panel.sample_footprint = _measure - fixture.panel._footprint_items("lead-id") + fixture.menu._footprint_items("lead-id") assert measured == ["lead-id"] - def test_a_sample_the_pool_has_dropped_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: - assert _panel(monkeypatch, footprint=None).panel._footprint_items(SELECTED_ID) == [] + def test_a_voice_the_pool_has_dropped_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert _panel(monkeypatch, footprint=None).menu._footprint_items(SELECTED_ID) == [] def test_an_unwired_hook_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: """A panel tolerates its hooks being unset until the coordinator wires them.""" - assert _panel(monkeypatch, footprint_wired=False).panel._footprint_items(SELECTED_ID) == [] + assert _panel(monkeypatch, footprint_wired=False).menu._footprint_items(SELECTED_ID) == [] class TestMenuComposition: - def test_the_sizes_sit_between_the_sample_name_and_the_actions( + def test_the_sizes_sit_between_the_voice_name_and_the_actions( self, monkeypatch: pytest.MonkeyPatch, build_recorder: _MenuBuildRecorder, @@ -477,6 +558,15 @@ def test_a_menu_with_no_figures_reads_as_it_always_has( assert build_recorder.texts_before_the_first_item() == [display_voice_label(SELECTED_ROW, "Bass")] + def test_a_row_the_list_no_longer_holds_raises_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, "gone") + + assert build_recorder.widgets == [] + class TestEditActions: def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -574,7 +664,7 @@ def test_each_item_prints_the_key_it_answers_to( ) -> None: """Every way a voice comes in is rebindable, so each item names the press that fires it.""" shortcuts = shipped_source() - _panel(monkeypatch).panel.add_pool_items() + _panel(monkeypatch).menu.add_pool_items() assert [item.shortcut for item in recorder.items] == [ shortcuts.display(ShortcutId.NEW_INSTRUMENT), @@ -588,7 +678,7 @@ def test_the_items_ask_for_a_written_voice_and_for_a_located_one( recorder: _MenuRecorder, ) -> None: fixture = _panel(monkeypatch) - fixture.panel.add_pool_items() + fixture.menu.add_pool_items() for item in recorder.items: item.callback() @@ -625,7 +715,7 @@ def test_a_row_claiming_the_press_leaves_the_list_menu_unbuilt( _deferred_calls(monkeypatch) monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: True) monkeypatch.setattr(fixture.panel, "_show_context_menu", lambda _position, _voice_id: None) - monkeypatch.setattr(voices_module.dpg, "get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID)) + monkeypatch.setattr(panel_module.dpg, "get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID)) fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON) fixture.panel._on_sample_clicked(0, (RIGHT_BUTTON, 0)) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py similarity index 95% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_voices_selection.py rename to tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py index 9ac0544cb..00f9b45c1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_voices_selection.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py @@ -1,6 +1,6 @@ from typing import Optional, Tuple -from sampletones_application.ui.panels.sequencer.voices import GUISequencerVoicesPanel +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index 3c4372c33..618738e63 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -2,8 +2,8 @@ import numpy as np -from sampletones_core.constants.enums import FeatureKey -from sampletones_core.exporters import Features +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features, playing_channels def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: @@ -57,3 +57,29 @@ def test_leaving_a_dimension_the_channel_lacks_keeps_it_absent(self) -> None: features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) assert features.duty_cycle is None assert features.held_features == (FeatureKey.VOLUME,) + + +class TestWhichChannelsSound: + """Describing a frame is what puts a channel in play, and every reader asks the same way.""" + + def test_a_channel_describing_frames_sounds(self) -> None: + channels = {ChannelName.PULSE1: build_features(8)} + + assert playing_channels(channels) == frozenset({ChannelName.PULSE1}) + + def test_a_channel_describing_nothing_stands_by(self) -> None: + channels = {ChannelName.TRIANGLE: build_features(0)} + + assert playing_channels(channels) == frozenset() + + def test_the_channels_that_sound_are_told_from_the_ones_that_stand_by(self) -> None: + channels = { + ChannelName.PULSE1: build_features(8), + ChannelName.PULSE2: build_features(0), + ChannelName.NOISE: build_features(4), + } + + assert playing_channels(channels) == frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + def test_nothing_offered_sounds_nowhere(self) -> None: + assert playing_channels({}) == frozenset() diff --git a/tests/unit/sampletones_core/project/voices/test_creation.py b/tests/unit/sampletones_core/project/voices/test_creation.py index 0a6fbc43b..9f80069f2 100644 --- a/tests/unit/sampletones_core/project/voices/test_creation.py +++ b/tests/unit/sampletones_core/project/voices/test_creation.py @@ -1,9 +1,37 @@ +from typing import Optional, Tuple + +import numpy as np + from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.exporters import Features from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH -from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.creation import instrument_from_features, new_instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +TONAL_REFERENCE = 64 +NOISE_REFERENCE = 5 +VOLUME = (15, 12, 9) +ARPEGGIO = (0, 4, 7) +DUTY_CYCLE = (2, 2, 1) + + +def _features( + reference: int, + *, + duty_cycle: Optional[Tuple[int, ...]] = DUTY_CYCLE, + volume: Tuple[int, ...] = VOLUME, +) -> Features: + """What one channel plays, as its exporter states it.""" + return Features( + initial_pitch=reference, + volume=np.array(volume, dtype=np.int8), + arpeggio=np.array(ARPEGGIO, dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=np.int8), + ) + class TestNewInstrument: def test_a_new_instrument_sounds_a_frame_on_every_channel(self) -> None: @@ -34,3 +62,101 @@ def test_a_new_instrument_rests_where_a_channel_added_by_hand_rests(self) -> Non def test_each_new_instrument_is_a_voice_of_its_own(self) -> None: assert new_instrument("lead").id != new_instrument("lead").id + + +class TestAnInstrumentTakenFromAChannel: + """A recording's channel is read back as envelopes, so what it played becomes editable.""" + + def test_the_envelopes_come_across_as_the_channel_played_them(self) -> None: + instrument = instrument_from_features( + "Bass (pulse1)", + _features(TONAL_REFERENCE), + ChannelName.PULSE1, + loop_point=None, + ) + + assert instrument.envelopes.volume == VOLUME + assert instrument.envelopes.arpeggio == ARPEGGIO + assert instrument.envelopes.duty_cycle == DUTY_CYCLE + + def test_a_dimension_the_channel_governs_stays_the_channels(self) -> None: + """An empty envelope means the channel keeps the value it holds, on either side of this.""" + instrument = instrument_from_features( + "Bass (pulse1)", + _features(TONAL_REFERENCE, volume=()), + ChannelName.PULSE1, + loop_point=None, + ) + + assert instrument.envelopes.volume == () + + def test_a_dimension_the_channel_lacks_is_left_unwritten(self) -> None: + """The triangle channel offers no duty cycle, so the voice writes none for it.""" + instrument = instrument_from_features( + "Bass (triangle)", + _features(TONAL_REFERENCE, duty_cycle=None), + ChannelName.TRIANGLE, + loop_point=None, + ) + + assert instrument.envelopes.duty_cycle == () + + def test_a_tonal_channels_reference_becomes_the_note_the_arpeggio_is_measured_against(self) -> None: + instrument = instrument_from_features( + "Bass (pulse1)", + _features(TONAL_REFERENCE), + ChannelName.PULSE1, + loop_point=None, + ) + + assert instrument.root_pitch == TONAL_REFERENCE + assert instrument.root_period == RESTING_REFERENCE_PERIOD + + def test_the_noise_channels_reference_becomes_the_period_the_arpeggio_is_measured_against(self) -> None: + instrument = instrument_from_features( + "Bass (noise)", + _features(NOISE_REFERENCE), + ChannelName.NOISE, + loop_point=None, + ) + + assert instrument.root_period == NOISE_REFERENCE + assert instrument.root_pitch == RESTING_REFERENCE_PITCH + + def test_the_voice_reads_on_its_own_channel_what_that_channel_stated(self) -> None: + """The reference travels with the envelopes, so the two agree where they came from.""" + features = _features(TONAL_REFERENCE) + + instrument = instrument_from_features("Bass (pulse1)", features, ChannelName.PULSE1, loop_point=None) + + assert instrument.features(ChannelName.PULSE1).initial_pitch == features.initial_pitch + + def test_the_voice_sounds_the_frames_the_channel_sounded(self) -> None: + instrument = instrument_from_features( + "Bass (pulse1)", + _features(TONAL_REFERENCE), + ChannelName.PULSE1, + loop_point=None, + ) + + assert len(instrument.instructions(ChannelName.PULSE1)) == len(VOLUME) + + def test_the_voice_repeats_from_the_tick_it_was_given(self) -> None: + instrument = instrument_from_features( + "Bass (pulse1)", + _features(TONAL_REFERENCE), + ChannelName.PULSE1, + loop_point=WHOLE_LOOP_POINT, + ) + + assert instrument.loop_point == WHOLE_LOOP_POINT + + def test_the_voice_carries_the_name_it_was_given(self) -> None: + instrument = instrument_from_features( + "Bass (pulse1)", + _features(TONAL_REFERENCE), + ChannelName.PULSE1, + loop_point=None, + ) + + assert instrument.name == "Bass (pulse1)" From 147ec95c636105db4a310d74902ff8687a417ee5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 23 Aug 2026 23:48:44 +0200 Subject: [PATCH 095/142] Renamed: the tracker's voice column back into the sample column --- .../categories/elements/sequencer.py | 4 +- .../constants/tracker.py | 2 +- .../logic/sequencer/tracker/tracker.py | 68 ++++++-- .../logic/sequencer/tracker/writer.py | 9 +- .../ui/panels/sequencer/columns.py | 8 +- .../ui/panels/sequencer/tracker.py | 75 ++++++--- .../view_model/sequencer/kind.py | 16 ++ .../view_model/sequencer/tracker.py | 47 ++++-- src/sampletones_config/lang/en.yaml | 4 +- .../sequencer/tracker/test_pitch_faces.py | 2 +- .../logic/sequencer/tracker/test_tracker.py | 147 ++++++++++++++++-- .../logic/sequencer/tracker/test_writer.py | 37 ++++- .../ui/panels/sequencer/test_columns.py | 6 +- .../sequencer/test_tracker_context_menu.py | 76 ++++++++- .../sequencer/test_tracker_header_menu.py | 4 +- .../sequencer/test_tracker_typed_voice.py | 123 +++++++++++++++ .../view_model/sequencer/test_tracker.py | 75 ++++++--- 17 files changed, 596 insertions(+), 107 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 269565c56..07cb43b4e 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -5,13 +5,13 @@ class SequencerTrackerElements(AbstractElement): TRACKER_TEXT = "tracker_text" OCTAVE = "octave" COLUMN_ROW = "column_row" - COLUMN_VOICE = "column_voice" + COLUMN_SAMPLE = "column_sample" COLUMN_PULSE_1 = "column_pulse_1" COLUMN_PULSE_2 = "column_pulse_2" COLUMN_TRIANGLE = "column_triangle" COLUMN_NOISE = "column_noise" HEADER_CHANNEL = "header_channel" - HEADER_VOICE = "header_voice" + HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" CONTEXT_SELECT_ALL = "context_select_all" diff --git a/src/sampletones_application/constants/tracker.py b/src/sampletones_application/constants/tracker.py index e72527fa4..229abd1b9 100644 --- a/src/sampletones_application/constants/tracker.py +++ b/src/sampletones_application/constants/tracker.py @@ -2,4 +2,4 @@ MIN_OCTAVE: Final[int] = 0 MAX_OCTAVE: Final[int] = 7 -DEFAULT_OCTAVE: Final[int] = 4 +DEFAULT_OCTAVE: Final[int] = 2 diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 444e7b269..f4dd5738b 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -1,6 +1,10 @@ from typing import Callable, Dict, FrozenSet, List, Optional, Set from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.view_model.sequencer.kind import ( + places_across_channels, + voice_kind, +) from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) @@ -10,6 +14,7 @@ SequencerRowViewModel, SequencerTrackerViewModel, ) +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.project.patterns.pattern import Pattern @@ -32,6 +37,7 @@ instrument=display_id(None), transpose=display_transpose(None), volume=display_volume(None), + kind=None, ) @@ -216,8 +222,14 @@ def place_note( channel: Optional[ChannelName], voice_id: str, ) -> None: + """Places a voice on the cell a column and a row name, where that column takes it. + + Every route that names a voice for a cell arrives here — a typed number, a menu item and a + pasted block alike — so the sample column's rule is asked once and each of them follows it. + """ if channel is None: - self.set_row_voice(row_index, voice_id) + if self.places_in_sample_column(voice_id): + self.set_row_sample(row_index, voice_id) else: self.set_row( channel, @@ -225,6 +237,18 @@ def place_note( command=NoteOn(voice_id=voice_id), ) + def places_in_sample_column(self, voice_id: str) -> bool: + """Whether the sample column takes the voice an id names. + + The column spreads a voice over the channels it covers, which a recording states for + itself, so it answers for a sample the project holds and stands by for anything else. + """ + voice = self._controller.project.voices.get(voice_id) + if voice is None: + return False + + return places_across_channels(voice_kind(voice)) + def cut_note( self, row_index: int, @@ -332,7 +356,7 @@ def clear_subcolumn_all_generators( volume=volume, ) - def set_row_voice( + def set_row_sample( self, row_index: int, voice_id: Optional[str], @@ -537,8 +561,8 @@ def select_frame(self, frame_index: int) -> None: self._frame_index = frame_index self.push_tracker() - def holds_sample(self, voice_id: str) -> bool: - """Whether the project holds the sample a note names, which is what makes the note placeable.""" + def holds_voice(self, voice_id: str) -> bool: + """Whether the project holds the voice a note names, which is what makes the note placeable.""" return self._controller.project.voices.get(voice_id) is not None def used_generators(self, voice_id: str) -> List[ChannelName]: @@ -627,12 +651,13 @@ def _referenced_generators_from_rows( self, rows: Dict[ChannelName, Optional[Row]], ) -> FrozenSet[ChannelName]: - """The channels spanned by the voices referenced on a row. + """The channels spanned by the samples a row names. - Each referenced voice contributes the channels its reconstruction covers, so the sample - column reasons about a voice's whole channel span, including channels whose cells are - empty. A row naming a voice the project no longer holds contributes the channel it sits - on, which keeps that cell reachable while the reference stands. + A sample contributes the channels its reconstruction covers, so the sample column reasons + about its whole span including channels whose cells stand empty. A hand-written instrument + sounds on the one channel it is named in, so it contributes none and leaves the column + speaking for samples alone. A row naming a voice the project no longer holds contributes + the channel it sits on, which keeps that cell reachable while the reference stands. """ relevant: Set[ChannelName] = set() resolved: Set[str] = set() @@ -646,11 +671,11 @@ def _referenced_generators_from_rows( continue resolved.add(voice_id) - sample = self._controller.project.voices.get(voice_id) - if sample is None: + voice = self._controller.project.voices.get(voice_id) + if voice is None: relevant.add(channel) - else: - relevant.update(self._used_generators(sample)) + elif places_across_channels(voice_kind(voice)): + relevant.update(self._used_generators(voice)) return frozenset(relevant) @@ -681,7 +706,7 @@ def _build_row( return SequencerRowViewModel( index=index, cells=cells, - relevant_channels=self._referenced_generators_from_rows(rows), + sample_channels=self._referenced_generators_from_rows(rows), ) def _carried_voice( @@ -720,8 +745,23 @@ def _build_cell( ), transpose=self._display_pitch(row.transpose, channel, voice), volume=display_volume(row.volume), + kind=self._named_kind(row.command), ) + def _named_kind(self, command: Optional[NoteCommand]) -> Optional[VoiceKind]: + """The kind of the voice a row names, absent where it names none the project holds. + + The cell states the voice it starts, so the kind is read from that command rather than from + whatever the channel carries into the row: a line that only bends a note names nothing and + takes the kind of nothing. + """ + match command: + case NoteOn() as note_on: + voice = self._controller.project.voices.get(note_on.voice_id) + return voice_kind(voice) if voice is not None else None + case _: + return None + @staticmethod def _display_pitch( transpose: Optional[int], diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py index 8dd19cf4a..8ad69b38f 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -82,17 +82,18 @@ def _write_note( channel: Optional[ChannelName], note: Optional[BlockNote], ) -> None: - """Writes the note a cell carries: a sample by id, a cut, or the emptiness of neither. + """Writes the note a cell carries: a voice by id, a cut, or the emptiness of neither. - A sample the project no longer holds leaves the cell as it stands, so a block outliving + A voice the project no longer holds leaves the cell as it stands, so a block outliving the project it was read from writes the notes that still name something and passes over - the rest. + the rest. The column the note lands in decides whether it takes that voice, which + :meth:`SequencerTrackerLogic.place_note` answers for every route alike. """ match note: case NoteOff(): self._tracker.cut_note(row_index, channel) case str() as voice_id: - if self._tracker.holds_sample(voice_id): + if self._tracker.holds_voice(voice_id): self._tracker.place_note(row_index, channel, voice_id) case None: self._tracker.clear_cell_subcolumn( diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index d37cca6b2..11e403fed 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -5,8 +5,8 @@ from sampletones_core.constants.enums import ChannelName _LEADING_TABLE_COLUMNS: Final[int] = 2 -VOICE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS -DIVIDER_TABLE_COLUMN: Final[int] = VOICE_TABLE_COLUMN + 1 +SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS +DIVIDER_TABLE_COLUMN: Final[int] = SAMPLE_TABLE_COLUMN + 1 _FIRST_CHANNEL_TABLE_COLUMN: Final[int] = DIVIDER_TABLE_COLUMN + 1 _TRAILING_TABLE_COLUMNS: Final[int] = 1 TRACKER_TABLE_COLUMNS: Final[int] = _FIRST_CHANNEL_TABLE_COLUMN + len(ChannelName.items()) + _TRAILING_TABLE_COLUMNS @@ -30,13 +30,13 @@ def channel_color(colors: ChannelColors, channel: ChannelName) -> BaseColor: def tracker_table_column(channel: Optional[ChannelName]) -> int: """Maps a logical column to its DPG table column index. - The visual divider between the voice column and the channels occupies a table + The visual divider between the sample column and the channels occupies a table column of its own, so the channels sit one slot further right than their logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers only the cursor-addressable columns. """ if channel is None: - return VOICE_TABLE_COLUMN + return SAMPLE_TABLE_COLUMN return _FIRST_CHANNEL_TABLE_COLUMN + ChannelName.items().index(channel) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index e2c232523..eb15261a3 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -43,8 +43,8 @@ DIVIDER_TABLE_COLUMN, HEADER_TABLE_ROW, HEADER_TABLE_ROWS, + SAMPLE_TABLE_COLUMN, TRACKER_TABLE_COLUMNS, - VOICE_TABLE_COLUMN, channel_color, tracker_table_column, tracker_table_row, @@ -92,6 +92,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) +from sampletones_application.view_model.sequencer.kind import places_across_channels from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, @@ -108,6 +109,7 @@ ) from sampletones_application.view_model.sequencer.voices import ( SequencerVoicesViewModel, + VoiceEntryViewModel, ) from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME @@ -326,7 +328,7 @@ def _load_column_labels(self, language_manager: LanguageManager) -> None: """Reads the name each column carries, which its header label and its menu title show.""" self._lbl_col_row = self._label(language_manager, SequencerTrackerElements.COLUMN_ROW) self._column_labels: Dict[Optional[ChannelName], str] = { - None: self._label(language_manager, SequencerTrackerElements.COLUMN_VOICE), + None: self._label(language_manager, SequencerTrackerElements.COLUMN_SAMPLE), ChannelName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1), ChannelName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2), ChannelName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE), @@ -371,7 +373,7 @@ def tooltip(element: SequencerTrackerElements) -> str: return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.TOOLTIP, element] self._tooltip_header_channel = channel_tooltip(tooltip(SequencerTrackerElements.HEADER_CHANNEL)) - self._tooltip_header_voice = tooltip(SequencerTrackerElements.HEADER_VOICE) + self._tooltip_header_sample = tooltip(SequencerTrackerElements.HEADER_SAMPLE) def _create_channel_switch(self, language_manager: LanguageManager) -> None: """Builds the switch a column header's click and menu act through. @@ -715,7 +717,7 @@ def _highlight_sample_column(self) -> None: """ dpg.highlight_table_column( TAG_SEQUENCER_TRACKER_TABLE, - VOICE_TABLE_COLUMN, + SAMPLE_TABLE_COLUMN, self._layout.colors.sample.column.rgba, ) dpg.highlight_table_column( @@ -770,7 +772,7 @@ def _compute_cell_values( ) -> CellValues: cell_values: CellValues = {} for row in view_model.rows: - cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.voice + cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.transpose cell_values[(row.index, None, SubColumn.VOLUME)] = row.volume for channel in ChannelName.items(): @@ -849,7 +851,7 @@ def _add_header_selectable( dpg.bind_item_handler_registry(selectable, self._header_handler_tag) show_tooltip( selectable, - self._tooltip_header_voice if channel is None else self._tooltip_header_channel, + self._tooltip_header_sample if channel is None else self._tooltip_header_channel, ) self._header_columns[selectable] = channel @@ -1059,13 +1061,24 @@ def _update_caret(self) -> None: def _resolve_voice_id( self, sample_index: int, + channel: Optional[ChannelName], ) -> Optional[Tuple[int, str]]: + """The voice a typed number names, where the column it was typed in takes that voice. + + A number past the end of the pool reads as the last voice, so a reader typing freely lands + on something. The sample column stands by for a voice it cannot spread over channels, and + answering nothing here is what leaves the cell showing the value it already held. + """ if not self._current_samples or not self._current_samples.voices: return None - samples = self._current_samples.voices - sample_index = max(0, min(sample_index, len(samples) - 1)) - return sample_index, samples[sample_index].voice_id + voices = self._current_samples.voices + sample_index = max(0, min(sample_index, len(voices) - 1)) + voice = voices[sample_index] + if not self._column_takes(channel, voice): + return None + + return sample_index, voice.voice_id def _handle_edit_action(self, action: EditAction) -> None: """Commits a single-subcolumn edit. @@ -1084,13 +1097,13 @@ def _handle_edit_action(self, action: EditAction) -> None: voice_id: Optional[str] = None if action.sample_index is not None: - resolved = self._resolve_voice_id(action.sample_index) - sample_index = resolved[0] if resolved is not None else None - voice_id = resolved[1] if resolved is not None else None - self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = tracker_display.format_committed( - SubColumn.INSTRUMENT, - sample_index, - ) + resolved = self._resolve_voice_id(action.sample_index, channel) + if resolved is not None: + sample_index, voice_id = resolved + self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = tracker_display.format_committed( + SubColumn.INSTRUMENT, + sample_index, + ) if action.transpose is not None: self._editable_cells.values[(row, channel, SubColumn.TRANSPOSE)] = tracker_display.format_committed( @@ -1443,22 +1456,42 @@ def _add_select_items(self, cell: TrackerCursor) -> None: ) def _add_instrument_submenu(self, cell: TrackerCursor) -> None: + """Offers the pool to a cell, each voice enabled where that cell's column takes it. + + The whole pool is listed wherever the menu is raised, so a reader sees every voice the + project holds and where each one goes: a channel column takes any of them, while the + sample column spreads a voice over the channels it covers and so takes a recording alone. + A voice the column stands by for is offered unreachable, which says it exists while + leaving it where it belongs. + """ with dpg.menu(label=self._lbl_context_set_voice): - samples = self._current_samples.voices if self._current_samples is not None else () - if not samples: + voices = self._current_samples.voices if self._current_samples is not None else () + if not voices: dpg.add_menu_item( label=self._lbl_context_no_voices, enabled=False, ) return - for index, sample in enumerate(samples): + for index, voice in enumerate(voices): dpg.add_menu_item( - label=tracker_display.indexed_label(index, sample.name), - user_data=(cell.row, cell.channel, sample.voice_id), + label=tracker_display.indexed_label(index, voice.name), + user_data=(cell.row, cell.channel, voice.voice_id), callback=self._on_set_instrument_menu, + enabled=self._column_takes(cell.channel, voice), ) + @staticmethod + def _column_takes( + channel: Optional[ChannelName], + voice: VoiceEntryViewModel, + ) -> bool: + """Whether the column a cell stands in places the voice a row of the menu names.""" + if channel is not None: + return True + + return places_across_channels(voice.kind) + def _add_transpose_items(self, target: TrackerTarget) -> None: self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu) diff --git a/src/sampletones_application/view_model/sequencer/kind.py b/src/sampletones_application/view_model/sequencer/kind.py index 910357cac..b5070034b 100644 --- a/src/sampletones_application/view_model/sequencer/kind.py +++ b/src/sampletones_application/view_model/sequencer/kind.py @@ -18,3 +18,19 @@ def voice_kind(voice: VoiceUnion) -> VoiceKind: return VoiceKind.SAMPLE case Instrument(): return VoiceKind.INSTRUMENT + + +def places_across_channels(kind: VoiceKind) -> bool: + """Whether the tracker's sample column can place a voice of this kind. + + The column writes a voice to every channel it covers and clears the rest, which a recording + states for itself. A hand-written instrument sounds wherever its envelopes make a frame, so the + channel it plays on is the reader's to name and it is placed in a channel column. + + Args: + kind: The kind of the voice being placed. + + Returns: + bool: Whether the sample column takes it. + """ + return kind is VoiceKind.SAMPLE diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index 94877f02d..83a9ba97d 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -1,8 +1,9 @@ -from typing import Callable, Dict, FrozenSet, Set, Tuple +from typing import Callable, Dict, FrozenSet, Optional, Set, Tuple from pydantic import BaseModel from sampletones_application.view_model.sequencer.aggregate import aggregate_labels +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import ( display_id, @@ -22,35 +23,57 @@ class SequencerCellViewModel(BaseModel, frozen=True): instrument: str transpose: str volume: str + kind: Optional[VoiceKind] + """The kind of the voice this cell names, absent where it names none. + + The cell reads its voice by list position, which says nothing about what that voice is. The + kind travels beside it so a reader of the cell — the sample column's summary, the colour the + slot takes — knows which of the two it is looking at. + """ @property def label(self) -> str: return f"{self.instrument} {self.transpose} {self.volume}" +def _sample_reading(cell: SequencerCellViewModel) -> str: + """A cell's voice reading as the sample column speaks it. + + The column places a recording over the channels it covers, so it reads a sample by its number + and a cut as a cut. A hand-written instrument is placed in the channel column that names it, so + it reads as empty here and leaves the summary to the channels the column governs. + """ + if cell.kind is VoiceKind.INSTRUMENT: + return display_id(None) + + return cell.instrument + + class SequencerRowViewModel(BaseModel, frozen=True): index: int cells: Dict[ChannelName, SequencerCellViewModel] - relevant_channels: FrozenSet[ChannelName] - """Channels the row's voices span — the union of their reconstructions' channels. + sample_channels: FrozenSet[ChannelName] + """Channels the row's samples span — the union of their reconstructions' channels. - The voice column summarises a subcolumn only across these channels, so a - sample that spans more channels than it currently occupies reads as mixed. + A sample governs the channels its reconstruction covers, so the sample column reads its + reference across exactly those and a sample missing from one of them reads as mixed. A row + naming only hand-written instruments spans none, since each of those sounds on the one + channel it is named in. """ @property def subcolumn_channels(self) -> FrozenSet[ChannelName]: - """Channels every voice column summary spans. + """Channels every sample column summary spans. A sample governs the channels its reconstruction covers, so its subcolumns summarise exactly those. Transpose and volume stand on their own, so a row - naming no voice spans every channel. + naming no sample spans every channel. """ - return self.relevant_channels or frozenset(self.cells) + return self.sample_channels or frozenset(self.cells) @property - def voice(self) -> str: - return self._aggregate(lambda cell: cell.instrument, display_id(None)) + def sample(self) -> str: + return self._aggregate(_sample_reading, display_id(None)) @property def transpose(self) -> str: @@ -65,10 +88,10 @@ def _aggregate( select: Callable[[SequencerCellViewModel], str], default: str, ) -> str: - """Summarise one subcolumn across the channels the voice column spans. + """Summarise one subcolumn across the channels the sample column spans. The summary holds a value only where every channel agrees on it, so - :data:`MIXED` marks each way they can differ: a voice missing from one of + :data:`MIXED` marks each way they can differ: a sample missing from one of its channels, a transpose set on some of them, or a row cut on some and blank on the rest. A row with no cells at all shows the empty default. """ diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 2d289b2bc..d5437fc73 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -532,7 +532,7 @@ sequencer.tracker.label.tracker_text: "Tracker" sequencer.tracker.label.octave: "Octave" sequencer.tracker.tooltip.octave: "The octave a note key types at" sequencer.tracker.label.column_row: "Row" -sequencer.tracker.label.column_voice: "Voice" +sequencer.tracker.label.column_sample: "Sample" sequencer.tracker.label.column_pulse_1: "Pulse 1" sequencer.tracker.label.column_pulse_2: "Pulse 2" sequencer.tracker.label.column_triangle: "Triangle" @@ -563,7 +563,7 @@ sequencer.tracker.label.context_unsolo: "Unsolo" sequencer.tracker.label.context_mute_all: "Mute all channels" sequencer.tracker.label.context_unmute_all: "Unmute all channels" sequencer.tracker.tooltip.header_channel: "Click to mute or unmute this channel.\n{modifier}+click to solo it, right-click for channel actions." -sequencer.tracker.tooltip.header_voice: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions." +sequencer.tracker.tooltip.header_sample: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions." # ============================================================================= # Sequencer tab — Order diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py index d5a6cc6dc..93bc188b8 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -123,7 +123,7 @@ def test_it_declines_an_instrument(self) -> None: controller, logic = _logic() instrument = _instrument(controller) - logic.set_row_voice(0, instrument.id) + logic.set_row_sample(0, instrument.id) assert all(logic.row(channel, 0) is None or logic.row(channel, 0).is_empty() for channel in ChannelName.items()) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 9d660c848..0bfed9e5d 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -2,13 +2,16 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.utils.display import NOTE_OFF, display_id from sampletones_shared.constants.symbols import MIXED -from tests.suite.sequencer import sample_reconstruction +from tests.suite.sequencer import UNKNOWN_SAMPLE_ID, sample_reconstruction def _controller() -> ProjectController: @@ -81,7 +84,7 @@ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) logic.set_note_off(ChannelName.NOISE, 0) logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT) @@ -96,7 +99,7 @@ def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> No sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) for channel in ChannelName.items(): logic.set_row(channel, 0, transpose=5) @@ -294,7 +297,7 @@ def test_fills_only_used_generators(self) -> None: name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): command = _row(controller, channel).command @@ -324,7 +327,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: sample_reconstruction([ChannelName.PULSE1]), name="lead", ) - logic.set_row_voice(0, lead.id) + logic.set_row_sample(0, lead.id) assert _row(controller, ChannelName.PULSE1).command is not None cleared = _row(controller, ChannelName.PULSE2) @@ -338,9 +341,9 @@ def test_none_sample_clears_the_whole_row(self) -> None: sample_reconstruction([ChannelName.PULSE1]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) - logic.set_row_voice(0, None) + logic.set_row_sample(0, None) for channel in ChannelName.items(): assert _row(controller, channel).command is None @@ -397,7 +400,7 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) logic.set_sample_subcolumn(0, transpose=5) logic.set_sample_subcolumn(0, volume=10) @@ -491,7 +494,7 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: row = logic.build_grid().rows[0] - assert row.voice == MIXED + assert row.sample == MIXED def test_full_placement_reads_as_the_sample(self) -> None: controller = _controller() @@ -500,12 +503,12 @@ def test_full_placement_reads_as_the_sample(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) row = logic.build_grid().rows[0] - assert row.voice == row.cells[ChannelName.PULSE1].instrument - assert row.voice != MIXED + assert row.sample == row.cells[ChannelName.PULSE1].instrument + assert row.sample != MIXED def test_diverging_transpose_renders_as_mixed(self) -> None: controller = _controller() @@ -514,7 +517,7 @@ def test_diverging_transpose_renders_as_mixed(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) logic.set_row(ChannelName.PULSE1, 0, transpose=5) row = logic.build_grid().rows[0] @@ -528,7 +531,7 @@ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - logic.set_row_voice(0, sample.id) + logic.set_row_sample(0, sample.id) logic.set_sample_subcolumn(0, transpose=5) row = logic.build_grid().rows[0] @@ -564,3 +567,119 @@ def test_empty_frame_still_shows_editable_rows(self) -> None: tracker = logic.build_grid() assert len(tracker.rows) == controller.project.song.rows_per_pattern + + +class TestWhatTheSampleColumnPlaces: + """The column spreads a voice over the channels it covers, which a recording states for itself.""" + + def test_a_sample_is_placed_across_the_channels_it_covers(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + + logic.place_note(0, None, sample.id) + + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + assert isinstance(_row(controller, channel).command, NoteOn) + + def test_an_instrument_leaves_the_row_as_it_stands(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + instrument = controller.add_instrument(new_instrument("lead")) + + logic.place_note(0, None, instrument.id) + + for channel in ChannelName.items(): + assert _row(controller, channel).command is None + + def test_an_instrument_lands_on_the_channel_column_that_names_it(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + instrument = controller.add_instrument(new_instrument("lead")) + + logic.place_note(0, ChannelName.NOISE, instrument.id) + + command = _row(controller, ChannelName.NOISE).command + assert isinstance(command, NoteOn) + assert command.voice_id == instrument.id + + def test_the_column_answers_for_a_sample(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1]), + name="lead", + ) + + assert logic.places_in_sample_column(sample.id) is True + + def test_the_column_stands_by_for_an_instrument(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + instrument = controller.add_instrument(new_instrument("lead")) + + assert logic.places_in_sample_column(instrument.id) is False + + def test_the_column_stands_by_for_a_voice_the_project_lost(self) -> None: + logic = SequencerTrackerLogic(_controller()) + + assert logic.places_in_sample_column(UNKNOWN_SAMPLE_ID) is False + + +class TestWhatTheSampleColumnReads: + """The column summarises what its own kind of voice put on the row.""" + + def test_an_instrument_alone_leaves_the_column_empty(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + instrument = controller.add_instrument(new_instrument("lead")) + _place_instrument(controller, ChannelName.PULSE1, instrument.id) + + row = logic.build_grid().rows[0] + + assert row.sample == display_id(None) + assert row.sample_channels == frozenset() + + def test_a_sample_beside_an_instrument_reads_as_that_sample(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + instrument = controller.add_instrument(new_instrument("pad")) + logic.set_row_sample(0, sample.id) + _place_instrument(controller, ChannelName.NOISE, instrument.id) + + row = logic.build_grid().rows[0] + + assert row.sample == row.cells[ChannelName.PULSE1].instrument + assert row.sample != MIXED + + def test_a_row_cut_on_every_channel_still_reads_as_a_cut(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.cut_note(0, None) + + assert logic.build_grid().rows[0].sample == NOTE_OFF + + def test_a_cell_names_the_kind_of_the_voice_it_starts(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1]), + name="lead", + ) + instrument = controller.add_instrument(new_instrument("pad")) + _place_instrument(controller, ChannelName.PULSE1, sample.id) + _place_instrument(controller, ChannelName.NOISE, instrument.id) + + cells = logic.build_grid().rows[0].cells + + assert cells[ChannelName.PULSE1].kind is VoiceKind.SAMPLE + assert cells[ChannelName.NOISE].kind is VoiceKind.INSTRUMENT + assert cells[ChannelName.TRIANGLE].kind is None diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py index 522f0e726..a69b88c0e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -14,6 +14,7 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.creation import new_instrument from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.sequencer import ( @@ -28,11 +29,12 @@ EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." LEAD: Final[str] = "00" BASS: Final[str] = "01" +PAD: Final[str] = "02" @dataclass(frozen=True, kw_only=True) class Grid: - """A four-row frame with two samples, the state every paste case starts from.""" + """A four-row frame with two samples and an instrument, the state every paste case starts from.""" controller: ProjectController logic: SequencerTrackerLogic @@ -42,12 +44,13 @@ class Grid: @pytest.fixture def grid() -> Grid: - """A frame short enough for a case to state whole, holding a sample over two channels and one - over a third. + """A frame short enough for a case to state whole, holding a sample over two channels, one over + a third, and an instrument. - Which channels a sample governs is what the sample column fans a write out over, so the pair - covers both readings: a write that reaches some channels and clears the rest, and a note - written into a channel its own reconstruction leaves out. + Which channels a sample governs is what the sample column fans a write out over, so the pair of + samples covers both readings: a write that reaches some channels and clears the rest, and a note + written into a channel its own reconstruction leaves out. The instrument beside them is what the + sample column stands by for, so a block carrying one states where it does and does not land. """ controller = ProjectController(ProjectManager()) logic = SequencerTrackerLogic(controller) @@ -60,11 +63,12 @@ def grid() -> Grid: sample_reconstruction([ChannelName.TRIANGLE]), name="bass", ) + pad = controller.add_instrument(new_instrument("pad")) return Grid( controller=controller, logic=logic, writer=TrackerBlockWriter(logic), - voice_ids=(lead.id, bass.id), + voice_ids=(lead.id, bass.id, pad.id), ) @@ -110,6 +114,25 @@ class TestCase(BaseRegularTestCase): EMPTY, ), ), + TestCase( + label="an instrument through the sample column is passed over", + block=(PAD,), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, channel=None), + expected=(EMPTY, EMPTY, EMPTY, EMPTY), + ), + TestCase( + label="an instrument through a channel column lands on that channel", + block=(PAD,), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, channel=ChannelName.NOISE), + expected=( + ".. ... . | .. ... . | .. ... . | 02 ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), TestCase( label="a channel beside the sample column overwrites what it settled", block=(f"{LEAD} ... . | {BASS}",), diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py index 2fd4842e1..91629fd8a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py @@ -4,8 +4,8 @@ DIVIDER_TABLE_COLUMN, HEADER_TABLE_ROW, HEADER_TABLE_ROWS, + SAMPLE_TABLE_COLUMN, TRACKER_TABLE_COLUMNS, - VOICE_TABLE_COLUMN, tracker_table_column, tracker_table_row, ) @@ -22,8 +22,8 @@ def test_sample_column_directly_precedes_the_divider() -> None: - assert tracker_table_column(None) == VOICE_TABLE_COLUMN == 2 - assert DIVIDER_TABLE_COLUMN == VOICE_TABLE_COLUMN + 1 + assert tracker_table_column(None) == SAMPLE_TABLE_COLUMN == 2 + assert DIVIDER_TABLE_COLUMN == SAMPLE_TABLE_COLUMN + 1 @pytest.mark.parametrize("channel, expected_column", _CHANNEL_COLUMNS) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 5c63c6bdf..1a8cf1fa4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -1,5 +1,5 @@ import contextlib -from typing import Any, Iterator, List, Tuple +from typing import Any, Dict, Iterator, List, Tuple import pytest @@ -56,12 +56,20 @@ class _MenuItemRecorder: def __init__(self) -> None: self.items: List[Tuple[Any, Any]] = [] + self.entries: List[Dict[str, Any]] = [] def add_menu_item(self, **kwargs: Any) -> int: + self.entries.append(kwargs) if "callback" in kwargs and "user_data" in kwargs: self.items.append((kwargs["user_data"], kwargs["callback"])) return 0 + def reachable(self, label: str) -> bool: + """Whether the one item carrying ``label`` was offered enabled.""" + entries = [entry for entry in self.entries if entry["label"] == label] + assert len(entries) == 1, f"{label!r} appears {len(entries)} times" + return bool(entries[0]["enabled"]) + def dispatch_as_dpg(self) -> None: """Fires each recorded callback the way DearPyGui does: sender first.""" for user_data, callback in self.items: @@ -153,3 +161,69 @@ def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) - recorder.dispatch_as_dpg() assert chosen == ["lead-id"] + + +class TestWhichVoicesAColumnOffers: + """The whole pool is listed wherever the menu is raised; the column decides what is reachable.""" + + SAMPLE_LABEL = "00 lead" + INSTRUMENT_LABEL = "01 pad" + + @staticmethod + def _panel_with_both_kinds() -> tracker_module.GUISequencerTrackerPanel: + panel = _panel() + panel._current_samples = SequencerVoicesViewModel( + voices=( + VoiceEntryViewModel( + voice_id="lead-id", + name="lead", + kind=VoiceKind.SAMPLE, + loop=False, + ), + VoiceEntryViewModel( + voice_id="pad-id", + name="pad", + kind=VoiceKind.INSTRUMENT, + loop=False, + ), + ), + ) + return panel + + def test_a_channel_column_reaches_both_kinds(self, recorder: _MenuItemRecorder) -> None: + panel = self._panel_with_both_kinds() + + panel._add_instrument_submenu(_cell(0, ChannelName.PULSE2)) + + assert recorder.reachable(self.SAMPLE_LABEL) is True + assert recorder.reachable(self.INSTRUMENT_LABEL) is True + + def test_the_sample_column_reaches_a_sample_alone(self, recorder: _MenuItemRecorder) -> None: + panel = self._panel_with_both_kinds() + + panel._add_instrument_submenu(_cell(0, None)) + + assert recorder.reachable(self.SAMPLE_LABEL) is True + assert recorder.reachable(self.INSTRUMENT_LABEL) is False + + def test_the_sample_column_still_names_the_instrument_it_stands_by_for( + self, + recorder: _MenuItemRecorder, + ) -> None: + """An unreachable item says the voice exists while leaving it where it belongs.""" + panel = self._panel_with_both_kinds() + + panel._add_instrument_submenu(_cell(0, None)) + + assert [entry["label"] for entry in recorder.entries] == [ + self.SAMPLE_LABEL, + self.INSTRUMENT_LABEL, + ] + + def test_an_empty_pool_offers_one_unreachable_item(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + panel._current_samples = SequencerVoicesViewModel(voices=()) + + panel._add_instrument_submenu(_cell(0, None)) + + assert [entry["enabled"] for entry in recorder.entries] == [False] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py index af6638f29..1aa7c74f1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py @@ -393,5 +393,5 @@ def test_the_channel_tooltip_leaves_no_placeholder_behind(self, panel: GUISequen def test_both_headers_explain_their_click(self, panel: GUISequencerTrackerPanel) -> None: assert panel._tooltip_header_channel - assert panel._tooltip_header_voice - assert panel._tooltip_header_channel != panel._tooltip_header_voice + assert panel._tooltip_header_sample + assert panel._tooltip_header_channel != panel._tooltip_header_sample diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py new file mode 100644 index 000000000..6d371b45a --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -0,0 +1,123 @@ +from typing import List, Optional, Tuple + +import pytest + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.input.edit import EditAction +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, + VoiceEntryViewModel, + VoiceKind, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import display_id + +SAMPLE_INDEX = 0 +INSTRUMENT_INDEX = 1 +STORED_LABEL = display_id(None) + +Write = Tuple[int, Optional[ChannelName], Optional[str]] + + +class Panel: + """A tracker panel holding the pool and the cell cache a typed edit reads and writes. + + The commit path touches only those two and the ``on_set_row`` hook, so the widgets the labels + are drawn on stay out of it and the reading under test is what the cache is left holding. + """ + + def __init__(self) -> None: + self.panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) + self.panel._editable_cells = EditableCells() + self.panel._current_samples = SequencerVoicesViewModel( + voices=( + VoiceEntryViewModel( + voice_id="lead-id", + name="lead", + kind=VoiceKind.SAMPLE, + loop=False, + ), + VoiceEntryViewModel( + voice_id="pad-id", + name="pad", + kind=VoiceKind.INSTRUMENT, + loop=False, + ), + ), + ) + self.writes: List[Write] = [] + self.panel.on_set_row = lambda row, channel, voice_id, transpose, volume: self.writes.append( + (row, channel, voice_id) + ) + + def type_voice(self, index: int, channel: Optional[ChannelName]) -> None: + self.panel._handle_edit_action( + EditAction( + row=0, + channel=channel, + sample_index=index, + transpose=None, + volume=None, + ) + ) + + def shown(self, channel: Optional[ChannelName]) -> str: + """The label the cell cache holds, which is what the cell shows once the commit settles.""" + return self.panel._editable_cells.values.get((0, channel, SubColumn.INSTRUMENT), STORED_LABEL) + + +@pytest.fixture +def panel() -> Panel: + return Panel() + + +class TestTypingAVoiceNumber: + """The column a number is typed in decides whether the voice it names lands there.""" + + def test_a_sample_typed_in_the_sample_column_is_written(self, panel: Panel) -> None: + panel.type_voice(SAMPLE_INDEX, None) + + assert panel.writes == [(0, None, "lead-id")] + assert panel.shown(None) == display_id(SAMPLE_INDEX) + + def test_an_instrument_typed_in_a_channel_column_is_written(self, panel: Panel) -> None: + panel.type_voice(INSTRUMENT_INDEX, ChannelName.NOISE) + + assert panel.writes == [(0, ChannelName.NOISE, "pad-id")] + assert panel.shown(ChannelName.NOISE) == display_id(INSTRUMENT_INDEX) + + def test_an_instrument_typed_in_the_sample_column_names_no_voice(self, panel: Panel) -> None: + panel.type_voice(INSTRUMENT_INDEX, None) + + assert panel.writes == [(0, None, None)] + + def test_an_instrument_typed_in_the_sample_column_leaves_the_cell_showing_what_it_held( + self, + panel: Panel, + ) -> None: + """The refusal changes nothing, so a cell taking the number optimistically would keep it.""" + panel.type_voice(INSTRUMENT_INDEX, None) + + assert panel.shown(None) == STORED_LABEL + + def test_a_number_past_the_pool_reads_as_the_last_voice(self, panel: Panel) -> None: + panel.type_voice(99, ChannelName.PULSE1) + + assert panel.writes == [(0, ChannelName.PULSE1, "pad-id")] + + def test_a_number_past_the_pool_still_answers_to_the_column(self, panel: Panel) -> None: + """The last voice is the instrument, which the sample column stands by for.""" + panel.type_voice(99, None) + + assert panel.writes == [(0, None, None)] + assert panel.shown(None) == STORED_LABEL + + def test_typing_into_an_empty_pool_names_no_voice(self, panel: Panel) -> None: + panel.panel._current_samples = SequencerVoicesViewModel(voices=()) + + panel.type_voice(SAMPLE_INDEX, ChannelName.PULSE1) + + assert panel.writes == [(0, ChannelName.PULSE1, None)] + assert panel.shown(ChannelName.PULSE1) == STORED_LABEL diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index c3909e929..9c81d5298 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, FrozenSet +from typing import Dict, FrozenSet, Optional import pytest @@ -7,6 +7,7 @@ SequencerCellViewModel, SequencerRowViewModel, ) +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import ( NOTE_OFF, @@ -28,11 +29,13 @@ def _cell( instrument: str = _EMPTY_INSTRUMENT, transpose: str = _EMPTY_TRANSPOSE, volume: str = _EMPTY_VOLUME, + kind: Optional[VoiceKind] = None, ) -> SequencerCellViewModel: return SequencerCellViewModel( instrument=instrument, transpose=transpose, volume=volume, + kind=kind, ) @@ -44,6 +47,7 @@ def _empty_cell() -> SequencerCellViewModel: instrument=display_id(0), transpose=display_transpose(5), volume=display_volume(8), + kind=VoiceKind.SAMPLE, ) @@ -61,16 +65,16 @@ class TestSampleColumnAggregate(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class AggregateCase(BaseRegularTestCase): cells: Dict[ChannelName, SequencerCellViewModel] - relevant_channels: FrozenSet[ChannelName] + sample_channels: FrozenSet[ChannelName] expected_instrument: str expected_transpose: str expected_volume: str test_cases = ( AggregateCase( - label="no_relevant_channels_fall_back_to_defaults", + label="no_sample_channels_fall_back_to_defaults", cells=_row_cells(), - relevant_channels=frozenset(), + sample_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, @@ -78,23 +82,23 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="transpose_and_volume_span_all_channels_when_no_sample_is_present", cells={channel: _cell(volume=display_volume(8)) for channel in ChannelName.items()}, - relevant_channels=frozenset(), + sample_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=display_volume(8), ), AggregateCase( - label="single_relevant_channel_present", + label="a_single_sample_channel_present", cells=_row_cells(pulse1=_OCCUPIED), - relevant_channels=frozenset({ChannelName.PULSE1}), + sample_channels=frozenset({ChannelName.PULSE1}), expected_instrument=display_id(0), expected_transpose=display_transpose(5), expected_volume=display_volume(8), ), AggregateCase( - label="sample_present_across_all_its_relevant_channels", + label="a_sample_present_across_every_channel_it_covers", cells=_row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED), - relevant_channels=frozenset( + sample_channels=frozenset( { ChannelName.PULSE1, ChannelName.TRIANGLE, @@ -105,9 +109,9 @@ class AggregateCase(BaseRegularTestCase): expected_volume=display_volume(8), ), AggregateCase( - label="sample_missing_from_one_relevant_channel_is_mixed", + label="a_sample_missing_from_one_of_its_channels_is_mixed", cells=_row_cells(pulse1=_OCCUPIED), - relevant_channels=frozenset( + sample_channels=frozenset( { ChannelName.PULSE1, ChannelName.TRIANGLE, @@ -127,7 +131,7 @@ class AggregateCase(BaseRegularTestCase): volume=display_volume(8), ), ), - relevant_channels=frozenset( + sample_channels=frozenset( { ChannelName.PULSE1, ChannelName.TRIANGLE, @@ -137,10 +141,43 @@ class AggregateCase(BaseRegularTestCase): expected_transpose=MIXED, expected_volume=display_volume(8), ), + AggregateCase( + label="an_instrument_alone_on_a_row_leaves_the_sample_column_empty", + cells=_row_cells( + pulse1=_cell( + instrument=display_id(3), + kind=VoiceKind.INSTRUMENT, + ), + ), + sample_channels=frozenset(), + expected_instrument=_EMPTY_INSTRUMENT, + expected_transpose=_EMPTY_TRANSPOSE, + expected_volume=_EMPTY_VOLUME, + ), + AggregateCase( + label="an_instrument_beside_a_sample_leaves_the_samples_reading_alone", + cells=_row_cells( + pulse1=_OCCUPIED, + triangle=_OCCUPIED, + noise=_cell( + instrument=display_id(3), + kind=VoiceKind.INSTRUMENT, + ), + ), + sample_channels=frozenset( + { + ChannelName.PULSE1, + ChannelName.TRIANGLE, + } + ), + expected_instrument=display_id(0), + expected_transpose=display_transpose(5), + expected_volume=display_volume(8), + ), AggregateCase( label="all_channels_note_off_reads_as_note_off", cells={channel: _cell(instrument=NOTE_OFF) for channel in ChannelName.items()}, - relevant_channels=frozenset(), + sample_channels=frozenset(), expected_instrument=NOTE_OFF, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, @@ -148,7 +185,7 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="half_cut_row_is_mixed", cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)), - relevant_channels=frozenset(), + sample_channels=frozenset(), expected_instrument=MIXED, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, @@ -156,7 +193,7 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="zero_transpose_beside_an_empty_one_is_mixed", cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))), - relevant_channels=frozenset(), + sample_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=MIXED, expected_volume=_EMPTY_VOLUME, @@ -164,7 +201,7 @@ class AggregateCase(BaseRegularTestCase): AggregateCase( label="zero_transpose_shared_by_every_channel_reads_as_zero", cells={channel: _cell(transpose=display_transpose(0)) for channel in ChannelName.items()}, - relevant_channels=frozenset(), + sample_channels=frozenset(), expected_instrument=_EMPTY_INSTRUMENT, expected_transpose=display_transpose(0), expected_volume=_EMPTY_VOLUME, @@ -172,16 +209,16 @@ class AggregateCase(BaseRegularTestCase): ) @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_sample_column_aggregates_over_relevant_channels( + def test_sample_column_aggregates_over_the_channels_it_spans( self, case: AggregateCase, ) -> None: row = SequencerRowViewModel( index=0, cells=case.cells, - relevant_channels=case.relevant_channels, + sample_channels=case.sample_channels, ) - assert row.voice == case.expected_instrument + assert row.sample == case.expected_instrument assert row.transpose == case.expected_transpose assert row.volume == case.expected_volume From 0498c09848dcb273cb6ec28168ba4f38a6ae0b10 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 00:01:09 +0200 Subject: [PATCH 096/142] Added: a reconstruction's account of how far it has come --- .../services/progress.py | 4 +- src/sampletones_core/exports/backend.py | 11 +- .../exports/implementation/bitphase.py | 17 +-- .../exports/implementation/famitracker.py | 11 +- src/sampletones_core/exports/progress.py | 10 +- src/sampletones_core/performance/__init__.py | 2 - src/sampletones_core/performance/progress.py | 10 +- src/sampletones_core/performance/song.py | 4 +- .../reconstructions/converter/conversion.py | 14 +- .../reconstructions/converter/converter.py | 6 +- .../reconstructions/progress.py | 65 +++++++++ .../reconstructor/reconstructor.py | 32 ++++- src/sampletones_core/reconstructions/stage.py | 47 +++++++ .../scripts/reconstruction.py | 18 ++- src/sampletones_player/builder.py | 12 +- src/sampletones_player/compression/encode.py | 4 +- .../compression/progress/report.py | 10 +- src/sampletones_player/compression/song.py | 5 +- src/sampletones_player/export.py | 8 +- src/sampletones_player/song.py | 5 +- src/sampletones_shared/utils/progress.py | 29 ++++ .../reconstruction/test_conversion_jobs.py | 76 ++++++++++- tests/suite/progress.py | 19 ++- .../services/export/test_service.py | 14 +- .../services/test_progress.py | 7 +- .../sampletones_core/exports/test_progress.py | 4 +- .../converter/test_conversion.py | 24 ++-- .../reconstructions/test_progress.py | 124 ++++++++++++++++++ .../compression/progress/test_monitor.py | 4 +- .../compression/test_admit.py | 4 +- 30 files changed, 482 insertions(+), 118 deletions(-) create mode 100644 src/sampletones_core/reconstructions/progress.py create mode 100644 src/sampletones_core/reconstructions/stage.py create mode 100644 src/sampletones_shared/utils/progress.py create mode 100644 tests/unit/sampletones_core/reconstructions/test_progress.py diff --git a/src/sampletones_application/services/progress.py b/src/sampletones_application/services/progress.py index cf54b49dc..bdddb5124 100644 --- a/src/sampletones_application/services/progress.py +++ b/src/sampletones_application/services/progress.py @@ -2,10 +2,10 @@ from sampletones_application.services.result import ServiceProgress from sampletones_core.parallelization import ETAEstimator +from sampletones_shared.utils.progress import report_interval StageT = TypeVar("StageT") -PROGRESS_STEPS: Final[int] = 200 UNMEASURED: Final[int] = 0 @@ -45,7 +45,7 @@ def __init__( self._total = total self._emit = emit self._estimator = ETAEstimator(total=total) if estimates and total > UNMEASURED else None - self._interval = max(1, total // PROGRESS_STEPS) + self._interval = report_interval(total) self._reported: int = 0 def advance(self, completed: int) -> None: diff --git a/src/sampletones_core/exports/backend.py b/src/sampletones_core/exports/backend.py index a4c1856b2..b8d6bd1ad 100644 --- a/src/sampletones_core/exports/backend.py +++ b/src/sampletones_core/exports/backend.py @@ -3,13 +3,14 @@ from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.progress import SILENT_REPORTER, ExportReporter +from sampletones_core.exports.progress import ExportReporter from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, SampleExport, ) from sampletones_core.exports.scope import ExportScope +from sampletones_shared.utils.progress import silent_reporter class ExportBackend(Protocol): @@ -22,7 +23,7 @@ class ExportBackend(Protocol): A write reports itself as it runs and asks its reporter whether the answer is still wanted, which is what lets a caller watch a long format and withdraw one. A caller with - nothing to tell passes :data:`SILENT_REPORTER` and hears back the file alone. + nothing to tell passes :func:`silent_reporter` and hears back the file alone. """ @property @@ -47,7 +48,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Writes one channel slice. @@ -68,7 +69,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Writes every channel slice of one reconstruction. @@ -91,7 +92,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Writes a whole composition. diff --git a/src/sampletones_core/exports/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py index 6a01f18ff..ba5df71b6 100644 --- a/src/sampletones_core/exports/implementation/bitphase.py +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -3,7 +3,7 @@ from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.progress import SILENT_REPORTER, ExportReporter, announce +from sampletones_core.exports.progress import ExportReporter, announce from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, @@ -19,6 +19,7 @@ ) from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_shared.utils.progress import silent_reporter from sampletones_shared.utils.system.paths import get_filename DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) @@ -53,7 +54,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_btp(destination, instrument_to_bitphase(request)) @@ -65,7 +66,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_btp(destination, sample_to_bitphase(request)) @@ -77,7 +78,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_btp(destination, project_to_bitphase(request.project)) @@ -110,7 +111,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_preset(destination, instrument_to_preset(request)) @@ -122,7 +123,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: destination.parent.mkdir(parents=True, exist_ok=True) @@ -137,7 +138,7 @@ def write_sample( EXT_FILE_JSON, ) ) - paths.extend(self.write_instrument(filepath, instrument, SILENT_REPORTER).paths) + paths.extend(self.write_instrument(filepath, instrument, silent_reporter).paths) announce(report, ExportStage.WRITING, index, written) return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) @@ -146,7 +147,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Reports that a preset holds one instrument. diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index 59b74914a..b084a2230 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -4,7 +4,7 @@ from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat -from sampletones_core.exports.progress import SILENT_REPORTER, ExportReporter, announce +from sampletones_core.exports.progress import ExportReporter, announce from sampletones_core.exports.request import ( InstrumentExport, ProjectExport, @@ -22,6 +22,7 @@ MAX_SEQUENCE_ITEMS, ) from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from sampletones_shared.utils.progress import silent_reporter from sampletones_shared.utils.system.paths import get_filename SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) @@ -53,7 +54,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) instrument = build_instrument( @@ -77,7 +78,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: destination.parent.mkdir(parents=True, exist_ok=True) @@ -93,7 +94,7 @@ def write_sample( EXT_FILE_INSTRUMENT, ) ) - artifact = self.write_instrument(filepath, instrument, SILENT_REPORTER) + artifact = self.write_instrument(filepath, instrument, silent_reporter) paths.extend(artifact.paths) truncations.append(artifact.truncation) announce(report, ExportStage.WRITING, index, written) @@ -107,7 +108,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) write_ftm(destination, request.project) diff --git a/src/sampletones_core/exports/progress.py b/src/sampletones_core/exports/progress.py index f0159edbd..cd18def09 100644 --- a/src/sampletones_core/exports/progress.py +++ b/src/sampletones_core/exports/progress.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, Final, Optional +from typing import Callable, Optional from sampletones_core.exports.stage import ExportStage from sampletones_shared.exceptions import OperationCancelled @@ -24,14 +24,6 @@ class ExportProgress: ExportReporter = Callable[[ExportProgress], bool] -def _carry_on(progress: ExportProgress) -> bool: # pylint: disable=unused-argument - """Answers that the run goes on, which is what a caller watching nothing asks of a stage.""" - return True - - -SILENT_REPORTER: Final[ExportReporter] = _carry_on - - def announce( report: ExportReporter, stage: ExportStage, diff --git a/src/sampletones_core/performance/__init__.py b/src/sampletones_core/performance/__init__.py index 4c9f8dedf..2ea4f4307 100644 --- a/src/sampletones_core/performance/__init__.py +++ b/src/sampletones_core/performance/__init__.py @@ -1,6 +1,5 @@ from .modifiers import apply_modifiers from .progress import ( - SILENT_WALK_REPORTER, WalkProgress, WalkReporter, announce, @@ -12,7 +11,6 @@ from .voice import VoiceReading __all__ = [ - "SILENT_WALK_REPORTER", "ChannelPerformance", "VoiceReading", "WalkProgress", diff --git a/src/sampletones_core/performance/progress.py b/src/sampletones_core/performance/progress.py index 7579addec..a779c98bd 100644 --- a/src/sampletones_core/performance/progress.py +++ b/src/sampletones_core/performance/progress.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, Final +from typing import Callable from sampletones_shared.exceptions import OperationCancelled @@ -21,14 +21,6 @@ class WalkProgress: WalkReporter = Callable[[WalkProgress], bool] -def _carry_on(progress: WalkProgress) -> bool: # pylint: disable=unused-argument - """Answers that the walk goes on, which is what a caller watching nothing asks of it.""" - return True - - -SILENT_WALK_REPORTER: Final[WalkReporter] = _carry_on - - def announce(report: WalkReporter, ticks: int, total: int) -> None: """Tells a reporter how far the walk has come, and unwinds a walk it withdraws. diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index c92f96d70..8ca1a7c75 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -4,7 +4,6 @@ from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP from sampletones_core.instructions import InstructionUnion from sampletones_core.performance.progress import ( - SILENT_WALK_REPORTER, WalkReporter, announce, ) @@ -16,11 +15,12 @@ from sampletones_core.project.song_position import SongPosition from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.timing.song import SongTiming +from sampletones_shared.utils.progress import silent_reporter def song_instructions( project: Project, - report: WalkReporter = SILENT_WALK_REPORTER, + report: WalkReporter = silent_reporter, ) -> Dict[ChannelName, List[InstructionUnion]]: """Plays a whole song out as the instructions each channel sounds, one per engine tick. diff --git a/src/sampletones_core/reconstructions/converter/conversion.py b/src/sampletones_core/reconstructions/converter/conversion.py index badc36b38..baa4e1eee 100644 --- a/src/sampletones_core/reconstructions/converter/conversion.py +++ b/src/sampletones_core/reconstructions/converter/conversion.py @@ -5,28 +5,30 @@ from sampletones_shared.exceptions import UnsupportedAudioFormatError from sampletones_shared.logger import logger +from ..progress import ReconstructionReporter from ..reconstructor.reconstructor import Reconstructor from .job import ConversionJob -def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob]) -> Path: +def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob, ReconstructionReporter]) -> Path: """Builds one job's reconstruction and writes it where the job says. - Runs in a pool worker, so the job travels with the reconstructor that builds it. A source - in a format the loader has no reader for is reported and left, which keeps one such file - from ending a batch. + Runs in a pool worker, so the job travels with the reconstructor that builds it and the + reporter it tells its progress to. A source in a format the loader has no reader for is + reported and left, which keeps one such file from ending a batch. Returns: The file the job named, whether or not a reconstruction reached it. Raises: KeyboardInterrupt: If the run is interrupted, so the pool stops. + OperationCancelled: If the run is withdrawn while the job is under way. """ - reconstructor, job = arguments + reconstructor, job, report = arguments job.output_path.parent.mkdir(parents=True, exist_ok=True) reconstruction = None try: - reconstruction = reconstructor.reconstruct(job.sources, job.stems) + reconstruction = reconstructor.reconstruct(job.sources, job.stems, report=report) if reconstruction is not None: reconstruction.save(job.output_path) del reconstruction diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py index 285c9d1b3..f2f2f7cd1 100644 --- a/src/sampletones_core/reconstructions/converter/converter.py +++ b/src/sampletones_core/reconstructions/converter/converter.py @@ -5,7 +5,9 @@ from sampletones_core.parallelization import TaskProcessor from sampletones_shared.logger import LoggerProtocol from sampletones_shared.logger import logger as default_logger +from sampletones_shared.utils.progress import silent_reporter +from ..progress import ReconstructionReporter from ..reconstructor.reconstructor import Reconstructor from .conversion import reconstruct_job from .job import ConversionJob @@ -43,11 +45,11 @@ def start(self) -> None: def _create_tasks(self) -> List[Any]: reconstructor = Reconstructor(self.config) self.jobs = self.plan.jobs(self.config) - return [(reconstructor, job) for job in self.jobs] + return [(reconstructor, job, silent_reporter) for job in self.jobs] def _get_task_function( self, - ) -> Callable[[Tuple[Reconstructor, ConversionJob]], Path]: + ) -> Callable[[Tuple[Reconstructor, ConversionJob, ReconstructionReporter]], Path]: return reconstruct_job def _process_results(self, results: List[Path]) -> Tuple[Path, ...]: diff --git a/src/sampletones_core/reconstructions/progress.py b/src/sampletones_core/reconstructions/progress.py new file mode 100644 index 000000000..6009e11ec --- /dev/null +++ b/src/sampletones_core/reconstructions/progress.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import Callable, Final + +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.utils.arrays import clamp + +STAGE_BEGUN: Final[int] = 0 +WHOLE_STAGE: Final[int] = 1 + +NOTHING_DONE: Final[float] = 0.0 +WHOLE_RUN: Final[float] = 1.0 + + +@dataclass(frozen=True) +class ReconstructionProgress: + """How far one stage of a reconstruction has come. + + Attributes: + stage: The work the run is in the middle of, which names the unit the counts are in. + completed: What the stage has reached so far. + total: What the stage counts up to; a stage announcing its arrival alone counts to one. + """ + + stage: ReconstructionStage + completed: int + total: int + + @property + def fraction(self) -> float: + """How much of the whole reconstruction stands finished, the stage weighed by its share. + + The stages a run passes through count in units of their own, so a reading that spans them + all is each stage's own progress taken through the share it holds of the run. The reading + stays within the run it describes, so whoever draws it is handed a fraction of one. + """ + if self.total <= 0: + return self.stage.offset + + reached = self.stage.offset + self.stage.share * (self.completed / self.total) + return clamp(reached, NOTHING_DONE, WHOLE_RUN) + + +ReconstructionReporter = Callable[[ReconstructionProgress], bool] + + +def announce( + report: ReconstructionReporter, + stage: ReconstructionStage, + completed: int, + total: int, +) -> None: + """Tells a reporter how far a stage has come, and unwinds the run it withdraws. + + Args: + report: Hears the stage and answers whether the run goes on. + stage: The work the run is in the middle of. + completed: What the stage has reached so far. + total: What the stage counts up to. + + Raises: + OperationCancelled: If the run is no longer wanted. + """ + if not report(ReconstructionProgress(stage=stage, completed=completed, total=total)): + raise OperationCancelled(f"the reconstruction was withdrawn while {stage}") diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 935775524..1b3a3d6e3 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -15,6 +15,12 @@ ) from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from sampletones_core.reconstructions.progress import ( + STAGE_BEGUN, + WHOLE_STAGE, + ReconstructionReporter, + announce, +) from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData @@ -24,8 +30,10 @@ from sampletones_core.reconstructions.reconstructor.stems.assignment.track import TrackAssignment from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker +from sampletones_core.reconstructions.stage import ReconstructionStage from sampletones_shared.exceptions import NoLibraryDataError from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.progress import silent_reporter from sampletones_shared.utils.system.paths import to_path @@ -90,6 +98,8 @@ def reconstruct( self, paths: Sequence[Pathlike], stems_config: StemsConfig, + *, + report: ReconstructionReporter = silent_reporter, ) -> Optional[Reconstruction]: """Reconstructs one or more stem audio files into one reconstruction. @@ -106,6 +116,7 @@ def reconstruct( stems_config: The stems setup built for this process from the inputs: the entries with their channels, the precedence hierarchy, and the per-stem channel cap. + report: Hears each stage of the run and answers whether it is still wanted. Returns: Optional[Reconstruction]: The reconstruction built from the stems. @@ -113,14 +124,17 @@ def reconstruct( Raises: ValueError: If the entries count differently than ``paths``. TypeError: If a path is not a string or ``Path``. + OperationCancelled: If the run is withdrawn while it is under way. """ checked_paths = self._check_stem_paths(paths, stems_config) + announce(report, ReconstructionStage.LOADING, STAGE_BEGUN, WHOLE_STAGE) recordings = self._load_stem_recordings(checked_paths) stem_frames, coefficient = self._prepare_stem_frames(recordings, stems_config) worker = self._build_worker(common_length(recordings)) - assignment = self._assign_stem_frames(stem_frames, stems_config, worker) + assignment = self._assign_stem_frames(stem_frames, stems_config, worker, report) self._drop_resting_channels(assignment) - self._record_streams(worker.decoder.decode(assignment.lattices)) + announce(report, ReconstructionStage.DECODING, STAGE_BEGUN, WHOLE_STAGE) + self._record_streams(worker.decoder.decode(assignment.lattices), report) return Reconstruction.from_state( self.state, self.config, @@ -206,6 +220,7 @@ def _assign_stem_frames( stem_frames: Dict[int, FragmentedAudio], stems_config: StemsConfig, worker: ReconstructorWorker, + report: ReconstructionReporter, ) -> TrackAssignment: """Assigns every frame's channels to the stems and gathers the outcome per channel. @@ -215,7 +230,9 @@ def _assign_stem_frames( stay parallel to the frames, and stem id ``i`` names frame ``i`` of its channel. """ assignment = TrackAssignment(self.state.channel_names) - for fragment_id in range(self._stem_frame_count(stem_frames)): + frames = self._stem_frame_count(stem_frames) + for fragment_id in range(frames): + announce(report, ReconstructionStage.MATCHING, fragment_id, frames) assignment.add( assign_frame( {stem_id: fragments[fragment_id] for stem_id, fragments in stem_frames.items()}, @@ -227,6 +244,7 @@ def _assign_stem_frames( ) ) + announce(report, ReconstructionStage.MATCHING, frames, frames) return assignment @staticmethod @@ -245,17 +263,21 @@ def _drop_resting_channels(self, assignment: TrackAssignment) -> None: self.state.drop(channel_name) assignment.drop(channel_name) - def _record_streams(self, streams: Streams) -> None: + def _record_streams(self, streams: Streams, report: ReconstructionReporter) -> None: """Folds the decoded streams into the state, one frame at a time. Frame order is what carries a generator's oscillator phase from one frame into the next, which is the continuity final regeneration renders against. """ - for position in range(self._frame_count(streams)): + frames = self._frame_count(streams) + for position in range(frames): + announce(report, ReconstructionStage.RENDERING, position, frames) for channel_name in self.state.channel_names: candidate = streams[channel_name][position] self._record(channel_name, candidate.instruction, candidate.approximation.audio) + announce(report, ReconstructionStage.RENDERING, frames, frames) + @staticmethod def _frame_count(streams: Streams) -> int: """The frames the streams span; every channel in play answers each of them.""" diff --git a/src/sampletones_core/reconstructions/stage.py b/src/sampletones_core/reconstructions/stage.py new file mode 100644 index 000000000..8e6725379 --- /dev/null +++ b/src/sampletones_core/reconstructions/stage.py @@ -0,0 +1,47 @@ +from enum import StrEnum +from typing import Final, Mapping + + +class ReconstructionStage(StrEnum): + """The work a reconstruction is in the middle of, as the progress it reports names it. + + A run reads its recordings onto one scale, matches every frame against the library, reads each + channel's frames into the stream it plays, and renders that stream back to the audio the + reconstruction carries. Each stage counts in its own unit, so what a report means is read from + the stage it names. + + Matching visits the library once per frame per stem and is what a run spends its time on, which + is what :data:`STAGE_SHARES` states: the bulk of the reading belongs to matching so a bar tracks + the time a run actually takes, while the stages around it keep enough of it to move visibly as + they pass. + """ + + LOADING = "loading" + MATCHING = "matching" + DECODING = "decoding" + RENDERING = "rendering" + + @property + def share(self) -> float: + """How much of a whole reconstruction this stage stands for.""" + return STAGE_SHARES[self] + + @property + def offset(self) -> float: + """How much of a reconstruction stands finished when this stage begins.""" + offset = 0.0 + for stage in ReconstructionStage: + if stage is self: + break + + offset += stage.share + + return offset + + +STAGE_SHARES: Final[Mapping[ReconstructionStage, float]] = { + ReconstructionStage.LOADING: 0.05, + ReconstructionStage.MATCHING: 0.80, + ReconstructionStage.DECODING: 0.05, + ReconstructionStage.RENDERING: 0.10, +} diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py index b3ab0de2d..22753ad88 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional, Tuple +from typing import Final, Optional, Tuple from tqdm import tqdm @@ -14,10 +14,13 @@ get_output_path, reconstruct_job, ) +from sampletones_core.reconstructions.progress import ReconstructionProgress from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.scripts.library import generate_library from sampletones_shared.logger import logger, null_logger +BAR_STEPS: Final[int] = 1000 + def reconstruct_file( input_path: Path, @@ -40,7 +43,18 @@ def reconstruct_file( stems=_classic_setup(config), output_path=output_path, ) - reconstruct_job((Reconstructor(config), job)) + progress_bar = tqdm(total=BAR_STEPS, desc=f"Reconstructing {input_path.name}", unit="step") + + def on_progress(progress: ReconstructionProgress) -> bool: + progress_bar.set_postfix_str(progress.stage) + progress_bar.update(round(progress.fraction * BAR_STEPS) - progress_bar.n) + return True + + try: + reconstruct_job((Reconstructor(config), job, on_progress)) + finally: + progress_bar.close() + logger.info(f"Reconstruction file saved to {output_path}") diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 5e099eac4..baf0d3685 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -5,7 +5,6 @@ from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.instructions import InstructionUnion from sampletones_core.performance import ( - SILENT_WALK_REPORTER, WalkReporter, song_instructions, ) @@ -16,11 +15,12 @@ from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter +from sampletones_player.compression.progress.report import CodecReporter from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers from sampletones_player.registers.streams import ChannelStreams from sampletones_player.song import Song +from sampletones_shared.utils.progress import silent_reporter SONG_START: Final[int] = 0 NO_SEEDS: Final[Tuple[Phrase, ...]] = () @@ -56,7 +56,7 @@ def streams_from_instructions( def song_from_reconstruction( reconstruction: Reconstruction, loop_tick: Optional[int], - report: CodecReporter = SILENT_REPORTER, + report: CodecReporter = silent_reporter, ) -> Song: """Builds the song the console plays a reconstruction as. @@ -144,7 +144,7 @@ def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optio def song_from_sample( request: SampleExport, - report: CodecReporter = SILENT_REPORTER, + report: CodecReporter = silent_reporter, ) -> Song: """Builds the song the console plays an export request as. @@ -184,8 +184,8 @@ def song_from_sample( def song_from_project( project: Project, loop_tick: Optional[int], - report: CodecReporter = SILENT_REPORTER, - walk: WalkReporter = SILENT_WALK_REPORTER, + report: CodecReporter = silent_reporter, + walk: WalkReporter = silent_reporter, ) -> Song: """Builds the song the console plays a whole project as. diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index 1078bebd3..c7e9b2f81 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -15,7 +15,6 @@ from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.progress.monitor import CodecMonitor from sampletones_player.compression.progress.report import ( - SILENT_REPORTER, CodecReporter, ) from sampletones_player.compression.search import search_phrases @@ -24,6 +23,7 @@ from sampletones_player.compression.tokens.phrase import PhraseToken from sampletones_player.compression.tokens.types import TokenUnion from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag +from sampletones_shared.utils.progress import silent_reporter STREAM_START: Final[int] = 0 SETTLING_ROUNDS: Final[int] = 3 @@ -119,7 +119,7 @@ def encode_planes( *, options: CodecOptions, boundaries: FrozenSet[int], - report: CodecReporter = SILENT_REPORTER, + report: CodecReporter = silent_reporter, ) -> CompressedPlanes: """Compresses a song's eight planes into the dictionary and streams the driver reads. diff --git a/src/sampletones_player/compression/progress/report.py b/src/sampletones_player/compression/progress/report.py index b71a2ef7a..092e7bf27 100644 --- a/src/sampletones_player/compression/progress/report.py +++ b/src/sampletones_player/compression/progress/report.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, Final +from typing import Callable @dataclass(frozen=True) @@ -17,11 +17,3 @@ class CodecProgress: CodecReporter = Callable[[CodecProgress], bool] - - -def _carry_on(progress: CodecProgress) -> bool: # pylint: disable=unused-argument - """Answers that the run goes on, which is what a caller watching nothing asks of it.""" - return True - - -SILENT_REPORTER: Final[CodecReporter] = _carry_on diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py index 673099605..c757b7e07 100644 --- a/src/sampletones_player/compression/song.py +++ b/src/sampletones_player/compression/song.py @@ -8,8 +8,9 @@ from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.rebuild import streams_from_planes from sampletones_player.compression.planes.separate import planes_from_streams -from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter +from sampletones_player.compression.progress.report import CodecReporter from sampletones_player.registers.streams import ChannelStreams +from sampletones_shared.utils.progress import silent_reporter def _entries(loop_tick: Optional[int]) -> FrozenSet[int]: @@ -25,7 +26,7 @@ def compress_song( *, seeds: Sequence[Phrase], loop_tick: Optional[int] = None, - report: CodecReporter = SILENT_REPORTER, + report: CodecReporter = silent_reporter, ) -> CompressedPlanes: """Compresses a song's four register streams into the dictionary and streams a file carries. diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py index b181137d7..e354e0c1c 100644 --- a/src/sampletones_player/export.py +++ b/src/sampletones_player/export.py @@ -4,7 +4,6 @@ from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.progress import ( - SILENT_REPORTER, ExportProgress, ExportReporter, announce, @@ -23,6 +22,7 @@ from sampletones_player.nsf.file import write_nsf from sampletones_player.nsf.information import NSFInformation from sampletones_shared.paths.extensions import EXT_FILE_NSF +from sampletones_shared.utils.progress import silent_reporter SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset( { @@ -132,7 +132,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Writes a program playing one channel slice. @@ -153,7 +153,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Writes a program playing every channel slice of one reconstruction together. @@ -188,7 +188,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: """Writes a program playing a whole composition. diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index eed2f374c..d2eb84e20 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -9,9 +9,10 @@ from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.progress.report import SILENT_REPORTER, CodecReporter +from sampletones_player.compression.progress.report import CodecReporter from sampletones_player.compression.song import compress_song, decompress_song from sampletones_player.registers.streams import ChannelStreams +from sampletones_shared.utils.progress import silent_reporter class Song(BaseModel): @@ -45,7 +46,7 @@ def from_streams( schedule: PlaySchedule, loop_tick: Optional[int], seeds: Sequence[Phrase], - report: CodecReporter = SILENT_REPORTER, + report: CodecReporter = silent_reporter, ) -> Song: """Compresses the register values a song plays into the song the console holds. diff --git a/src/sampletones_shared/utils/progress.py b/src/sampletones_shared/utils/progress.py new file mode 100644 index 000000000..3085775ba --- /dev/null +++ b/src/sampletones_shared/utils/progress.py @@ -0,0 +1,29 @@ +from typing import Final + +PROGRESS_STEPS: Final[int] = 200 + + +def silent_reporter(progress: object) -> bool: # pylint: disable=unused-argument + """Answers that the run goes on, which is what a caller watching nothing asks of it. + + A run reports through a reporter that answers whether its answer is still wanted, so a caller + with nothing to watch supplies this one and hears the run through to its end. The report is + taken as it comes, since a caller that reads none of it holds no opinion on its shape. + """ + return True + + +def report_interval(total: int) -> int: + """How far a count travels between two reports of a stage measured against ``total``. + + A stage may step through millions of samples or a handful of files, so reporting every step + fills a queue with updates no eye resolves and no bar redraws. Spacing the reports over a + fixed number of steps holds the rate steady whatever the stage counts in. + + Args: + total: What the stage's count is measured against. + + Returns: + int: The count a stage covers between reports, which is at least one. + """ + return max(1, total // PROGRESS_STEPS) diff --git a/tests/integration/reconstruction/test_conversion_jobs.py b/tests/integration/reconstruction/test_conversion_jobs.py index 2a5dcba79..feef7731c 100644 --- a/tests/integration/reconstruction/test_conversion_jobs.py +++ b/tests/integration/reconstruction/test_conversion_jobs.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction, Reconstructor @@ -8,13 +10,20 @@ GroupConversion, reconstruct_job, ) +from sampletones_core.reconstructions.progress import ( + ReconstructionProgress, +) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.utils.progress import silent_reporter from tests.integration.assets.reconstruction import ( build_mini_library, three_stem_config, three_stem_reconstruction_config, write_three_stem_recordings, ) +from tests.suite.progress import FIRST_REPORT, RecordingReporter, reported_stages def _writing_to(config: Config, directory: Path) -> Config: @@ -33,7 +42,7 @@ def test_three_stems_convert_into_one_reconstruction_file(self, tmp_path: Path) jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config) assert len(jobs) == 1 - written = reconstruct_job((reconstructor, jobs[0])) + written = reconstruct_job((reconstructor, jobs[0], silent_reporter)) assert written.exists() loaded = Reconstruction.load(written) @@ -48,7 +57,7 @@ def test_one_source_converts_the_classic_way(self, tmp_path: Path) -> None: stems = StemsConfig.single_entry(list(config.generation.channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) - written = reconstruct_job((reconstructor, jobs[0])) + written = reconstruct_job((reconstructor, jobs[0], silent_reporter)) loaded = Reconstruction.load(written) assert written.stem == source.stem @@ -71,7 +80,68 @@ def test_each_recording_is_written_on_its_own(self, tmp_path: Path) -> None: assert len(jobs) == len(sources) for job in jobs: - written = reconstruct_job((reconstructor, job)) + written = reconstruct_job((reconstructor, job, silent_reporter)) loaded = Reconstruction.load(written) assert loaded.audio_filepath == job.sources assert tuple(loaded.playing_channels) == (ChannelName.PULSE1,) + + +class TestAJobReportsItselfAsItRuns: + """A job says which stage it is in and how far that stage has come, over the real pipeline. + + A conversion is one job whatever the number of stems, so without this a whole reconstruction + stands at nothing until the file is written. The run reported here is the real one over a + small library, so what the stages count is what the reconstruction actually did. + """ + + def test_the_run_passes_through_its_stages_in_order(self, tmp_path: Path) -> None: + config = _writing_to(three_stem_reconstruction_config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + sources = write_three_stem_recordings(config, tmp_path) + jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config) + reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() + + reconstruct_job((reconstructor, jobs[0], reporter)) + + assert reported_stages(reporter.reports) == list(ReconstructionStage) + + def test_the_reading_climbs_from_nothing_to_the_whole_run(self, tmp_path: Path) -> None: + config = _writing_to(Config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(list(config.generation.channels)) + jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) + reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() + + reconstruct_job((reconstructor, jobs[0], reporter)) + readings = [report.fraction for report in reporter.reports] + + assert readings == sorted(readings) + assert readings[0] == 0.0 + assert readings[-1] == 1.0 + + def test_the_matching_stage_counts_the_frames_the_recording_holds(self, tmp_path: Path) -> None: + config = _writing_to(Config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(list(config.generation.channels)) + jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) + reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() + + reconstruct_job((reconstructor, jobs[0], reporter)) + matching = [report for report in reporter.reports if report.stage == ReconstructionStage.MATCHING] + + assert [report.completed for report in matching] == list(range(matching[0].total + 1)) + + def test_a_withdrawn_job_unwinds_and_writes_nothing(self, tmp_path: Path) -> None: + config = _writing_to(Config(), tmp_path / "out") + reconstructor = Reconstructor(config, library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(list(config.generation.channels)) + jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) + reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + + with pytest.raises(OperationCancelled): + reconstruct_job((reconstructor, jobs[0], reporter)) + + assert not jobs[0].output_path.exists() diff --git a/tests/suite/progress.py b/tests/suite/progress.py index ae903efce..e326d6bc6 100644 --- a/tests/suite/progress.py +++ b/tests/suite/progress.py @@ -1,9 +1,7 @@ -from typing import Final, Generic, List, Optional, Sequence, TypeVar - -from sampletones_core.exports.progress import ExportProgress -from sampletones_core.exports.stage import ExportStage +from typing import Final, Generic, List, Optional, Protocol, Sequence, TypeVar ProgressT = TypeVar("ProgressT") +StageT = TypeVar("StageT", covariant=True) NEVER_WITHDRAWN: Final[Optional[int]] = None FIRST_REPORT: Final[int] = 1 @@ -31,16 +29,23 @@ def last(self) -> ProgressT: return self.reports[-1] -def reported_stages(reports: Sequence[ExportProgress]) -> List[ExportStage]: +class StagedProgress(Protocol[StageT]): + """A report that names the work it comes from, whatever that work counts in.""" + + @property + def stage(self) -> StageT: ... + + +def reported_stages(reports: Sequence[StagedProgress[StageT]]) -> List[StageT]: """The stages a run reached, in order, a stretch spent in one counted once. Args: reports: What the run said about itself, in the order it said it. Returns: - List[ExportStage]: The stages, each entry a stage the run moved into. + List[StageT]: The stages, each entry a stage the run moved into. """ - reached: List[ExportStage] = [] + reached: List[StageT] = [] for report in reports: if not reached or reached[-1] != report.stage: reached.append(report.stage) diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 4fc6f63f3..7b0bf7cc2 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -20,7 +20,6 @@ from sampletones_core.exports.artifact import ExportArtifact from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.progress import ( - SILENT_REPORTER, ExportReporter, announce, ) @@ -33,6 +32,7 @@ from sampletones_core.exports.stage import ExportStage from sampletones_core.project.project import Project from sampletones_shared.music import Tuning +from sampletones_shared.utils.progress import silent_reporter NES_FREQUENCY: Final[int] = 60 NOTHING_WRITTEN: Final[int] = 0 @@ -76,7 +76,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: return self._write("instrument", destination, request, report) @@ -84,7 +84,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: return self._write("sample", destination, request, report) @@ -92,7 +92,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: return self._write("project", destination, request, report) @@ -553,7 +553,7 @@ def write_instrument( self, destination: Path, request: InstrumentExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: announce(report, ExportStage.WALKING, NOTHING_WRITTEN, None) self.stages.append(ExportStage.WALKING) @@ -566,7 +566,7 @@ def write_sample( self, destination: Path, request: SampleExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: raise NotImplementedError @@ -574,7 +574,7 @@ def write_project( self, destination: Path, request: ProjectExport, - report: ExportReporter = SILENT_REPORTER, + report: ExportReporter = silent_reporter, ) -> ExportArtifact: raise NotImplementedError diff --git a/tests/unit/sampletones_application/services/test_progress.py b/tests/unit/sampletones_application/services/test_progress.py index b8935e976..6fe70d597 100644 --- a/tests/unit/sampletones_application/services/test_progress.py +++ b/tests/unit/sampletones_application/services/test_progress.py @@ -1,13 +1,10 @@ from typing import Final, List -from sampletones_application.services.progress import ( - PROGRESS_STEPS, - UNMEASURED, - StageProgress, -) +from sampletones_application.services.progress import UNMEASURED, StageProgress from sampletones_application.services.render.result import RenderStage from sampletones_application.services.result import ServiceProgress from sampletones_core.exports.stage import ExportStage +from sampletones_shared.utils.progress import PROGRESS_STEPS TOTAL_SAMPLES: Final[int] = PROGRESS_STEPS * 100 STEP: Final[int] = TOTAL_SAMPLES // PROGRESS_STEPS diff --git a/tests/unit/sampletones_core/exports/test_progress.py b/tests/unit/sampletones_core/exports/test_progress.py index a39838f64..d51d9d2a5 100644 --- a/tests/unit/sampletones_core/exports/test_progress.py +++ b/tests/unit/sampletones_core/exports/test_progress.py @@ -3,12 +3,12 @@ import pytest from sampletones_core.exports.progress import ( - SILENT_REPORTER, ExportProgress, announce, ) from sampletones_core.exports.stage import ExportStage from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.utils.progress import silent_reporter from tests.suite.progress import FIRST_REPORT, RecordingReporter WRITTEN: Final[int] = 3 @@ -30,7 +30,7 @@ def test_a_stage_only_the_data_ends_states_no_length(self) -> None: assert reporter.last.total is None def test_a_caller_watching_nothing_lets_every_stage_through(self) -> None: - announce(SILENT_REPORTER, ExportStage.WALKING, WRITTEN, TO_WRITE) + announce(silent_reporter, ExportStage.WALKING, WRITTEN, TO_WRITE) class TestWithdrawingARun: diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py index 10fb0a867..cd1045770 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py @@ -9,6 +9,7 @@ from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import UnsupportedAudioFormatError +from sampletones_shared.utils.progress import silent_reporter @pytest.fixture @@ -31,7 +32,7 @@ def test_creates_parent_directory_when_not_exist( tmp_path: Path, ) -> None: output_path = tmp_path / "nested" / "dir" / "song.stn" - reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) + reconstruct_job((mock_reconstructor, _job(tmp_path, output_path), silent_reporter)) assert output_path.parent.exists() def test_builds_the_reconstruction_from_the_jobs_sources_and_setup( @@ -40,8 +41,12 @@ def test_builds_the_reconstruction_from_the_jobs_sources_and_setup( tmp_path: Path, ) -> None: job = _job(tmp_path, tmp_path / "song.stn") - reconstruct_job((mock_reconstructor, job)) - mock_reconstructor.reconstruct.assert_called_once_with(job.sources, job.stems) + reconstruct_job((mock_reconstructor, job, silent_reporter)) + mock_reconstructor.reconstruct.assert_called_once_with( + job.sources, + job.stems, + report=silent_reporter, + ) def test_saves_reconstruction_to_output_path( self, @@ -51,7 +56,7 @@ def test_saves_reconstruction_to_output_path( mock_reconstruction = MagicMock() mock_reconstructor.reconstruct.return_value = mock_reconstruction output_path = tmp_path / "song.stn" - reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) + reconstruct_job((mock_reconstructor, _job(tmp_path, output_path), silent_reporter)) mock_reconstruction.save.assert_called_once_with(output_path) def test_reports_the_output_path_when_the_reconstruction_is_empty( @@ -61,7 +66,8 @@ def test_reports_the_output_path_when_the_reconstruction_is_empty( ) -> None: mock_reconstructor.reconstruct.return_value = None output_path = tmp_path / "song.stn" - assert reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) == output_path + job = _job(tmp_path, output_path) + assert reconstruct_job((mock_reconstructor, job, silent_reporter)) == output_path def test_always_returns_output_path( self, @@ -69,7 +75,8 @@ def test_always_returns_output_path( tmp_path: Path, ) -> None: output_path = tmp_path / "song.stn" - assert reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) == output_path + job = _job(tmp_path, output_path) + assert reconstruct_job((mock_reconstructor, job, silent_reporter)) == output_path def test_unsupported_audio_format_error_is_swallowed( self, @@ -78,7 +85,8 @@ def test_unsupported_audio_format_error_is_swallowed( ) -> None: mock_reconstructor.reconstruct.side_effect = UnsupportedAudioFormatError("bad format") output_path = tmp_path / "song.stn" - assert reconstruct_job((mock_reconstructor, _job(tmp_path, output_path))) == output_path + job = _job(tmp_path, output_path) + assert reconstruct_job((mock_reconstructor, job, silent_reporter)) == output_path def test_keyboard_interrupt_is_reraised( self, @@ -87,4 +95,4 @@ def test_keyboard_interrupt_is_reraised( ) -> None: mock_reconstructor.reconstruct.side_effect = KeyboardInterrupt with pytest.raises(KeyboardInterrupt): - reconstruct_job((mock_reconstructor, _job(tmp_path, tmp_path / "song.stn"))) + reconstruct_job((mock_reconstructor, _job(tmp_path, tmp_path / "song.stn"), silent_reporter)) diff --git a/tests/unit/sampletones_core/reconstructions/test_progress.py b/tests/unit/sampletones_core/reconstructions/test_progress.py new file mode 100644 index 000000000..514e14c16 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/test_progress.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import pytest + +from sampletones_core.reconstructions.progress import ( + ReconstructionProgress, + announce, +) +from sampletones_core.reconstructions.stage import STAGE_SHARES, ReconstructionStage +from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.utils.progress import silent_reporter +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.progress import FIRST_REPORT, RecordingReporter + +WHOLE: float = 1.0 +FRAMES: int = 8 + + +class TestStageShares(BaseTestSuite): + """Every stage a reconstruction passes through holds a share, and the shares are the whole run. + + A bar crossing the stages reads each one through its share, so a stage the shares forgot would + leave the bar standing still while that stage ran, and shares summing to anything other than + the whole would leave it short of its end or past it. + """ + + def test_every_stage_holds_a_share(self) -> None: + assert set(STAGE_SHARES) == set(ReconstructionStage) + + def test_the_shares_are_the_whole_run(self) -> None: + assert sum(STAGE_SHARES.values()) == pytest.approx(WHOLE) + + def test_a_stage_begins_where_the_stages_before_it_end(self) -> None: + stages = list(ReconstructionStage) + offsets = [stage.offset for stage in stages] + + assert offsets[0] == pytest.approx(0.0) + for stage, offset, following in zip(stages, offsets, offsets[1:]): + assert following == pytest.approx(offset + stage.share) + + def test_the_last_stage_ends_on_the_whole_run(self) -> None: + last = list(ReconstructionStage)[-1] + assert last.offset + last.share == pytest.approx(WHOLE) + + +class TestReconstructionFraction(BaseTestSuite): + """A stage's own count reads as a fraction of the whole reconstruction. + + The stages count in units of their own — frames here, an arrival there — so what a bar shows + is each stage's progress taken through the share it holds, which is what lets one reading span + a run that changes what it is counting three times over. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: float + stage: ReconstructionStage + completed: int + total: int + + @property + def label(self) -> str: + return f"{self.stage}_{self.completed}_of_{self.total}" + + test_cases = ( + TestCase(stage=ReconstructionStage.LOADING, completed=0, total=1, expected=0.0), + TestCase(stage=ReconstructionStage.MATCHING, completed=0, total=FRAMES, expected=0.05), + TestCase(stage=ReconstructionStage.MATCHING, completed=FRAMES, total=FRAMES, expected=0.85), + TestCase(stage=ReconstructionStage.RENDERING, completed=FRAMES, total=FRAMES, expected=WHOLE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_fraction_weighs_the_stage_by_its_share(self, test_case: TestCase) -> None: + progress = ReconstructionProgress( + stage=test_case.stage, + completed=test_case.completed, + total=test_case.total, + ) + + assert progress.fraction == pytest.approx(test_case.expected) + + def test_a_stage_measured_against_nothing_reads_as_its_own_beginning(self) -> None: + progress = ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=0, total=0) + + assert progress.fraction == pytest.approx(ReconstructionStage.DECODING.offset) + + def test_a_run_read_in_stage_order_never_turns_back(self) -> None: + readings: List[float] = [] + for stage in ReconstructionStage: + for completed in range(FRAMES + 1): + readings.append(ReconstructionProgress(stage=stage, completed=completed, total=FRAMES).fraction) + + assert readings == sorted(readings) + assert readings[0] == pytest.approx(0.0) + assert readings[-1] == pytest.approx(WHOLE) + + +class TestAnnounce(BaseTestSuite): + """A run tells its reporter how far it has come, and unwinds where the reporter withdraws it.""" + + def test_a_report_carries_the_stage_and_its_counts(self) -> None: + reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() + + announce(reporter, ReconstructionStage.MATCHING, 3, FRAMES) + + assert reporter.last == ReconstructionProgress( + stage=ReconstructionStage.MATCHING, + completed=3, + total=FRAMES, + ) + + def test_a_withdrawn_run_unwinds_where_it_stood(self) -> None: + reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) + + with pytest.raises(OperationCancelled): + announce(reporter, ReconstructionStage.MATCHING, 0, FRAMES) + + def test_a_caller_watching_nothing_hears_the_run_through(self) -> None: + readings: Tuple[int, ...] = (0, FRAMES // 2, FRAMES) + + for completed in readings: + announce(silent_reporter, ReconstructionStage.RENDERING, completed, FRAMES) diff --git a/tests/unit/sampletones_player/compression/progress/test_monitor.py b/tests/unit/sampletones_player/compression/progress/test_monitor.py index 10ab84170..28b0c98d7 100644 --- a/tests/unit/sampletones_player/compression/progress/test_monitor.py +++ b/tests/unit/sampletones_player/compression/progress/test_monitor.py @@ -4,10 +4,10 @@ from sampletones_player.compression.progress.monitor import CodecMonitor from sampletones_player.compression.progress.report import ( - SILENT_REPORTER, CodecProgress, ) from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.utils.progress import silent_reporter from tests.suite.progress import FIRST_REPORT, RecordingReporter PHRASES_FOUND: Final[int] = 4 @@ -37,7 +37,7 @@ def test_a_stretch_between_readings_repeats_the_last_one(self) -> None: assert reporter.last == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN) def test_what_the_run_last_reached_is_its_own_to_read(self) -> None: - monitor = CodecMonitor(SILENT_REPORTER) + monitor = CodecMonitor(silent_reporter) monitor.reached(PHRASES_FOUND, BYTES_LAID_DOWN) assert monitor.progress == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN) diff --git a/tests/unit/sampletones_player/compression/test_admit.py b/tests/unit/sampletones_player/compression/test_admit.py index d54b4b2c3..d3bd68e8b 100644 --- a/tests/unit/sampletones_player/compression/test_admit.py +++ b/tests/unit/sampletones_player/compression/test_admit.py @@ -13,8 +13,8 @@ from sampletones_player.compression.parse.result import Parse from sampletones_player.compression.parse.song import parse_planes from sampletones_player.compression.progress.monitor import CodecMonitor -from sampletones_player.compression.progress.report import SILENT_REPORTER from sampletones_player.specification.compression import BYTE_VALUES, MAX_PHRASE_IDS +from sampletones_shared.utils.progress import silent_reporter STREAM_START: Final[frozenset] = frozenset({0}) LEANED_ON: Final[bytes] = b"\x10\x18\x14\x22\x1c\x30\x11\x19\x15\x23\x1d\x31\x12\x1a\x16\x24" @@ -62,7 +62,7 @@ def baseline_fixture(cache: MatchCache) -> Tuple[Parse, ...]: phrase_table(()), replace(EVERY_LAYER, phrases=False), STREAM_START, - CodecMonitor(SILENT_REPORTER), + CodecMonitor(silent_reporter), ) From 95ad930e3608c4d7a5ecdf4486ace620544371da Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 00:29:44 +0200 Subject: [PATCH 097/142] Added: a line a task reports its own progress back to the run on --- pyproject.toml | 1 + .../logic/instruction/library.py | 2 +- .../parallelization/channel/__init__.py | 11 ++ .../parallelization/channel/process.py | 77 +++++++++ .../parallelization/channel/protocol.py | 37 ++++ .../parallelization/channel/pump.py | 75 ++++++++ .../parallelization/processor.py | 72 ++++++++ src/sampletones_core/parallelization/steps.py | 48 ++++++ src/sampletones_core/parallelization/task.py | 46 ++++- .../integration/sampletones_core/__init__.py | 0 .../parallelization/__init__.py | 0 .../parallelization/test_progress_channel.py | 140 +++++++++++++++ tests/suite/parallelization.py | 163 ++++++++++++++++++ .../parallelization/__init__.py | 0 .../parallelization/test_steps.py | 120 +++++++++++++ 15 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 src/sampletones_core/parallelization/channel/__init__.py create mode 100644 src/sampletones_core/parallelization/channel/process.py create mode 100644 src/sampletones_core/parallelization/channel/protocol.py create mode 100644 src/sampletones_core/parallelization/channel/pump.py create mode 100644 src/sampletones_core/parallelization/steps.py create mode 100644 tests/integration/sampletones_core/__init__.py create mode 100644 tests/integration/sampletones_core/parallelization/__init__.py create mode 100644 tests/integration/sampletones_core/parallelization/test_progress_channel.py create mode 100644 tests/suite/parallelization.py create mode 100644 tests/unit/sampletones_core/parallelization/__init__.py create mode 100644 tests/unit/sampletones_core/parallelization/test_steps.py diff --git a/pyproject.toml b/pyproject.toml index 473b5cbc2..562a6b6b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ known_first_party = [ [tool.pytest.ini_options] addopts = "--import-mode=importlib" +pythonpath = ["."] [tool.coverage.run] source = [ diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 271f878cb..3ec235d67 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -395,7 +395,7 @@ def _update_progress_state(self, task_progress: TaskProgress) -> None: eta_string=eta_string ) - self._emit_view(status_text, progress=task_progress.get_progress()) + self._emit_view(status_text, progress=task_progress.fraction) def _on_generation_completed(self) -> None: self.call(self.on_generation_completed) diff --git a/src/sampletones_core/parallelization/channel/__init__.py b/src/sampletones_core/parallelization/channel/__init__.py new file mode 100644 index 000000000..095a5f639 --- /dev/null +++ b/src/sampletones_core/parallelization/channel/__init__.py @@ -0,0 +1,11 @@ +from .process import ProcessProgressChannel, QueueStepReporter +from .protocol import ProgressChannel, StepReporter +from .pump import ProgressPump + +__all__ = [ + "ProcessProgressChannel", + "ProgressChannel", + "ProgressPump", + "QueueStepReporter", + "StepReporter", +] diff --git a/src/sampletones_core/parallelization/channel/process.py b/src/sampletones_core/parallelization/channel/process.py new file mode 100644 index 000000000..340745d83 --- /dev/null +++ b/src/sampletones_core/parallelization/channel/process.py @@ -0,0 +1,77 @@ +import multiprocessing +import queue +import threading +from multiprocessing.managers import SyncManager +from typing import Final, Optional + +from sampletones_core.parallelization.channel.protocol import StepReporter +from sampletones_core.parallelization.task import TaskReport, TaskStep + +SPAWN_CONTEXT: Final[str] = "spawn" + + +class QueueStepReporter: + """The line one task reports on, made of a queue up and a flag down. + + Both ends are manager proxies, so the pair travels to a worker with the task it belongs to and + reconnects there over the manager's own socket. That is what lets a worker started as a fresh + interpreter — which is how every platform this runs on starts one — reach the run that sent it. + """ + + def __init__( + self, + reports: "queue.Queue[TaskReport]", + withdrawn: threading.Event, + index: int, + ) -> None: + self._reports = reports + self._withdrawn = withdrawn + self._index = index + + def __call__(self, step: TaskStep) -> bool: + """Files the step under the task this line belongs to, and answers whether the run goes on. + + Args: + step: Where the task now stands. + + Returns: + bool: Whether the run still wants the answer this task is building. + """ + self._reports.put(TaskReport(index=self._index, step=step)) + return not self._withdrawn.is_set() + + +class ProcessProgressChannel: + """The line a run holds with tasks running in worker processes. + + A manager stands beside the pool and owns both ends, so each end reaches a task as an ordinary + value the task is built with. The manager runs under the same spawn context the pool's workers + do, which keeps one way of starting a process across the whole run. + + The channel is opened by the run that hands the lines out and closed by it once every task has + been heard from, since the manager is a process of its own to be reaped. + """ + + def __init__(self) -> None: + self._manager: SyncManager = multiprocessing.get_context(SPAWN_CONTEXT).Manager() + self._reports: "queue.Queue[TaskReport]" = self._manager.Queue() + self._withdrawn: threading.Event = self._manager.Event() + + def reporter(self, index: int) -> StepReporter: + """The line the task at ``index`` reports on, which travels to its worker with it.""" + return QueueStepReporter(self._reports, self._withdrawn, index) + + def poll(self, timeout: float) -> Optional[TaskReport]: + """The next report a task filed, waiting up to ``timeout`` seconds for one to arrive.""" + try: + return self._reports.get(timeout=timeout) + except queue.Empty: + return None + + def withdraw(self) -> None: + """Tells every task the run has let go of the answer it was building.""" + self._withdrawn.set() + + def close(self) -> None: + """Ends the channel and reaps the manager process that held its ends.""" + self._manager.shutdown() diff --git a/src/sampletones_core/parallelization/channel/protocol.py b/src/sampletones_core/parallelization/channel/protocol.py new file mode 100644 index 000000000..5376ce09b --- /dev/null +++ b/src/sampletones_core/parallelization/channel/protocol.py @@ -0,0 +1,37 @@ +from typing import Optional, Protocol + +from sampletones_core.parallelization.task import TaskReport, TaskStep + + +class StepReporter(Protocol): + """A task's line back to the run that started it. + + A task says where it stands and hears whether the run still wants its answer, which is the + contract a run inside one process already reports itself by; what a line adds is the distance + the report travels. One line serves one task, so a report names the task it came from without + that task holding an opinion on its own place in the run. + """ + + def __call__(self, step: TaskStep) -> bool: + """Carries the step, and answers whether the run goes on.""" + + +class ProgressChannel(Protocol): + """The line a run and the tasks it handed out hold each other on. + + Progress travels one way and a withdrawal the other, so one channel carries both directions of + the conversation. What a channel is made of follows from where its tasks run, which is why a + run reaches its tasks through this contract rather than through any one way of crossing to them. + """ + + def reporter(self, index: int) -> StepReporter: + """The line the task at ``index`` reports on, which travels to wherever that task runs.""" + + def poll(self, timeout: float) -> Optional[TaskReport]: + """The next report a task filed, waiting up to ``timeout`` seconds for one to arrive.""" + + def withdraw(self) -> None: + """Tells every task the run has let go of the answer it was building.""" + + def close(self) -> None: + """Ends the channel and releases whatever it held open for the tasks to reach.""" diff --git a/src/sampletones_core/parallelization/channel/pump.py b/src/sampletones_core/parallelization/channel/pump.py new file mode 100644 index 000000000..ae8ea75b9 --- /dev/null +++ b/src/sampletones_core/parallelization/channel/pump.py @@ -0,0 +1,75 @@ +import threading +from typing import Callable, Final + +from sampletones_core.parallelization.channel.protocol import ProgressChannel +from sampletones_core.parallelization.task import TaskReport +from sampletones_shared.logger import LoggerProtocol +from sampletones_shared.types.callback import VoidCallback + +POLL_SECONDS: Final[float] = 0.05 +DRAIN_SECONDS: Final[float] = 0.0 +JOIN_SECONDS: Final[float] = 1.0 +PUMP_THREAD_NAME: Final[str] = "TaskProcessorProgress" + + +class ProgressPump: + """Carries what the tasks report to whoever is watching the run. + + A run's monitor thread waits on the results the pool hands back, so a report arriving between + two of them is heard on a thread of its own. Everything already waiting is taken in one turn + and announced once, which holds the announcements to the rate this reads at however many steps + the tasks file in between — the reading a bar wants, at a rate a bar can be redrawn at. + """ + + def __init__( + self, + channel: ProgressChannel, + *, + record: Callable[[TaskReport], None], + announce: VoidCallback, + logger: LoggerProtocol, + ) -> None: + """Holds the one thread that reads a channel. + + Args: + channel: The line the tasks report on. + record: Takes each report as where the task that filed it now stands. + announce: Tells whoever is watching that the run has moved. + logger: Hears a channel that ends before the pump is asked to stop. + """ + self._channel = channel + self._record = record + self._announce = announce + self._logger = logger + self._stopped = threading.Event() + self._thread = threading.Thread(target=self._read, daemon=True, name=PUMP_THREAD_NAME) + + def start(self) -> None: + """Begins reading the channel.""" + self._thread.start() + + def stop(self) -> None: + """Asks the reading to end, and waits for the thread to leave the channel alone.""" + self._stopped.set() + if self._thread.is_alive(): + self._thread.join(timeout=JOIN_SECONDS) + + def _read(self) -> None: + while not self._stopped.is_set(): + try: + report = self._channel.poll(POLL_SECONDS) + if report is None: + continue + + self._record(report) + self._drain() + except (EOFError, OSError) as exception: + self._logger.warning(f"Progress channel ended while the run was still reading: {exception}") + return + + self._announce() + + def _drain(self) -> None: + """Takes every report already waiting, so one turn of the loop announces them together.""" + while (report := self._channel.poll(DRAIN_SECONDS)) is not None: + self._record(report) diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index a377bf3ce..56483ea9a 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -7,10 +7,15 @@ from pebble import ProcessMapFuture, ProcessPool from sampletones_core.constants.algorithm import MAX_WORKERS +from sampletones_core.parallelization.channel.process import ProcessProgressChannel +from sampletones_core.parallelization.channel.protocol import ProgressChannel, StepReporter +from sampletones_core.parallelization.channel.pump import ProgressPump +from sampletones_core.parallelization.steps import TaskSteps from sampletones_core.parallelization.task import ( TaskProgress, TaskStatus, ) +from sampletones_shared.exceptions import OperationCancelled from sampletones_shared.logger import LoggerProtocol from sampletones_shared.logger import logger as default_logger from sampletones_shared.types.callback import Callback, VoidCallback @@ -45,6 +50,11 @@ def __init__( self._pool_lock: threading.Lock = threading.Lock() self._exception: Optional[Exception] = None + self._steps: TaskSteps = TaskSteps() + self._channel_lock: threading.Lock = threading.Lock() + self._channel: Optional[ProgressChannel] = None + self._pump: Optional[ProgressPump] = None + self.on_start: Optional[VoidCallback] = None self.on_progress: Optional[Callable[[TaskStatus, TaskProgress], None]] = None self.on_completed: Optional[Callable[[T], None]] = None @@ -72,6 +82,7 @@ def cleanup(self) -> None: self.running = False self.cancelling = True + self._withdraw() self._notify_progress() self._cleanup() @@ -79,6 +90,7 @@ def cancel(self) -> None: self.status = TaskStatus.CANCELLING self.cancelling = True + self._withdraw() self._notify_progress() self._cleanup() @@ -93,6 +105,7 @@ def shutdown(self) -> None: self.running = False self.cancelling = True + self._withdraw() self._notify_progress() if self.future is not None: self.future.cancel() @@ -128,6 +141,52 @@ def _get_task_function(self) -> Callback: ... @abstractmethod def _process_results(self, results: List[T]) -> Any: ... + def _task_reporter(self, index: int) -> StepReporter: + """The line the task at ``index`` reports its own progress on. + + A subclass whose tasks find their way through work the run cannot see asks for one per task + as it builds them. The channel those lines run through is opened on the first ask, so a run + whose tasks report nothing costs nothing to listen to. + """ + with self._channel_lock: + if self._channel is None: + self._channel = ProcessProgressChannel() + + return self._channel.reporter(index) + + def _withdraw(self) -> None: + """Tells the running tasks the run has let go of the answer they were building.""" + with self._channel_lock: + if self._channel is not None: + self._channel.withdraw() + + def _start_pump(self) -> None: + with self._channel_lock: + if self._channel is None: + return + + self._pump = ProgressPump( + self._channel, + record=self._steps.record, + announce=self._notify_progress, + logger=self.logger, + ) + + self._pump.start() + + def _release_channel(self) -> None: + """Ends the reading and the channel, which the run does once its tasks are all heard from.""" + if self._pump is not None: + self._pump.stop() + self._pump = None + + with self._channel_lock: + if self._channel is not None: + self._channel.close() + self._channel = None + + self._steps.clear() + def _reset_status(self) -> None: self.status = TaskStatus.PENDING self.running = False @@ -137,6 +196,13 @@ def _reset_status(self) -> None: self.current_item = None def _run_tasks(self) -> None: + """Runs the tasks and holds the progress channel for exactly as long as they do.""" + try: + self._process_tasks() + finally: + self._release_channel() + + def _process_tasks(self) -> None: self._reset_status() try: @@ -156,6 +222,7 @@ def _run_tasks(self) -> None: self.pool = ProcessPool(max_workers=workers, context=context) task_function = self._get_task_function() self.future = self.pool.map(task_function, tasks, timeout=None) + self._start_pump() self.call(self.on_start) results = [] @@ -171,12 +238,16 @@ def _run_tasks(self) -> None: result = next(iterator) results.append(result) + self._steps.complete(self.completed_tasks) self.completed_tasks += 1 self._notify_progress() except StopIteration: pass except KeyboardInterrupt as exception: raise CancelledError() from exception + except OperationCancelled: + self._finalize_cancellation() + return except CancelledError: self._finalize_cancellation() return @@ -197,6 +268,7 @@ def _notify_progress(self) -> None: total=self.total_tasks, completed=self.completed_tasks, current_item=self.current_item, + steps=self._steps.snapshot(), ) self.call(self.on_progress, self.status, progress) diff --git a/src/sampletones_core/parallelization/steps.py b/src/sampletones_core/parallelization/steps.py new file mode 100644 index 000000000..0b329e228 --- /dev/null +++ b/src/sampletones_core/parallelization/steps.py @@ -0,0 +1,48 @@ +import threading +from typing import Dict, Set, Tuple + +from sampletones_core.parallelization.task import TaskReport, TaskStep + + +class TaskSteps: + """Where each running task of a run stands, as the tasks themselves last said. + + A task reports from wherever it runs while the run's monitor waits on the results the pool + hands back, so the two arrive on different threads and meet here. An entry lives from a task's + first report until the run counts that task as finished, which is what keeps a task's own + progress and the run's completed count from describing the same work twice. + + A finished task stays finished. Its reports travel a line the run reads at its own pace, so one + filed before the result arrived may be read after it; the run has already counted that task + whole, and what it said on the way there is history by then. + """ + + def __init__(self) -> None: + self._steps: Dict[int, TaskStep] = {} + self._finished: Set[int] = set() + self._lock = threading.Lock() + + def record(self, report: TaskReport) -> None: + """Takes the step a task filed as where that task now stands, while it is still running.""" + with self._lock: + if report.index in self._finished: + return + + self._steps[report.index] = report.step + + def complete(self, index: int) -> None: + """Counts the task at ``index`` among the finished, whose work the run now carries itself.""" + with self._lock: + self._finished.add(index) + self._steps.pop(index, None) + + def clear(self) -> None: + """Lets go of every task, which is where a run about to start over stands.""" + with self._lock: + self._steps.clear() + self._finished.clear() + + def snapshot(self) -> Tuple[TaskStep, ...]: + """Where the running tasks stand, in the order the run handed them out.""" + with self._lock: + return tuple(self._steps[index] for index in sorted(self._steps)) diff --git a/src/sampletones_core/parallelization/task.py b/src/sampletones_core/parallelization/task.py index 6c7226610..7ce053605 100644 --- a/src/sampletones_core/parallelization/task.py +++ b/src/sampletones_core/parallelization/task.py @@ -1,9 +1,9 @@ from enum import Enum -from typing import Optional, TypeVar +from typing import Final, Optional, Tuple from pydantic import BaseModel, ConfigDict -T = TypeVar("T") +NOTHING_TO_DO: Final[int] = 0 class TaskStatus(Enum): @@ -17,15 +17,51 @@ class TaskStatus(Enum): CLEANING_UP = "CLEANING_UP" +class TaskStep(BaseModel): + """How far one task of a run has come through work only it can see. + + A task that is the whole of what a run does leaves the run's own counts at nothing until it + finishes, so a task that knows its way through its work says so here. The stage names the unit + the counts are in, and the fraction is that stage read against the whole task, since the stages + a task passes through count in units of their own. + """ + + model_config = ConfigDict(frozen=True) + + stage: str + completed: int + total: int + fraction: float + + +class TaskReport(BaseModel): + """One task's step, and which task of the run it came from.""" + + model_config = ConfigDict(frozen=True) + + index: int + step: TaskStep + + class TaskProgress(BaseModel): + """How far a run has come: the tasks it finished, and where the ones still running stand.""" + model_config = ConfigDict(frozen=True) total: int completed: int current_item: Optional[str] = None + steps: Tuple[TaskStep, ...] = () + + @property + def partial(self) -> float: + """The work under way beyond the finished tasks, counted in tasks.""" + return sum(step.fraction for step in self.steps) - def get_progress(self) -> float: - if self.total == 0: + @property + def fraction(self) -> float: + """How full the run stands, each running task counted for the part of it that is done.""" + if self.total == NOTHING_TO_DO: return 0.0 - return self.completed / self.total + return (self.completed + self.partial) / self.total diff --git a/tests/integration/sampletones_core/__init__.py b/tests/integration/sampletones_core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_core/parallelization/__init__.py b/tests/integration/sampletones_core/parallelization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_core/parallelization/test_progress_channel.py b/tests/integration/sampletones_core/parallelization/test_progress_channel.py new file mode 100644 index 000000000..df19dfc32 --- /dev/null +++ b/tests/integration/sampletones_core/parallelization/test_progress_channel.py @@ -0,0 +1,140 @@ +from contextlib import contextmanager +from pathlib import Path +from typing import Final, Iterator, Tuple + +import pytest + +from sampletones_core.parallelization.task import TaskStatus +from tests.suite.parallelization import ( + COUNTING_STAGE, + STEP_COUNT, + CountingProcessor, + ProgressRecorder, + several_tasks_reporting, + stands_partway, +) + +READING_TIMEOUT: Final[float] = 60.0 +POOL_TIMEOUT: Final[float] = 120.0 +ONE_TASK: Final[int] = 1 +SEVERAL_TASKS: Final[int] = 3 +LONE_WORKER: Final[int] = 1 +TWO_WORKERS: Final[int] = 2 +FIRST_COUNT: Final[int] = 1 +WHOLE_RUN: Final[float] = 1.0 +RELEASE_NAME: Final[str] = "release" + + +@pytest.fixture +def release_path(tmp_path: Path) -> Path: + """Where a test puts the file its tasks wait on before they finish counting.""" + return tmp_path / RELEASE_NAME + + +@contextmanager +def counting_run( + task_count: int, + release_path: Path, + workers: int, +) -> Iterator[Tuple[CountingProcessor, ProgressRecorder]]: + """Starts a counting run, hands the test its recorder, and reaps the pool afterwards. + + The release file is written on the way out whatever the test did, so a run whose assertion + failed before releasing its tasks still ends rather than holding a worker at its halfway mark. + """ + recorder = ProgressRecorder() + processor = CountingProcessor(task_count, release_path, max_workers=workers) + processor.set_callbacks(on_progress=recorder) + processor.start() + try: + yield processor, recorder + finally: + release_path.touch(exist_ok=True) + processor.shutdown() + + +class TestOneTaskReportsItselfBeforeItFinishes: + """A run made of a single task says how far that task has come while it is still running. + + Every conversion is one job whatever the number of stems, so a run reporting completions alone + stands at nothing for its whole length and then jumps to full. The task here is held at its + halfway mark until the reading below has already been taken, so what each assertion rests on is + a report that crossed from a live worker process rather than from a finished one. + """ + + def test_a_reading_arrives_from_a_task_still_running(self, release_path: Path) -> None: + with counting_run(ONE_TASK, release_path, LONE_WORKER) as (processor, recorder): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + + release_path.touch() + processor.wait(POOL_TIMEOUT) + + def test_the_steps_carry_what_the_task_said_of_itself(self, release_path: Path) -> None: + with counting_run(ONE_TASK, release_path, LONE_WORKER) as (processor, recorder): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + release_path.touch() + processor.wait(POOL_TIMEOUT) + + steps = recorder.steps + assert steps + assert {step.stage for step in steps} == {COUNTING_STAGE} + assert {step.total for step in steps} == {STEP_COUNT} + assert all(FIRST_COUNT <= step.completed <= STEP_COUNT for step in steps) + assert all(step.fraction == step.completed / STEP_COUNT for step in steps) + + def test_the_run_climbs_and_arrives_at_its_end(self, release_path: Path) -> None: + with counting_run(ONE_TASK, release_path, LONE_WORKER) as (processor, recorder): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + release_path.touch() + processor.wait(POOL_TIMEOUT) + + fractions = recorder.fractions + assert fractions == sorted(fractions) + assert fractions[-1] == WHOLE_RUN + assert recorder.last_of(TaskStatus.COMPLETED) is not None + + +class TestSeveralTasksReportSideBySide: + """Tasks running at once each report on a line of its own, and the run reads them together. + + A finished task's share is let go of as the run counts it, and a report that was already on its + way is history by then, so the two ways of describing the same work never add up twice — which + is what keeps the reading inside the run it describes. + """ + + def test_two_tasks_stand_on_their_own_lines_at_once(self, release_path: Path) -> None: + with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder): + assert recorder.wait_for(several_tasks_reporting, READING_TIMEOUT) + + release_path.touch() + processor.wait(POOL_TIMEOUT) + + def test_the_reading_climbs_and_stays_within_the_run(self, release_path: Path) -> None: + with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder): + assert recorder.wait_for(several_tasks_reporting, READING_TIMEOUT) + release_path.touch() + processor.wait(POOL_TIMEOUT) + + fractions = recorder.fractions + assert fractions == sorted(fractions) + assert max(fractions) == WHOLE_RUN + + +class TestAWithdrawalReachesTheTasks: + """A run let go of unwinds the tasks it handed out, over the same channel they report on. + + The tasks are held at their halfway mark, so the withdrawal below is delivered to workers that + are provably still running: what ends the run is the answer the reporter gave them, and the + pool being torn down afterwards is the backstop rather than the mechanism. + """ + + def test_a_withdrawn_run_ends_cancelled(self, release_path: Path) -> None: + with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + + processor.cancel() + release_path.touch() + processor.wait(POOL_TIMEOUT) + + assert recorder.last_of(TaskStatus.CANCELLING) is not None + assert not processor.is_running() diff --git a/tests/suite/parallelization.py b/tests/suite/parallelization.py new file mode 100644 index 000000000..02cfef70c --- /dev/null +++ b/tests/suite/parallelization.py @@ -0,0 +1,163 @@ +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Final, FrozenSet, List, Optional, Tuple + +from sampletones_core.parallelization.channel.protocol import StepReporter +from sampletones_core.parallelization.processor import TaskProcessor +from sampletones_core.parallelization.task import TaskProgress, TaskStatus, TaskStep +from sampletones_shared.exceptions import OperationCancelled + +COUNTING_STAGE: Final[str] = "counting" +STEP_COUNT: Final[int] = 8 +HALFWAY: Final[int] = STEP_COUNT // 2 +RELEASE_POLL_SECONDS: Final[float] = 0.01 +RELEASE_TIMEOUT_SECONDS: Final[float] = 30.0 +NOTHING_COMPLETED: Final[int] = 0 +WHOLE_TASK: Final[float] = 1.0 +ONE_STEP: Final[int] = 1 + +LIVE_STATUSES: Final[FrozenSet[TaskStatus]] = frozenset({TaskStatus.RUNNING, TaskStatus.COMPLETED}) + + +@dataclass(frozen=True) +class CountingTask: + """One task of a run that counts, reports each count, and does nothing else. + + A reconstruction needs an audio file, an instruction library and seconds of arithmetic before + it can say anything about itself, none of which the channel carrying what it says depends on. + Counting stands in for the work, so what a test of the channel measures is the channel. + + The task waits at the halfway mark until the file at ``release_path`` appears, which is how a + test can assert that a partial report arrived while the task was provably still running. The + wait gives up after ``RELEASE_TIMEOUT_SECONDS`` so a test that never releases it fails on its + own assertion rather than holding the run open. + """ + + index: int + report: StepReporter + release_path: Path + + +def count_task(task: CountingTask) -> int: + """Counts to ``STEP_COUNT``, reporting each count, and answers which task did the counting. + + Runs in a pool worker, so the line it reports on travelled here with it. + + Raises: + OperationCancelled: If the run is withdrawn while the counting is under way. + """ + for completed in range(1, STEP_COUNT + 1): + step = TaskStep( + stage=COUNTING_STAGE, + completed=completed, + total=STEP_COUNT, + fraction=completed / STEP_COUNT, + ) + if not task.report(step): + raise OperationCancelled(f"task {task.index} was withdrawn at {completed}") + + if completed == HALFWAY: + _wait_for_release(task.release_path) + + return task.index + + +def _wait_for_release(release_path: Path) -> None: + deadline = time.monotonic() + RELEASE_TIMEOUT_SECONDS + while not release_path.exists() and time.monotonic() < deadline: + time.sleep(RELEASE_POLL_SECONDS) + + +class CountingProcessor(TaskProcessor[int]): + """A run whose tasks only count, so what a test reads is the channel they count over.""" + + def __init__(self, task_count: int, release_path: Path, max_workers: int) -> None: + super().__init__(max_workers=max_workers) + self._task_count = task_count + self._release_path = release_path + + def _create_tasks(self) -> List[Any]: + return [ + CountingTask( + index=index, + report=self._task_reporter(index), + release_path=self._release_path, + ) + for index in range(self._task_count) + ] + + def _get_task_function(self) -> Callable[[CountingTask], int]: + return count_task + + def _process_results(self, results: List[int]) -> Tuple[int, ...]: + return tuple(results) + + +class ProgressRecorder: + """Keeps every reading a run offers, and waits for the one a test is after. + + A run reports from two threads — the one waiting on results and the one reading the channel — + so the readings are gathered under a condition every arrival wakes. A test names the reading it + wants and waits for it, which is how an assertion about work in flight is made while that work + is provably still in flight. + """ + + def __init__(self) -> None: + self.readings: List[Tuple[TaskStatus, TaskProgress]] = [] + self._condition = threading.Condition() + + def __call__(self, status: TaskStatus, progress: TaskProgress) -> None: + with self._condition: + self.readings.append((status, progress)) + self._condition.notify_all() + + def wait_for(self, matches: Callable[[TaskProgress], bool], timeout: float) -> bool: + """Waits for a reading that matches, and answers whether the run offered one. + + Args: + matches: What the test is waiting to see the run stand at. + timeout: How long to wait for it. + + Returns: + bool: Whether such a reading arrived within the time given. + """ + with self._condition: + return self._condition.wait_for( + lambda: any(matches(progress) for _, progress in self.readings), + timeout=timeout, + ) + + @property + def fractions(self) -> List[float]: + """How full the run stood at each reading taken while it ran, in the order they arrived. + + A run that has ended lets go of its counts, so the readings its teardown offers describe a + processor standing ready rather than the run that just finished. + """ + with self._condition: + return [progress.fraction for status, progress in self.readings if status in LIVE_STATUSES] + + @property + def steps(self) -> List[TaskStep]: + """Every step a task filed, as the readings carried them.""" + with self._condition: + return [step for _, progress in self.readings for step in progress.steps] + + def last_of(self, status: TaskStatus) -> Optional[TaskProgress]: + """The final reading the run offered while it stood at ``status``.""" + with self._condition: + matching = [progress for reading, progress in self.readings if reading == status] + + return matching[-1] if matching else None + + +def stands_partway(progress: TaskProgress) -> bool: + """A run standing between its ends with no task yet finished, so the work is inside one.""" + return progress.completed == NOTHING_COMPLETED and 0.0 < progress.fraction < WHOLE_TASK + + +def several_tasks_reporting(progress: TaskProgress) -> bool: + """More than one task standing on a line of its own at the same moment.""" + return len(progress.steps) > ONE_STEP diff --git a/tests/unit/sampletones_core/parallelization/__init__.py b/tests/unit/sampletones_core/parallelization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/parallelization/test_steps.py b/tests/unit/sampletones_core/parallelization/test_steps.py new file mode 100644 index 000000000..cbe24f396 --- /dev/null +++ b/tests/unit/sampletones_core/parallelization/test_steps.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.parallelization.steps import TaskSteps +from sampletones_core.parallelization.task import TaskProgress, TaskReport, TaskStep +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +STAGE: Final[str] = "matching" +TOTAL: Final[int] = 4 +FIRST: Final[int] = 0 +SECOND: Final[int] = 1 +THIRD: Final[int] = 2 + + +def _report(index: int, completed: int) -> TaskReport: + return TaskReport( + index=index, + step=TaskStep(stage=STAGE, completed=completed, total=TOTAL, fraction=completed / TOTAL), + ) + + +class TestWhereTheRunningTasksStand(BaseTestSuite): + """The table holds one step per running task, in the order the run handed the tasks out.""" + + def test_a_task_reporting_twice_stands_where_it_last_said(self) -> None: + steps = TaskSteps() + + steps.record(_report(FIRST, 1)) + steps.record(_report(FIRST, 3)) + + assert steps.snapshot() == (_report(FIRST, 3).step,) + + def test_the_snapshot_follows_the_order_the_tasks_were_handed_out(self) -> None: + steps = TaskSteps() + + steps.record(_report(THIRD, 1)) + steps.record(_report(FIRST, 2)) + steps.record(_report(SECOND, 3)) + + assert steps.snapshot() == ( + _report(FIRST, 2).step, + _report(SECOND, 3).step, + _report(THIRD, 1).step, + ) + + def test_a_finished_task_is_let_go_of(self) -> None: + steps = TaskSteps() + steps.record(_report(FIRST, 2)) + steps.record(_report(SECOND, 1)) + + steps.complete(FIRST) + + assert steps.snapshot() == (_report(SECOND, 1).step,) + + def test_a_report_arriving_after_its_task_finished_is_history(self) -> None: + steps = TaskSteps() + steps.record(_report(FIRST, 2)) + + steps.complete(FIRST) + steps.record(_report(FIRST, 4)) + + assert steps.snapshot() == () + + def test_a_task_finishing_before_it_ever_reported_costs_nothing(self) -> None: + steps = TaskSteps() + + steps.complete(SECOND) + + assert steps.snapshot() == () + + def test_a_cleared_table_takes_a_new_run_as_it_comes(self) -> None: + steps = TaskSteps() + steps.record(_report(FIRST, 2)) + steps.complete(FIRST) + + steps.clear() + steps.record(_report(FIRST, 1)) + + assert steps.snapshot() == (_report(FIRST, 1).step,) + + +class TestHowFullARunStands(BaseTestSuite): + """A run reads as the tasks it finished plus the part of each running task that is done.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: float + total: int + completed: int + fractions: Tuple[float, ...] + + test_cases = ( + TestCase(label="a_run_with_nothing_to_do", total=0, completed=0, fractions=(), expected=0.0), + TestCase(label="one_task_untouched", total=1, completed=0, fractions=(), expected=0.0), + TestCase(label="one_task_partway", total=1, completed=0, fractions=(0.25,), expected=0.25), + TestCase(label="one_task_done", total=1, completed=1, fractions=(), expected=1.0), + TestCase(label="two_of_four_and_one_partway", total=4, completed=2, fractions=(0.5,), expected=0.625), + TestCase(label="two_tasks_partway", total=4, completed=1, fractions=(0.5, 0.25), expected=0.4375), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_fraction_counts_the_work_under_way(self, test_case: TestCase) -> None: + progress = TaskProgress( + total=test_case.total, + completed=test_case.completed, + steps=tuple( + TaskStep(stage=STAGE, completed=1, total=TOTAL, fraction=fraction) for fraction in test_case.fractions + ), + ) + + assert progress.fraction == pytest.approx(test_case.expected) + + def test_a_run_reporting_no_steps_reads_as_its_completed_tasks(self) -> None: + progress = TaskProgress(total=4, completed=3) + + assert progress.partial == 0.0 + assert progress.fraction == pytest.approx(0.75) From 9eed0baaf4b779142d4bdc3b41de6916f8e08700 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 00:36:44 +0200 Subject: [PATCH 098/142] Renamed: the tracker's first slot from instrument to voice --- docs/development/bugs-and-todos.md | 5 -- docs/development/playback.md | 2 +- docs/development/sequencer-blocks.md | 4 +- docs/guide/sequencer.md | 8 ++-- .../tabs/sequencer/colors/history_role.py | 2 +- .../layout/tabs/sequencer/colors/tracker.py | 9 ++-- .../tabs/sequencer/tracker/subcolumn.py | 2 +- .../logic/project/controller.py | 4 +- .../logic/sequencer/clipboard/tracker.py | 4 +- .../logic/sequencer/history_detail.py | 8 ++-- .../logic/sequencer/tracker/reader.py | 6 +-- .../logic/sequencer/tracker/tracker.py | 26 +++++----- .../logic/sequencer/tracker/writer.py | 2 +- .../ui/panels/sequencer/display.py | 8 ++-- .../ui/panels/sequencer/history.py | 4 +- .../ui/panels/sequencer/input/tracker.py | 8 ++-- .../ui/panels/sequencer/tracker.py | 22 ++++----- .../view_model/sequencer/move.py | 2 +- .../view_model/sequencer/subcolumn.py | 2 +- .../view_model/sequencer/tracker.py | 6 +-- .../view_model/shared/history.py | 2 +- .../layout/tabs/sequencer/colors.yaml | 2 +- .../layout/tabs/sequencer/tracker.yaml | 2 +- tests/suite/sequencer.py | 2 +- .../coordinators/tabs/test_sequencer.py | 2 +- .../logic/sequencer/clipboard/test_tracker.py | 18 +++---- .../logic/sequencer/test_history_detail.py | 6 +-- .../logic/sequencer/tracker/test_adjuster.py | 22 ++++----- .../logic/sequencer/tracker/test_reader.py | 32 ++++++------- .../logic/sequencer/tracker/test_tracker.py | 28 +++++------ .../logic/sequencer/tracker/test_writer.py | 32 ++++++------- .../sequencer/input/test_tracker_input.py | 38 +++++++-------- .../ui/panels/sequencer/test_block_keys.py | 2 +- .../ui/panels/sequencer/test_block_menu.py | 8 ++-- .../ui/panels/sequencer/test_panel_escape.py | 8 ++-- .../panels/sequencer/test_panel_tab_gate.py | 2 +- .../panels/sequencer/test_selection_keys.py | 4 +- .../panels/sequencer/test_tracker_channels.py | 4 +- .../sequencer/test_tracker_context_menu.py | 16 +++---- .../sequencer/test_tracker_navigation.py | 2 +- .../sequencer/test_tracker_play_shortcut.py | 4 +- .../ui/panels/sequencer/test_tracker_rows.py | 2 +- .../sequencer/test_tracker_typed_voice.py | 2 +- .../view_model/sequencer/test_region.py | 2 +- .../view_model/sequencer/test_slot.py | 2 +- .../view_model/sequencer/test_tracker.py | 48 +++++++++---------- .../sampletones_core/project/test_song.py | 20 ++++---- 47 files changed, 219 insertions(+), 227 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f0440802e..6122b38f2 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -26,11 +26,6 @@ * A loop point per envelope: a voice states one point, applied to every populated sequence. * A sample's loop point is offered as a switch in the voice list, though the model carries the point for both kinds of voice. -* `SubColumn.INSTRUMENT` names the first slot of both tracker column kinds, and the two hold - different things: the voice id under the Voice column, and the note on a channel column. One - name for both is wrong half the time, and splitting it reaches the layout keys - (`sequencer/colors.yaml`, `sequencer/tracker.yaml`) and their DTOs, so it is a question of its - own rather than part of naming a voice. ### Workflow diff --git a/docs/development/playback.md b/docs/development/playback.md index 6446cd8b5..beca2c541 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -152,7 +152,7 @@ says (architecture principle 12). The mask is pulled per rendered row, which is principle 6 for this control: a channel drops in or out as the render-ahead buffer drains, with the immediacy every other live edit has. A silenced -channel still takes each row's instrument, transpose, and volume, so returning it to the mix resumes +channel still takes each row's voice, transpose, and volume, so returning it to the mix resumes on the state its pattern has reached. Muting is monitoring, and principle 5 governs what follows. The project holds every channel, so diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index d6f0284f7..50a241c59 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -55,8 +55,8 @@ about reaches nothing. A tracker block carries subcolumn offsets measured from `column_slot_base(column)`, and every base is a multiple of the subcolumn count. An offset therefore addresses the same -kind of subcolumn at whichever column it is replayed against: an instrument value cannot -reach a volume slot. The paste hook takes a `TrackerCell` — a row and a column, with no +kind of subcolumn at whichever column it is replayed against: a voice reference reaches only +another voice slot. The paste hook takes a `TrackerCell` — a row and a column, with no subcolumn — so the type states the rule: the anchor decides *where* a block lands and the block decides *which kind* goes where. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index c7f35aefd..e2e876d9d 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -38,14 +38,14 @@ asks first, because it clears every row that references it. ## Writing a pattern The **Tracker** grid is the pattern editor. Each row is one step in time; the -columns are the **Voice** and the four channels — **Pulse 1**, **Pulse 2**, +columns are the **Sample** and the four channels — **Pulse 1**, **Pulse 2**, **Triangle**, **Noise** — each carrying a voice, a pitch, and a volume. Click a cell and type its value. Right-clicking a cell opens the rest of the operations — **Set voice**, **Note off**, **Clear cell** and **Clear row**, transpose and volume adjustments, **Play from here** to audition from the cursor row, and **Play from this frame** to start at the top of the shown frame. -The **Voice** column places a sample across every channel its reconstruction +The **Sample** column places a sample across every channel its reconstruction covers. An instrument sounds on one channel at a time, so name it in the channel column you want it on. @@ -109,7 +109,7 @@ down and to the right of it. In the **Tracker**, a block keeps the kinds of the cells it came from — a transpose lands in a transpose, a volume in a volume, whichever column you paste onto — and whatever reaches past the last row or the last column is left out. A cell reading -`?`, where the **Voice** column's channels disagree, passes over its target and +`?`, where the **Sample** column's channels disagree, passes over its target and leaves what was there; an empty cell empties it. In the **Order**, a block pasted past the last frame grows the song to hold it, and @@ -192,7 +192,7 @@ wherever you see it. |---------|--------| | Click a channel's name | Silence it, or bring it back | | `Ctrl`+click a channel's name | Solo it — silence the other three; `Ctrl`+click again returns the mix you had | -| Click **Voice** (tracker) or **Master** (order) | Silence every channel, or bring them all back | +| Click **Sample** (tracker) or **Master** (order) | Silence every channel, or bring them all back | | Right-click any name | The same actions as a menu | The **Playback ▸ Channels** submenu carries the same mix: a check marks each channel diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py index ee02d51fd..2a7f2590f 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py @@ -6,7 +6,7 @@ class HistoryRoleColors(BaseModel, extra="forbid", frozen=True): """Colours for the history-detail token roles unique to the detail line. - The instrument/transpose/volume, frame, row, and sample tokens draw from the + The voice/transpose/volume, frame, row, and sample tokens draw from the shared :class:`TrackerColors` palette; only the roles unique to the detail line live here. """ diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py index b6882e867..548db8fb9 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py @@ -7,13 +7,12 @@ class TrackerColors(BaseModel, extra="forbid", frozen=True): """The semantic text colours shared across every tracker view. One palette feeds the pattern grid, the order table, and the history detail so a - concept keeps its colour everywhere: ``instrument`` (the note/sample reference, - yellow like ``sample``), ``transpose``, ``volume``, ``sample``, the ``frame`` and - ``row`` indices, and the ``order`` entries. Defining them once keeps every panel in - step. + concept keeps its colour everywhere: ``voice`` (the slot naming a voice, yellow like + ``sample``), ``transpose``, ``volume``, ``sample``, the ``frame`` and ``row`` + indices, and the ``order`` entries. Defining them once keeps every panel in step. """ - instrument: WrittenColor + voice: WrittenColor transpose: WrittenColor volume: WrittenColor sample: WrittenColor diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py b/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py index 67ff0740f..2e744d054 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py @@ -2,6 +2,6 @@ class SubcolumnWidths(BaseModel, extra="forbid", frozen=True): - instrument: int + voice: int transpose: int volume: int diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index e629238ed..42fb68d93 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -442,7 +442,7 @@ def clear_row( pattern_index: int, row_index: int, *, - instrument: bool = True, + voice: bool = True, transpose: bool = True, volume: bool = True, ) -> None: @@ -457,7 +457,7 @@ def clear_row( channel, pattern_index, row_index, - command=None if instrument else existing.command, + command=None if voice else existing.command, transpose=None if transpose else existing.transpose, volume=None if volume else existing.volume, ) diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py index b191e5122..a5eb89728 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/tracker.py +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -123,7 +123,7 @@ def _state_slot( key: BlockKey, ) -> str: match subcolumn: - case SubColumn.INSTRUMENT: + case SubColumn.VOICE: return self._state_note(block.notes, key) case SubColumn.TRANSPOSE: return self._state_number( @@ -193,7 +193,7 @@ def _read_rows( slot = slot_from_flat(shape.first + position) key = (row_offset, shape.first + position - base) match slot.subcolumn: - case SubColumn.INSTRUMENT: + case SubColumn.VOICE: read = store_reading( notes, key, diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 584a084af..ecae5e82b 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -31,12 +31,12 @@ _SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = { - SubColumn.INSTRUMENT: "i", + SubColumn.VOICE: "n", SubColumn.TRANSPOSE: "t", SubColumn.VOLUME: "v", } _SUBCOLUMN_ROLES: Final[Dict[SubColumn, HistoryDetailRole]] = { - SubColumn.INSTRUMENT: HistoryDetailRole.INSTRUMENT, + SubColumn.VOICE: HistoryDetailRole.VOICE, SubColumn.TRANSPOSE: HistoryDetailRole.TRANSPOSE, SubColumn.VOLUME: HistoryDetailRole.VOLUME, } @@ -154,9 +154,7 @@ def clear_subcolumn( subcolumn: SubColumn, ) -> Segments: affected = ( - ChannelName.items() - if subcolumn is SubColumn.INSTRUMENT - else self._tracker_logic.relevant_channels(row_index) + ChannelName.items() if subcolumn is SubColumn.VOICE else self._tracker_logic.relevant_channels(row_index) ) segments = list(self._location(row_index, channel, affected)) segments.append(self._subcolumn(subcolumn)) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index 00e700c86..c5e6b8b63 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -33,7 +33,7 @@ def read(self, region: TrackerRegion) -> TrackerBlock: """Takes the values a region covers, keeping each kind of subcolumn in a map of its own.""" base = column_slot_base(slot_from_flat(region.first_slot).channel) return TrackerBlock( - notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of), + notes=self._read_subcolumn(region, base, SubColumn.VOICE, self._note_of), transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of), ) @@ -90,8 +90,8 @@ def _note_of(row: Optional[Row]) -> Optional[BlockNote]: column it is written into. """ match row.command if row is not None else None: - case NoteOn() as instrument: - return instrument.voice_id + case NoteOn() as note_on: + return note_on.voice_id case NoteOff() as note_off: return note_off case None: diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index f4dd5738b..1c32004f8 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -34,7 +34,7 @@ from sampletones_shared.utils.callbacks import CallbackMixin _EMPTY_CELL = SequencerCellViewModel( - instrument=display_id(None), + voice=display_id(None), transpose=display_transpose(None), volume=display_volume(None), kind=None, @@ -169,23 +169,23 @@ def clear_cell_subcolumn( ) -> None: """Empties one subcolumn of a cell. - From the sample column an instrument reaches every channel, since the sample + From the sample column the voice slot reaches every channel, since the sample it names is the row's whole note, while transpose and volume follow the channels that column governs. """ - instrument = subcolumn is SubColumn.INSTRUMENT + voice = subcolumn is SubColumn.VOICE transpose = subcolumn is SubColumn.TRANSPOSE volume = subcolumn is SubColumn.VOLUME if channel is not None: self.clear_subcolumn( channel, row_index, - instrument=instrument, + voice=voice, transpose=transpose, volume=volume, ) - elif instrument: - self.clear_subcolumn_all_generators(row_index, instrument=True) + elif voice: + self.clear_subcolumn_all_generators(row_index, voice=True) else: self.clear_sample_subcolumn( row_index, @@ -318,7 +318,7 @@ def clear_subcolumn( channel: ChannelName, row_index: int, *, - instrument: bool = False, + voice: bool = False, transpose: bool = False, volume: bool = False, ) -> None: @@ -330,7 +330,7 @@ def clear_subcolumn( channel, pattern_index, row_index, - instrument=instrument, + voice=voice, transpose=transpose, volume=volume, ) @@ -343,7 +343,7 @@ def clear_subcolumn_all_generators( self, row_index: int, *, - instrument: bool = False, + voice: bool = False, transpose: bool = False, volume: bool = False, ) -> None: @@ -351,7 +351,7 @@ def clear_subcolumn_all_generators( self.clear_subcolumn( channel, row_index, - instrument=instrument, + voice=voice, transpose=transpose, volume=volume, ) @@ -363,7 +363,7 @@ def set_row_sample( ) -> None: """Places a sample across the channels its reconstruction uses. - The voice column is authoritative: the sample is written to every channel it covers, and + The sample column is authoritative: the sample is written to every channel it covers, and the remaining channels on that row are cleared so the row plays exactly that sample. An empty voice id wipes the whole row. @@ -458,7 +458,7 @@ def set_sample_subcolumn( ) -> None: """Synchronises a subcolumn across the row's relevant channels. - Transpose and volume exist independently of an instrument: they follow the + Transpose and volume exist independently of the voice slot: they follow the sample's channels when one is present, and otherwise reach every channel, so a value typed in the sample column always lands somewhere. """ @@ -739,7 +739,7 @@ def _build_cell( was written against a root the reader chose, so its rows read as the notes they sound. """ return SequencerCellViewModel( - instrument=display_command( + voice=display_command( self._controller.project.voices, row.command, ), diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py index 8ad69b38f..80ca31837 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -99,7 +99,7 @@ def _write_note( self._tracker.clear_cell_subcolumn( row_index, channel, - SubColumn.INSTRUMENT, + SubColumn.VOICE, ) def _write_transpose( diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index 796873595..01d90bfc1 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -13,7 +13,7 @@ CELL_TITLE_SEPARATOR: Final[str] = " | " _DEFAULT_LABELS: Final[Dict[SubColumn, str]] = { - SubColumn.INSTRUMENT: display_id(None), + SubColumn.VOICE: display_id(None), SubColumn.TRANSPOSE: display_transpose(None), SubColumn.VOLUME: display_volume(None), } @@ -36,8 +36,8 @@ def cell_title(index: int, label: str) -> str: def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn) -> str: """Extract the pre-formatted display string for one subcolumn from a cell view model.""" match subcolumn: - case SubColumn.INSTRUMENT: - return cell_view_model.instrument + case SubColumn.VOICE: + return cell_view_model.voice case SubColumn.TRANSPOSE: return cell_view_model.transpose case SubColumn.VOLUME: @@ -47,7 +47,7 @@ def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn) def format_committed(subcolumn: SubColumn, value: Optional[int]) -> str: """Format an integer value as the display string stored in the optimistic cell cache.""" match subcolumn: - case SubColumn.INSTRUMENT: + case SubColumn.VOICE: return display_id(value) case SubColumn.TRANSPOSE: return display_transpose(value) diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 9a340473c..e165270c7 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -335,8 +335,8 @@ def _role_color(self, role: HistoryDetailRole) -> BaseColor: return roles.channel case HistoryDetailRole.ROW: return text.row - case HistoryDetailRole.INSTRUMENT: - return text.instrument + case HistoryDetailRole.VOICE: + return text.voice case HistoryDetailRole.TRANSPOSE: return text.transpose case HistoryDetailRole.VOLUME: diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py index a0670ed5c..9b31cf75e 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -24,7 +24,7 @@ from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS DIGIT_COUNT: Final[Dict[SubColumn, int]] = { - SubColumn.INSTRUMENT: 2, + SubColumn.VOICE: 2, SubColumn.TRANSPOSE: 2, SubColumn.VOLUME: 1, } @@ -40,7 +40,7 @@ class TrackerCursor: def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: try: match cursor.subcolumn: - case SubColumn.INSTRUMENT: + case SubColumn.VOICE: return EditAction( row=cursor.row, channel=cursor.channel, @@ -212,7 +212,7 @@ def navigate_subcolumn( """Steps the cursor along the flattened slot axis, wrapping at either end. Wrapping is a navigation policy the cursor owns: walking right off the last - volume slot lands on the sample column's instrument, so a held arrow key + volume slot lands on the sample column's voice slot, so a held arrow key tours the whole row. """ if self.cursor is None: @@ -265,7 +265,7 @@ def type_char( if self.cursor is None: return self, None - if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: + if self.cursor.subcolumn is SubColumn.VOICE and char == MINUS: return self._after_entry(), self._note_off_action(self.cursor) if self.cursor.subcolumn is SubColumn.TRANSPOSE: diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index eb15261a3..4dd527477 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -222,7 +222,7 @@ def __init__( widths = layout.tracker.subcolumn_widths self._subcolumn_widths: Dict[SubColumn, int] = { - SubColumn.INSTRUMENT: widths.instrument, + SubColumn.VOICE: widths.voice, SubColumn.TRANSPOSE: widths.transpose, SubColumn.VOLUME: widths.volume, } @@ -442,7 +442,7 @@ def _create_subcolumn_themes(self) -> None: """ subcolumn_colors = self._layout.colors.text theme_colors = { - SubColumn.INSTRUMENT: subcolumn_colors.instrument, + SubColumn.VOICE: subcolumn_colors.voice, SubColumn.TRANSPOSE: subcolumn_colors.transpose, SubColumn.VOLUME: subcolumn_colors.volume, } @@ -772,7 +772,7 @@ def _compute_cell_values( ) -> CellValues: cell_values: CellValues = {} for row in view_model.rows: - cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample + cell_values[(row.index, None, SubColumn.VOICE)] = row.sample cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.transpose cell_values[(row.index, None, SubColumn.VOLUME)] = row.volume for channel in ChannelName.items(): @@ -1090,7 +1090,7 @@ def _handle_edit_action(self, action: EditAction) -> None: row, channel = action.row, action.channel if action.note_off: - self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = NOTE_OFF + self._editable_cells.values[(row, channel, SubColumn.VOICE)] = NOTE_OFF self.call(self.on_set_note_off, row, channel) return @@ -1100,8 +1100,8 @@ def _handle_edit_action(self, action: EditAction) -> None: resolved = self._resolve_voice_id(action.sample_index, channel) if resolved is not None: sample_index, voice_id = resolved - self._editable_cells.values[(row, channel, SubColumn.INSTRUMENT)] = tracker_display.format_committed( - SubColumn.INSTRUMENT, + self._editable_cells.values[(row, channel, SubColumn.VOICE)] = tracker_display.format_committed( + SubColumn.VOICE, sample_index, ) @@ -1420,7 +1420,7 @@ def add_action_items(self, target: TrackerTarget) -> None: dpg.add_separator() self._surface.add_block_items(target) dpg.add_separator() - self._add_instrument_submenu(target.cell) + self._add_voice_submenu(target.cell) dpg.add_menu_item( label=self._lbl_context_note_off, callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.channel), @@ -1455,7 +1455,7 @@ def _add_select_items(self, cell: TrackerCursor) -> None: callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell), ) - def _add_instrument_submenu(self, cell: TrackerCursor) -> None: + def _add_voice_submenu(self, cell: TrackerCursor) -> None: """Offers the pool to a cell, each voice enabled where that cell's column takes it. The whole pool is listed wherever the menu is raised, so a reader sees every voice the @@ -1477,7 +1477,7 @@ def _add_instrument_submenu(self, cell: TrackerCursor) -> None: dpg.add_menu_item( label=tracker_display.indexed_label(index, voice.name), user_data=(cell.row, cell.channel, voice.voice_id), - callback=self._on_set_instrument_menu, + callback=self._on_set_voice_menu, enabled=self._column_takes(cell.channel, voice), ) @@ -1519,7 +1519,7 @@ def _add_adjust_items( callback=callback, ) - def _on_set_instrument_menu( + def _on_set_voice_menu( self, _sender: Sender, _app_data: None, @@ -1999,7 +1999,7 @@ def _on_row_number_clicked( dpg.set_value(sender, False) existing = self._input_state.cursor channel = existing.channel if existing is not None else None - subcolumn = existing.subcolumn if existing is not None else SubColumn.INSTRUMENT + subcolumn = existing.subcolumn if existing is not None else SubColumn.VOICE self._apply_state( TrackerInputState( cursor=TrackerCursor( diff --git a/src/sampletones_application/view_model/sequencer/move.py b/src/sampletones_application/view_model/sequencer/move.py index 5490d1045..92e13a521 100644 --- a/src/sampletones_application/view_model/sequencer/move.py +++ b/src/sampletones_application/view_model/sequencer/move.py @@ -5,7 +5,7 @@ class MoveDirection(Enum): """A reorder action: move an item toward the start or end of its sequence. - Shared by the instruments list (vertical) and the order table (horizontal); + Shared by the voices list (vertical) and the order table (horizontal); the axis-neutral names map to up/left (``PREVIOUS``), down/right (``NEXT``), top/start (``FIRST``) and bottom/end (``LAST``) at each call site. """ diff --git a/src/sampletones_application/view_model/sequencer/subcolumn.py b/src/sampletones_application/view_model/sequencer/subcolumn.py index 27201b910..d4b11552c 100644 --- a/src/sampletones_application/view_model/sequencer/subcolumn.py +++ b/src/sampletones_application/view_model/sequencer/subcolumn.py @@ -2,6 +2,6 @@ class SubColumn(StrEnum): - INSTRUMENT = "instrument" + VOICE = "voice" TRANSPOSE = "transpose" VOLUME = "volume" diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index 83a9ba97d..9668c8120 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -20,7 +20,7 @@ class SequencerCellViewModel(BaseModel, frozen=True): tracker grid renders :attr:`label`, the combined cell text. """ - instrument: str + voice: str transpose: str volume: str kind: Optional[VoiceKind] @@ -33,7 +33,7 @@ class SequencerCellViewModel(BaseModel, frozen=True): @property def label(self) -> str: - return f"{self.instrument} {self.transpose} {self.volume}" + return f"{self.voice} {self.transpose} {self.volume}" def _sample_reading(cell: SequencerCellViewModel) -> str: @@ -46,7 +46,7 @@ def _sample_reading(cell: SequencerCellViewModel) -> str: if cell.kind is VoiceKind.INSTRUMENT: return display_id(None) - return cell.instrument + return cell.voice class SequencerRowViewModel(BaseModel, frozen=True): diff --git a/src/sampletones_application/view_model/shared/history.py b/src/sampletones_application/view_model/shared/history.py index 08fc07cd9..c74b9cacf 100644 --- a/src/sampletones_application/view_model/shared/history.py +++ b/src/sampletones_application/view_model/shared/history.py @@ -15,7 +15,7 @@ class HistoryDetailRole(StrEnum): FRAME = "frame" CHANNEL = "channel" ROW = "row" - INSTRUMENT = "instrument" + VOICE = "voice" TRANSPOSE = "transpose" VOLUME = "volume" VALUE = "value" diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index 88b613131..851461f25 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -29,7 +29,7 @@ history: value: .history_value separator: .history_separator text: - instrument: .tracker_reference + voice: .tracker_reference transpose: .tracker_transpose volume: .tracker_volume sample: .tracker_reference diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 611b4bff6..10a90f729 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -6,6 +6,6 @@ octave_width: 90 channel_column_tint: 0.09 muted_text_fraction: 0.45 subcolumn_widths: - instrument: 26 + voice: 26 transpose: 30 volume: 18 diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 56faefd9e..dda0e945d 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -202,7 +202,7 @@ def parse_block( continue match SUBCOLUMNS[slot_offset % len(SUBCOLUMNS)]: - case SubColumn.INSTRUMENT: + case SubColumn.VOICE: notes[key] = parse_note(token, voice_ids) case SubColumn.TRANSPOSE: transposes[key] = parse_transpose(token) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d4f210ae4..e6288ef0b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1597,7 +1597,7 @@ def test_player_returns_the_guarded_wrapper( PULSE1_CELL: Final[TrackerRegion] = TrackerRegion( first_row=0, last_row=0, - first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE).flat_index, last_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME).flat_index, ) PULSE1_FRAME: Final[OrderRegion] = OrderRegion( diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py index 277fcc71a..92e92ec80 100644 --- a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py @@ -58,7 +58,7 @@ def _region( PULSE1_CELL = _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), ) @@ -105,7 +105,7 @@ def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: Track class TestTheShapeAStatementCovers: def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: TrackerBlockText) -> None: region = _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE2, SubColumn.VOLUME), rows=4, ) @@ -116,7 +116,7 @@ def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: Tracker def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlockText) -> None: region = _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE2, SubColumn.VOLUME), ) @@ -125,7 +125,7 @@ def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlock def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: TrackerBlockText) -> None: block = TrackerBlock(notes={}, transposes={(0, 1): 1, (2, 1): 3}, volumes={}) region = _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=3, ) @@ -150,7 +150,7 @@ class RoundTripCase: "a cut and an empty note", TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}), _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=2, ), @@ -159,7 +159,7 @@ class RoundTripCase: "the whole transpose range", TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}), _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=3, ), @@ -168,7 +168,7 @@ class RoundTripCase: "the whole volume range", TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}), _region( - first_slot=_slot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), rows=2, ), @@ -177,7 +177,7 @@ class RoundTripCase: "a block anchored at the sample column", TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}), _region( - first_slot=_slot(None, SubColumn.INSTRUMENT), + first_slot=_slot(None, SubColumn.VOICE), last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), ), ), @@ -193,7 +193,7 @@ class RoundTripCase: "the whole grid", TrackerBlock(notes={(0, 12): "kick"}, transposes={(1, 1): -1}, volumes={(1, 14): 4}), _region( - first_slot=_slot(None, SubColumn.INSTRUMENT), + first_slot=_slot(None, SubColumn.VOICE), last_slot=_slot(ChannelName.NOISE, SubColumn.VOLUME), rows=2, ), diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 17a3209a4..bef0d2fa7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -132,7 +132,7 @@ def test_a_block_reads_as_the_channels_and_the_rows_it_covers(self) -> None: first_row=4, last_row=11, first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE).flat_index, - last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOICE).flat_index, ) ) @@ -149,7 +149,7 @@ def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None TrackerRegion( first_row=0, last_row=0, - first_slot=TrackerSlot(None, SubColumn.INSTRUMENT).flat_index, + first_slot=TrackerSlot(None, SubColumn.VOICE).flat_index, last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, ) ) @@ -179,7 +179,7 @@ def test_adjust_transpose_shows_signed_delta(self) -> None: TrackerRegion( first_row=0, last_row=0, - first_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.INSTRUMENT).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOICE).flat_index, last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOLUME).flat_index, ), -3, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py index 0a0cc74b9..745b78732 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py @@ -91,8 +91,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="a cell alone shifts its own channel", region=_region( - (ChannelName.PULSE1, SubColumn.INSTRUMENT), - (ChannelName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.VOICE), + (ChannelName.PULSE1, SubColumn.VOICE), ), delta=1, expected=( @@ -132,7 +132,7 @@ class TestCase(BaseRegularTestCase): label="a region across columns shifts each of them", region=_region( (ChannelName.PULSE2, SubColumn.VOLUME), - (ChannelName.NOISE, SubColumn.INSTRUMENT), + (ChannelName.NOISE, SubColumn.VOICE), ), delta=1, expected=( @@ -144,8 +144,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="a region across rows shifts each of them", region=_region( - (ChannelName.PULSE1, SubColumn.INSTRUMENT), - (ChannelName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.VOICE), + (ChannelName.PULSE1, SubColumn.VOICE), first_row=1, last_row=2, ), @@ -159,7 +159,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="an ungoverned sample column reaches every channel", region=_region( - (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOICE), (None, SubColumn.VOLUME), ), delta=3, @@ -173,7 +173,7 @@ class TestCase(BaseRegularTestCase): label="a governed sample column reaches the channels its sample uses", frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), region=_region( - (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOICE), (None, SubColumn.VOLUME), ), delta=3, @@ -187,7 +187,7 @@ class TestCase(BaseRegularTestCase): label="a channel covered beside the sample column moves a single step", frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), region=_region( - (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOICE), (ChannelName.PULSE1, SubColumn.VOLUME), ), delta=1, @@ -244,8 +244,8 @@ class TestCase(BaseRegularTestCase): TestCase( label="an unset cell steps down from full", region=_region( - (ChannelName.PULSE1, SubColumn.INSTRUMENT), - (ChannelName.PULSE1, SubColumn.INSTRUMENT), + (ChannelName.PULSE1, SubColumn.VOICE), + (ChannelName.PULSE1, SubColumn.VOICE), ), delta=-1, expected=( @@ -286,7 +286,7 @@ class TestCase(BaseRegularTestCase): label="a channel covered beside the sample column moves a single step", frame=(f"{LEAD} ... 8 | {LEAD} ... 8 | .. ... . | .. ... .",), region=_region( - (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOICE), (ChannelName.PULSE1, SubColumn.VOLUME), ), delta=-1, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py index 2a5b3f58c..88f03adef 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -70,7 +70,7 @@ def _column( return TrackerRegion( first_row=0, last_row=last_row, - first_slot=_slot(channel, SubColumn.INSTRUMENT), + first_slot=_slot(channel, SubColumn.VOICE), last_slot=_slot(channel, SubColumn.VOLUME), ) @@ -93,7 +93,7 @@ def test_a_cell_carries_the_values_it_holds( block = reader.read(_column(ChannelName.PULSE1)) - assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.notes[_key(SubColumn.VOICE)] == sample.id assert block.transposes[_key(SubColumn.TRANSPOSE)] == 5 assert block.volumes[_key(SubColumn.VOLUME)] == 3 @@ -104,7 +104,7 @@ def test_an_empty_cell_carries_its_emptiness( """An untouched channel holds no pattern at all, which reads as the empty cell it shows.""" block = reader.read(_column(ChannelName.NOISE)) - assert block.notes[_key(SubColumn.INSTRUMENT)] is None + assert block.notes[_key(SubColumn.VOICE)] is None assert block.transposes[_key(SubColumn.TRANSPOSE)] is None assert block.volumes[_key(SubColumn.VOLUME)] is None @@ -115,9 +115,9 @@ def test_a_cut_cell_carries_the_cut( ) -> None: logic.cut_note(0, ChannelName.PULSE1) - block = reader.read(_cell(0, ChannelName.PULSE1, SubColumn.INSTRUMENT)) + block = reader.read(_cell(0, ChannelName.PULSE1, SubColumn.VOICE)) - assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() + assert block.notes[_key(SubColumn.VOICE)] == NoteOff() def test_a_zero_transpose_carries_as_the_value_it_is( self, @@ -165,7 +165,7 @@ def test_a_value_every_governed_channel_shares_carries_over( block = reader.read(_column(None)) - assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.notes[_key(SubColumn.VOICE)] == sample.id assert block.transposes[_key(SubColumn.TRANSPOSE)] == 7 def test_a_note_carries_as_the_sample_it_names( @@ -181,9 +181,9 @@ def test_a_note_carries_as_the_sample_it_names( ) logic.place_note(0, None, sample.id) - block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + block = reader.read(_cell(0, None, SubColumn.VOICE)) - assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.notes[_key(SubColumn.VOICE)] == sample.id def test_a_column_its_channels_disagree_over_leaves_its_key_out( self, @@ -204,9 +204,9 @@ def test_a_half_cut_row_leaves_its_note_out( ) -> None: logic.cut_note(0, ChannelName.PULSE1) - block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + block = reader.read(_cell(0, None, SubColumn.VOICE)) - assert _key(SubColumn.INSTRUMENT) not in block.notes + assert _key(SubColumn.VOICE) not in block.notes def test_a_wholly_cut_row_carries_the_cut( self, @@ -215,9 +215,9 @@ def test_a_wholly_cut_row_carries_the_cut( ) -> None: logic.cut_note(0, None) - block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + block = reader.read(_cell(0, None, SubColumn.VOICE)) - assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() + assert block.notes[_key(SubColumn.VOICE)] == NoteOff() def test_an_untouched_row_carries_its_emptiness( self, @@ -226,7 +226,7 @@ def test_an_untouched_row_carries_its_emptiness( """Every channel is equally empty, which is a reading they agree on.""" block = reader.read(_column(None)) - assert block.notes[_key(SubColumn.INSTRUMENT)] is None + assert block.notes[_key(SubColumn.VOICE)] is None assert block.transposes[_key(SubColumn.TRANSPOSE)] is None assert block.volumes[_key(SubColumn.VOLUME)] is None @@ -246,12 +246,12 @@ def test_a_mixed_edge_column_leaves_only_itself_out( TrackerRegion( first_row=0, last_row=0, - first_slot=_slot(None, SubColumn.INSTRUMENT), + first_slot=_slot(None, SubColumn.VOICE), last_slot=_slot(None, SubColumn.VOLUME), ) ) - assert set(block.notes) == {_key(SubColumn.INSTRUMENT)} + assert set(block.notes) == {_key(SubColumn.VOICE)} assert set(block.transposes) == {_key(SubColumn.TRANSPOSE)} assert _key(SubColumn.VOLUME) not in block.volumes @@ -269,7 +269,7 @@ def test_the_offsets_are_measured_from_the_column_the_block_begins_in( first_row=0, last_row=0, first_slot=_slot(ChannelName.PULSE2, SubColumn.TRANSPOSE), - last_slot=_slot(ChannelName.TRIANGLE, SubColumn.INSTRUMENT), + last_slot=_slot(ChannelName.TRIANGLE, SubColumn.VOICE), ) ) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 0bfed9e5d..eb355ad1a 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -28,7 +28,7 @@ def _row( return song[channel].get_row(pattern_index, row_index) -def _place_instrument( +def _place_voice( controller: ProjectController, channel: ChannelName, voice_id: str, @@ -77,7 +77,7 @@ def test_a_channel_cell_clears_one_subcolumn_of_its_own(self) -> None: assert row.transpose is None assert row.volume == 10 - def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: + def test_the_sample_column_clears_the_voice_from_every_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( @@ -87,7 +87,7 @@ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: logic.set_row_sample(0, sample.id) logic.set_note_off(ChannelName.NOISE, 0) - logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT) + logic.clear_cell_subcolumn(0, None, SubColumn.VOICE) for channel in ChannelName.items(): assert _row(controller, channel).command is None @@ -248,7 +248,7 @@ def test_one_placement_reports_the_samples_whole_span(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - _place_instrument(controller, ChannelName.PULSE1, sample.id) + _place_voice(controller, ChannelName.PULSE1, sample.id) assert logic.referenced_channels(0) == frozenset( { @@ -359,7 +359,7 @@ def test_synchronises_across_relevant_channels_even_without_instrument( sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - _place_instrument(controller, ChannelName.PULSE1, sample.id) + _place_voice(controller, ChannelName.PULSE1, sample.id) logic.set_sample_subcolumn(0, transpose=5) logic.set_sample_subcolumn(0, volume=10) @@ -440,11 +440,11 @@ def test_clamps_to_max_transpose(self) -> None: assert _row(controller, ChannelName.PULSE1).transpose == MAX_TRANSPOSE - def test_preserves_instrument_and_volume(self) -> None: + def test_preserves_the_voice_and_the_volume(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead") - _place_instrument(controller, ChannelName.PULSE1, sample.id) + _place_voice(controller, ChannelName.PULSE1, sample.id) logic.adjust_volume(ChannelName.PULSE1, 0, -1) logic.adjust_transpose(ChannelName.PULSE1, 0, 2) @@ -490,7 +490,7 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), name="lead", ) - _place_instrument(controller, ChannelName.PULSE1, sample.id) + _place_voice(controller, ChannelName.PULSE1, sample.id) row = logic.build_grid().rows[0] @@ -507,7 +507,7 @@ def test_full_placement_reads_as_the_sample(self) -> None: row = logic.build_grid().rows[0] - assert row.sample == row.cells[ChannelName.PULSE1].instrument + assert row.sample == row.cells[ChannelName.PULSE1].voice assert row.sample != MIXED def test_diverging_transpose_renders_as_mixed(self) -> None: @@ -636,7 +636,7 @@ def test_an_instrument_alone_leaves_the_column_empty(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) instrument = controller.add_instrument(new_instrument("lead")) - _place_instrument(controller, ChannelName.PULSE1, instrument.id) + _place_voice(controller, ChannelName.PULSE1, instrument.id) row = logic.build_grid().rows[0] @@ -652,11 +652,11 @@ def test_a_sample_beside_an_instrument_reads_as_that_sample(self) -> None: ) instrument = controller.add_instrument(new_instrument("pad")) logic.set_row_sample(0, sample.id) - _place_instrument(controller, ChannelName.NOISE, instrument.id) + _place_voice(controller, ChannelName.NOISE, instrument.id) row = logic.build_grid().rows[0] - assert row.sample == row.cells[ChannelName.PULSE1].instrument + assert row.sample == row.cells[ChannelName.PULSE1].voice assert row.sample != MIXED def test_a_row_cut_on_every_channel_still_reads_as_a_cut(self) -> None: @@ -675,8 +675,8 @@ def test_a_cell_names_the_kind_of_the_voice_it_starts(self) -> None: name="lead", ) instrument = controller.add_instrument(new_instrument("pad")) - _place_instrument(controller, ChannelName.PULSE1, sample.id) - _place_instrument(controller, ChannelName.NOISE, instrument.id) + _place_voice(controller, ChannelName.PULSE1, sample.id) + _place_voice(controller, ChannelName.NOISE, instrument.id) cells = logic.build_grid().rows[0].cells diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py index a69b88c0e..2cdaf0a30 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -105,7 +105,7 @@ class TestCase(BaseRegularTestCase): label="a sample through the sample column reaches its channels and clears the rest", frame=(".. ... . | .. ... . | .. ... . | .. ... 5",), block=(LEAD,), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=None), expected=( "00 ... . | 00 ... . | .. ... . | .. ... .", @@ -117,14 +117,14 @@ class TestCase(BaseRegularTestCase): TestCase( label="an instrument through the sample column is passed over", block=(PAD,), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=None), expected=(EMPTY, EMPTY, EMPTY, EMPTY), ), TestCase( label="an instrument through a channel column lands on that channel", block=(PAD,), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.NOISE), expected=( ".. ... . | .. ... . | .. ... . | 02 ... .", @@ -136,7 +136,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a channel beside the sample column overwrites what it settled", block=(f"{LEAD} ... . | {BASS}",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=None), expected=( "01 ... . | 00 ... . | .. ... . | .. ... .", @@ -148,7 +148,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="a block read from the sample column writes one channel when written to one", block=(LEAD,), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.TRIANGLE), expected=( ".. ... . | .. ... . | 00 ... . | .. ... .", @@ -161,7 +161,7 @@ class TestCase(BaseRegularTestCase): label="a mixed cell leaves its target as it stands while its neighbours clear theirs", frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",), block=(".. ? .",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( ".. +03 . | .. ... . | .. ... . | .. ... .", @@ -187,7 +187,7 @@ class TestCase(BaseRegularTestCase): label="a cut through the sample column cuts every channel", frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), block=("~~",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=None), expected=( "~~ ... . | ~~ ... . | ~~ ... . | ~~ ... .", @@ -200,7 +200,7 @@ class TestCase(BaseRegularTestCase): label="a note naming an absent sample writes nothing into a channel", frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), block=("!! ? ?",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( "00 +02 5 | .. ... . | .. ... . | .. ... .", @@ -213,7 +213,7 @@ class TestCase(BaseRegularTestCase): label="a note naming an absent sample clears nothing through the sample column", frame=("00 ... . | 00 ... . | .. ... . | .. ... 5",), block=("!!",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=None), expected=( "00 ... . | 00 ... . | .. ... . | .. ... 5", @@ -226,7 +226,7 @@ class TestCase(BaseRegularTestCase): label="an empty instrument through the sample column clears every channel", frame=("00 ... . | 00 ... . | .. ... . | ~~ ... .",), block=("..",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=None), expected=(EMPTY, EMPTY, EMPTY, EMPTY), ), @@ -278,7 +278,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="slots past the last column are dropped rather than wrapped", block=(f"{LEAD} ... . | {BASS}",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.NOISE), expected=( ".. ... . | .. ... . | .. ... . | 00 ... .", @@ -291,7 +291,7 @@ class TestCase(BaseRegularTestCase): label="a wholly mixed block leaves the frame as it stands", frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), block=("? ? ?",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( "00 +02 5 | .. ... . | .. ... . | .. ... .", @@ -304,7 +304,7 @@ class TestCase(BaseRegularTestCase): label="a wholly empty block empties what it covers", frame=("00 +02 5 | 00 +02 5 | .. ... . | .. ... .",), block=(".. ... .",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, origin=TrackerCell(row=0, channel=ChannelName.PULSE1), expected=( ".. ... . | 00 +02 5 | .. ... . | .. ... .", @@ -371,7 +371,7 @@ def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None: first_row=0, last_row=0, first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE).flat_index, - last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOICE).flat_index, ) ) @@ -412,7 +412,7 @@ def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) region = TrackerRegion( first_row=0, last_row=1, - first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT).flat_index, + first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE).flat_index, last_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index, ) block = TrackerBlockReader(grid.logic).read(region) @@ -449,7 +449,7 @@ def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid: block = parse_block( ("? ? ?",), - first_subcolumn=SubColumn.INSTRUMENT, + first_subcolumn=SubColumn.VOICE, voice_ids=grid.voice_ids, ) grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2)) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 7c5c37c36..a6e7364cb 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -20,7 +20,7 @@ def _state( class TestNoteOffEntry: def test_minus_in_instrument_emits_note_off(self) -> None: - state, action = _state(SubColumn.INSTRUMENT).type_char("-") + state, action = _state(SubColumn.VOICE).type_char("-") assert action is not None assert action.note_off is True assert action.row == 0 @@ -28,7 +28,7 @@ def test_minus_in_instrument_emits_note_off(self) -> None: assert state.pending == "" def test_plus_in_instrument_is_ignored(self) -> None: - state = _state(SubColumn.INSTRUMENT) + state = _state(SubColumn.VOICE) new_state, action = state.type_char("+") assert action is None assert new_state is state @@ -43,10 +43,10 @@ class TestSelection: """Shift-extended moves grow a region from the cell the selection was started on.""" def test_a_state_without_an_anchor_covers_no_region(self) -> None: - assert _state(SubColumn.INSTRUMENT).region is None + assert _state(SubColumn.VOICE).region is None def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: - extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT) + extended = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT) region = extended.region assert region is not None @@ -54,13 +54,13 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: """The bounds are ordered by the region, so the direction of the drag leaves no trace.""" - upwards = _state(SubColumn.INSTRUMENT, row=5).extend_row(-1, ROW_COUNT).region - downwards = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).region + upwards = _state(SubColumn.VOICE, row=5).extend_row(-1, ROW_COUNT).region + downwards = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).region assert upwards == downwards def test_a_further_extend_keeps_the_original_anchor(self) -> None: - extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT) + extended = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT) region = extended.region assert region is not None @@ -74,24 +74,24 @@ def test_extending_slots_reaches_across_the_column_boundary(self) -> None: assert (region.first_slot, region.last_slot) == (2, 3) assert extended.cursor is not None assert extended.cursor.channel is ChannelName.PULSE1 - assert extended.cursor.subcolumn is SubColumn.INSTRUMENT + assert extended.cursor.subcolumn is SubColumn.VOICE def test_extending_slots_stops_at_either_end_of_the_axis(self) -> None: """A selection covers a run of the grid, so its reach stops where plain navigation wraps.""" - first = _state(SubColumn.INSTRUMENT, channel=None).extend_slot(-1) + first = _state(SubColumn.VOICE, channel=None).extend_slot(-1) last = _state(SubColumn.VOLUME, channel=ChannelName.NOISE).extend_slot(1) - assert first.cursor == TrackerCursor(0, None, SubColumn.INSTRUMENT) + assert first.cursor == TrackerCursor(0, None, SubColumn.VOICE) assert last.cursor == TrackerCursor(0, ChannelName.NOISE, SubColumn.VOLUME) def test_a_plain_move_collapses_the_selection(self) -> None: - moved = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT) + moved = _state(SubColumn.VOICE, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT) assert moved.anchor is None assert moved.region is None def test_a_plain_column_move_collapses_the_selection(self) -> None: - moved = _state(SubColumn.INSTRUMENT).extend_row(2, ROW_COUNT).navigate_column_by(1) + moved = _state(SubColumn.VOICE).extend_row(2, ROW_COUNT).navigate_column_by(1) assert moved.region is None @@ -110,7 +110,7 @@ def test_typing_a_value_collapses_the_selection(self) -> None: assert typed.region is None def test_a_note_off_collapses_the_selection(self) -> None: - selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + selected = _state(SubColumn.VOICE, row=4).extend_row(2, ROW_COUNT) typed, action = selected.type_char("-") @@ -145,8 +145,8 @@ def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> Non assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE),) def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: - selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) - cell = TrackerCursor(5, ChannelName.PULSE1, SubColumn.INSTRUMENT) + selected = _state(SubColumn.VOICE, row=4).extend_row(2, ROW_COUNT) + cell = TrackerCursor(5, ChannelName.PULSE1, SubColumn.VOICE) assert selected.region_at(cell) == selected.region @@ -193,15 +193,15 @@ def test_selecting_a_subcolumn_reaches_the_one_slot_the_cursor_stands_on(self) - def test_a_shape_stands_the_cursor_on_the_last_row_it_reaches(self) -> None: """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" - cell = TrackerCursor(4, ChannelName.PULSE1, SubColumn.INSTRUMENT) + cell = TrackerCursor(4, ChannelName.PULSE1, SubColumn.VOICE) - selected = _state(SubColumn.INSTRUMENT, row=4).select_column(cell, ROW_COUNT) + selected = _state(SubColumn.VOICE, row=4).select_column(cell, ROW_COUNT) assert selected.cursor == TrackerCursor(ROW_COUNT - 1, ChannelName.PULSE1, SubColumn.VOLUME) - assert selected.anchor == TrackerCursor(0, ChannelName.PULSE1, SubColumn.INSTRUMENT) + assert selected.anchor == TrackerCursor(0, ChannelName.PULSE1, SubColumn.VOICE) def test_a_frame_holding_no_rows_selects_nothing(self) -> None: - state = _state(SubColumn.INSTRUMENT) + state = _state(SubColumn.VOICE) assert state.select_all(0) is state diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 6dd257221..adce97f48 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -78,7 +78,7 @@ def _panel( gestures: Gestures, *, channel: Optional[ChannelName] = ChannelName.PULSE1, - subcolumn: SubColumn = SubColumn.INSTRUMENT, + subcolumn: SubColumn = SubColumn.VOICE, ) -> GUISequencerTrackerPanel: """A tracker panel reporting the gestures it fires, with its grid left unbuilt. diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 782832471..3de6f4d66 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -206,7 +206,7 @@ def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: def _tracker_cell(channel: Optional[ChannelName]) -> TrackerCursor: """The clicked cell the tracker item tests raise their menu on.""" - return TrackerCursor(CLICKED_ROW, channel, SubColumn.INSTRUMENT) + return TrackerCursor(CLICKED_ROW, channel, SubColumn.VOICE) def _order_cell(channel: Optional[ChannelName]) -> OrderCursor: @@ -240,7 +240,7 @@ def _order_selections( def _selected_tracker_state() -> TrackerInputState: """A selection running from the clicked row down two rows, over Pulse 1's whole cell.""" - state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, ChannelName.PULSE1, SubColumn.INSTRUMENT)) + state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, ChannelName.PULSE1, SubColumn.VOICE)) return state.extend_row(2, ROW_COUNT).extend_slot(2) @@ -287,10 +287,10 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) + target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.VOICE)) assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) - assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + assert target.region.slots == (TrackerSlot(None, SubColumn.VOICE),) def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: """The menu bar asks for the cursor's own target, which is the standing selection.""" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py index 0557c7b7a..f41019f89 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py @@ -26,14 +26,14 @@ class TestTrackerEscapeYieldsToGlobalStop: def test_escape_yields_when_no_pending_edit(self) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() - panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="") + panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.VOICE), pending="") assert panel._on_key_pressed(_escape()) is False def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() - panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="3") + panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.VOICE), pending="3") applied: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", applied.append) @@ -46,8 +46,8 @@ def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyP panel._shortcuts = shipped_source() panel._current_row_count = 64 panel._input_state = TrackerInputState( - cursor=TrackerCursor(4, None, SubColumn.INSTRUMENT), - anchor=TrackerCursor(2, None, SubColumn.INSTRUMENT), + cursor=TrackerCursor(4, None, SubColumn.VOICE), + anchor=TrackerCursor(2, None, SubColumn.VOICE), ) applied: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", applied.append) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py index 8d5c34f4a..29999c4d9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -36,7 +36,7 @@ def _tracker(tab_active: ActivePredicate) -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._router = KeyRouter() panel._tab_active = tab_active - panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT)) + panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.VOICE)) return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index 54de611d3..764f95b66 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -32,7 +32,7 @@ def _press(text: str) -> KeyEvent: def _tracker( channel: Optional[ChannelName] = ChannelName.PULSE1, - subcolumn: SubColumn = SubColumn.INSTRUMENT, + subcolumn: SubColumn = SubColumn.VOICE, ) -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() @@ -101,7 +101,7 @@ def test_shift_right_selects_the_next_subcolumn(self, monkeypatch: pytest.Monkey region = states[-1].region assert region is not None assert region.slots == ( - TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE), TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE), ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index e6d2111a4..3e07b4a22 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -42,12 +42,12 @@ HEADER_THEME = 1 MUTED_HEADER_THEME = 2 SUBCOLUMN_THEMES: Dict[SubColumn, int] = { - SubColumn.INSTRUMENT: 10, + SubColumn.VOICE: 10, SubColumn.TRANSPOSE: 11, SubColumn.VOLUME: 12, } MUTED_SUBCOLUMN_THEMES: Dict[SubColumn, int] = { - SubColumn.INSTRUMENT: 20, + SubColumn.VOICE: 20, SubColumn.TRANSPOSE: 21, SubColumn.VOLUME: 22, } diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 1a8cf1fa4..4e9a9d77b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -1,5 +1,5 @@ import contextlib -from typing import Any, Dict, Iterator, List, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple import pytest @@ -89,9 +89,9 @@ def _menu(**kwargs: Any) -> Iterator[None]: return instance -def _cell(row: int, channel: ChannelName) -> TrackerCursor: +def _cell(row: int, channel: Optional[ChannelName]) -> TrackerCursor: """The cell a menu was raised on, which the items carry as their payload.""" - return TrackerCursor(row, channel, SubColumn.INSTRUMENT) + return TrackerCursor(row, channel, SubColumn.VOICE) def _target(row: int, channel: ChannelName) -> TrackerTarget: @@ -157,7 +157,7 @@ def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) - chosen: List[str] = [] panel.on_set_row = lambda row, channel, voice_id, transpose, volume: chosen.append(voice_id) - panel._add_instrument_submenu(_cell(0, ChannelName.PULSE2)) + panel._add_voice_submenu(_cell(0, ChannelName.PULSE2)) recorder.dispatch_as_dpg() assert chosen == ["lead-id"] @@ -193,7 +193,7 @@ def _panel_with_both_kinds() -> tracker_module.GUISequencerTrackerPanel: def test_a_channel_column_reaches_both_kinds(self, recorder: _MenuItemRecorder) -> None: panel = self._panel_with_both_kinds() - panel._add_instrument_submenu(_cell(0, ChannelName.PULSE2)) + panel._add_voice_submenu(_cell(0, ChannelName.PULSE2)) assert recorder.reachable(self.SAMPLE_LABEL) is True assert recorder.reachable(self.INSTRUMENT_LABEL) is True @@ -201,7 +201,7 @@ def test_a_channel_column_reaches_both_kinds(self, recorder: _MenuItemRecorder) def test_the_sample_column_reaches_a_sample_alone(self, recorder: _MenuItemRecorder) -> None: panel = self._panel_with_both_kinds() - panel._add_instrument_submenu(_cell(0, None)) + panel._add_voice_submenu(_cell(0, None)) assert recorder.reachable(self.SAMPLE_LABEL) is True assert recorder.reachable(self.INSTRUMENT_LABEL) is False @@ -213,7 +213,7 @@ def test_the_sample_column_still_names_the_instrument_it_stands_by_for( """An unreachable item says the voice exists while leaving it where it belongs.""" panel = self._panel_with_both_kinds() - panel._add_instrument_submenu(_cell(0, None)) + panel._add_voice_submenu(_cell(0, None)) assert [entry["label"] for entry in recorder.entries] == [ self.SAMPLE_LABEL, @@ -224,6 +224,6 @@ def test_an_empty_pool_offers_one_unreachable_item(self, recorder: _MenuItemReco panel = _panel() panel._current_samples = SequencerVoicesViewModel(voices=()) - panel._add_instrument_submenu(_cell(0, None)) + panel._add_voice_submenu(_cell(0, None)) assert [entry["enabled"] for entry in recorder.entries] == [False] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index 2bfec6503..8eb235ab0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -32,7 +32,7 @@ def _panel() -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState( - cursor=TrackerCursor(CURSOR_ROW, None, SubColumn.INSTRUMENT), + cursor=TrackerCursor(CURSOR_ROW, None, SubColumn.VOICE), pending="", ) panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE)) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py index 9a6c38c97..2679b2534 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py @@ -26,7 +26,7 @@ class TestGridPlayFromHere: def test_ctrl_shift_space_plays_from_the_cursor_row(self) -> None: rows: List[int] = [] - panel = _panel(TrackerCursor(5, None, SubColumn.INSTRUMENT)) + panel = _panel(TrackerCursor(5, None, SubColumn.VOICE)) panel.on_play_from_row = rows.append assert panel._on_key_pressed(_play_from_here()) is True @@ -39,7 +39,7 @@ def test_ctrl_shift_space_yields_without_a_cursor(self) -> None: def test_ctrl_space_yields_to_the_global_shortcut(self) -> None: played: List[int] = [] - panel = _panel(TrackerCursor(5, None, SubColumn.INSTRUMENT)) + panel = _panel(TrackerCursor(5, None, SubColumn.VOICE)) panel.on_play_from_row = played.append result = panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Spacebar, modifiers=CTRL)) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index 16c9344c4..11372d787 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -128,7 +128,7 @@ def _place_cursor( ) -> None: """Puts the cursor where the panel's own state keeps it, the way an edit action does.""" panel._input_state = TrackerInputState( - cursor=TrackerCursor(row_index, channel, SubColumn.INSTRUMENT), + cursor=TrackerCursor(row_index, channel, SubColumn.VOICE), pending="", ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py index 6d371b45a..c130280ef 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -65,7 +65,7 @@ def type_voice(self, index: int, channel: Optional[ChannelName]) -> None: def shown(self, channel: Optional[ChannelName]) -> str: """The label the cell cache holds, which is what the cell shows once the commit settles.""" - return self.panel._editable_cells.values.get((0, channel, SubColumn.INSTRUMENT), STORED_LABEL) + return self.panel._editable_cells.values.get((0, channel, SubColumn.VOICE), STORED_LABEL) @pytest.fixture diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py index b18bc2a74..3a1debb7b 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -30,7 +30,7 @@ def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None assert region.slots == ( TrackerSlot(None, SubColumn.VOLUME), - TrackerSlot(ChannelName.PULSE1, SubColumn.INSTRUMENT), + TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE), ) def test_a_region_spans_the_whole_axis(self) -> None: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py index 0e8863be6..cc526609d 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py @@ -35,7 +35,7 @@ def test_the_axis_maps_onto_the_whole_index_range(self) -> None: assert indices == set(range(SLOT_COUNT)) def test_the_sample_columns_instrument_opens_the_axis(self) -> None: - assert TrackerSlot(None, SubColumn.INSTRUMENT).flat_index == 0 + assert TrackerSlot(None, SubColumn.VOICE).flat_index == 0 class TestColumnBase: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index 9c81d5298..f10f695f8 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -19,20 +19,20 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase -_EMPTY_INSTRUMENT = display_id(None) +_EMPTY_VOICE = display_id(None) _EMPTY_TRANSPOSE = display_transpose(None) _EMPTY_VOLUME = display_volume(None) def _cell( *, - instrument: str = _EMPTY_INSTRUMENT, + voice: str = _EMPTY_VOICE, transpose: str = _EMPTY_TRANSPOSE, volume: str = _EMPTY_VOLUME, kind: Optional[VoiceKind] = None, ) -> SequencerCellViewModel: return SequencerCellViewModel( - instrument=instrument, + voice=voice, transpose=transpose, volume=volume, kind=kind, @@ -44,7 +44,7 @@ def _empty_cell() -> SequencerCellViewModel: _OCCUPIED = _cell( - instrument=display_id(0), + voice=display_id(0), transpose=display_transpose(5), volume=display_volume(8), kind=VoiceKind.SAMPLE, @@ -66,7 +66,7 @@ class TestSampleColumnAggregate(BaseTestSuite): class AggregateCase(BaseRegularTestCase): cells: Dict[ChannelName, SequencerCellViewModel] sample_channels: FrozenSet[ChannelName] - expected_instrument: str + expected_sample: str expected_transpose: str expected_volume: str @@ -75,7 +75,7 @@ class AggregateCase(BaseRegularTestCase): label="no_sample_channels_fall_back_to_defaults", cells=_row_cells(), sample_channels=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_sample=_EMPTY_VOICE, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), @@ -83,7 +83,7 @@ class AggregateCase(BaseRegularTestCase): label="transpose_and_volume_span_all_channels_when_no_sample_is_present", cells={channel: _cell(volume=display_volume(8)) for channel in ChannelName.items()}, sample_channels=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_sample=_EMPTY_VOICE, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=display_volume(8), ), @@ -91,7 +91,7 @@ class AggregateCase(BaseRegularTestCase): label="a_single_sample_channel_present", cells=_row_cells(pulse1=_OCCUPIED), sample_channels=frozenset({ChannelName.PULSE1}), - expected_instrument=display_id(0), + expected_sample=display_id(0), expected_transpose=display_transpose(5), expected_volume=display_volume(8), ), @@ -104,7 +104,7 @@ class AggregateCase(BaseRegularTestCase): ChannelName.TRIANGLE, } ), - expected_instrument=display_id(0), + expected_sample=display_id(0), expected_transpose=display_transpose(5), expected_volume=display_volume(8), ), @@ -117,16 +117,16 @@ class AggregateCase(BaseRegularTestCase): ChannelName.TRIANGLE, } ), - expected_instrument=MIXED, + expected_sample=MIXED, expected_transpose=MIXED, expected_volume=MIXED, ), AggregateCase( - label="diverging_transpose_is_mixed_while_instrument_is_uniform", + label="diverging_transpose_is_mixed_while_the_sample_is_uniform", cells=_row_cells( pulse1=_OCCUPIED, triangle=_cell( - instrument=display_id(0), + voice=display_id(0), transpose=_EMPTY_TRANSPOSE, volume=display_volume(8), ), @@ -137,7 +137,7 @@ class AggregateCase(BaseRegularTestCase): ChannelName.TRIANGLE, } ), - expected_instrument=display_id(0), + expected_sample=display_id(0), expected_transpose=MIXED, expected_volume=display_volume(8), ), @@ -145,12 +145,12 @@ class AggregateCase(BaseRegularTestCase): label="an_instrument_alone_on_a_row_leaves_the_sample_column_empty", cells=_row_cells( pulse1=_cell( - instrument=display_id(3), + voice=display_id(3), kind=VoiceKind.INSTRUMENT, ), ), sample_channels=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_sample=_EMPTY_VOICE, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), @@ -160,7 +160,7 @@ class AggregateCase(BaseRegularTestCase): pulse1=_OCCUPIED, triangle=_OCCUPIED, noise=_cell( - instrument=display_id(3), + voice=display_id(3), kind=VoiceKind.INSTRUMENT, ), ), @@ -170,23 +170,23 @@ class AggregateCase(BaseRegularTestCase): ChannelName.TRIANGLE, } ), - expected_instrument=display_id(0), + expected_sample=display_id(0), expected_transpose=display_transpose(5), expected_volume=display_volume(8), ), AggregateCase( label="all_channels_note_off_reads_as_note_off", - cells={channel: _cell(instrument=NOTE_OFF) for channel in ChannelName.items()}, + cells={channel: _cell(voice=NOTE_OFF) for channel in ChannelName.items()}, sample_channels=frozenset(), - expected_instrument=NOTE_OFF, + expected_sample=NOTE_OFF, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), AggregateCase( label="half_cut_row_is_mixed", - cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)), + cells=_row_cells(pulse1=_cell(voice=NOTE_OFF)), sample_channels=frozenset(), - expected_instrument=MIXED, + expected_sample=MIXED, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), @@ -194,7 +194,7 @@ class AggregateCase(BaseRegularTestCase): label="zero_transpose_beside_an_empty_one_is_mixed", cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))), sample_channels=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_sample=_EMPTY_VOICE, expected_transpose=MIXED, expected_volume=_EMPTY_VOLUME, ), @@ -202,7 +202,7 @@ class AggregateCase(BaseRegularTestCase): label="zero_transpose_shared_by_every_channel_reads_as_zero", cells={channel: _cell(transpose=display_transpose(0)) for channel in ChannelName.items()}, sample_channels=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_sample=_EMPTY_VOICE, expected_transpose=display_transpose(0), expected_volume=_EMPTY_VOLUME, ), @@ -219,6 +219,6 @@ def test_sample_column_aggregates_over_the_channels_it_spans( sample_channels=case.sample_channels, ) - assert row.sample == case.expected_instrument + assert row.sample == case.expected_sample assert row.transpose == case.expected_transpose assert row.volume == case.expected_volume diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index 8438326ba..d6ee1eed2 100644 --- a/tests/unit/sampletones_core/project/test_song.py +++ b/tests/unit/sampletones_core/project/test_song.py @@ -18,7 +18,7 @@ def _song(rows_per_pattern: int = _ROWS) -> Song: return Song.empty(rows_per_pattern) -def _place_instrument(song: Song, channel: ChannelName, voice_id: str, row_index: int = 0) -> None: +def _place_voice(song: Song, channel: ChannelName, voice_id: str, row_index: int = 0) -> None: pattern = song.pattern(channel, 0) assert pattern is not None pattern.rows[row_index] = Row(command=NoteOn(voice_id=voice_id)) @@ -191,7 +191,7 @@ def test_editing_a_shared_pattern_is_heard_in_both_frames(self) -> None: duplicate_index = song.order[1][ChannelName.PULSE1] assert duplicate_index is not None - _place_instrument(song, ChannelName.PULSE1, "sample-a", row_index=0) + _place_voice(song, ChannelName.PULSE1, "sample-a", row_index=0) shared_pattern = song.pattern(ChannelName.PULSE1, duplicate_index) assert shared_pattern is not None @@ -248,7 +248,7 @@ def test_clone_avoids_indices_referenced_by_other_frames(self) -> None: def test_editing_a_cloned_pattern_leaves_the_source_untouched(self) -> None: song = _song() - _place_instrument(song, ChannelName.PULSE1, "sample-a", row_index=0) + _place_voice(song, ChannelName.PULSE1, "sample-a", row_index=0) source_index = song.order[0][ChannelName.PULSE1] song.clone_frame(0) @@ -359,20 +359,20 @@ def test_false_when_no_row_references_any_sample(self) -> None: def test_true_when_a_row_references_the_sample(self) -> None: song = _song() - _place_instrument(song, ChannelName.PULSE1, "abc") + _place_voice(song, ChannelName.PULSE1, "abc") assert song.references_voice("abc") is True def test_false_for_a_different_voice_id(self) -> None: song = _song() - _place_instrument(song, ChannelName.PULSE1, "abc") + _place_voice(song, ChannelName.PULSE1, "abc") assert song.references_voice("xyz") is False class TestSongClearSampleReferences: def test_clears_only_rows_referencing_the_target(self) -> None: song = _song() - _place_instrument(song, ChannelName.PULSE1, "abc", row_index=0) - _place_instrument(song, ChannelName.PULSE1, "keep", row_index=1) + _place_voice(song, ChannelName.PULSE1, "abc", row_index=0) + _place_voice(song, ChannelName.PULSE1, "keep", row_index=1) song.clear_voice_references("abc") @@ -383,8 +383,8 @@ def test_clears_only_rows_referencing_the_target(self) -> None: def test_clears_references_across_all_channels(self) -> None: song = _song() - _place_instrument(song, ChannelName.PULSE1, "abc") - _place_instrument(song, ChannelName.TRIANGLE, "abc") + _place_voice(song, ChannelName.PULSE1, "abc") + _place_voice(song, ChannelName.TRIANGLE, "abc") song.clear_voice_references("abc") @@ -392,7 +392,7 @@ def test_clears_references_across_all_channels(self) -> None: def test_leaves_rows_untouched_when_sample_absent(self) -> None: song = _song() - _place_instrument(song, ChannelName.PULSE1, "abc") + _place_voice(song, ChannelName.PULSE1, "abc") song.clear_voice_references("missing") From 9c2ddff8637efc38ccceb596756479913c076e4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 01:02:53 +0200 Subject: [PATCH 099/142] Added: each conversion job reporting its own stages to the run --- .../services/progress.py | 8 +- .../parallelization/processor.py | 14 +- .../reconstructions/converter/converter.py | 24 ++-- .../reconstructions/converter/progress.py | 59 +++++++++ src/sampletones_shared/utils/progress.py | 39 +++++- .../services/test_progress.py | 12 +- .../converter/test_converter.py | 42 +++++- .../converter/test_progress.py | 125 ++++++++++++++++++ .../sampletones_shared/utils/test_progress.py | 88 ++++++++++++ 9 files changed, 385 insertions(+), 26 deletions(-) create mode 100644 src/sampletones_core/reconstructions/converter/progress.py create mode 100644 tests/unit/sampletones_core/reconstructions/converter/test_progress.py create mode 100644 tests/unit/sampletones_shared/utils/test_progress.py diff --git a/src/sampletones_application/services/progress.py b/src/sampletones_application/services/progress.py index bdddb5124..4f98ad1cf 100644 --- a/src/sampletones_application/services/progress.py +++ b/src/sampletones_application/services/progress.py @@ -2,7 +2,7 @@ from sampletones_application.services.result import ServiceProgress from sampletones_core.parallelization import ETAEstimator -from sampletones_shared.utils.progress import report_interval +from sampletones_shared.utils.progress import ReportRate StageT = TypeVar("StageT") @@ -45,8 +45,7 @@ def __init__( self._total = total self._emit = emit self._estimator = ETAEstimator(total=total) if estimates and total > UNMEASURED else None - self._interval = report_interval(total) - self._reported: int = 0 + self._rate = ReportRate(total) def advance(self, completed: int) -> None: """Reports the stage at ``completed`` where a step is due. @@ -54,10 +53,9 @@ def advance(self, completed: int) -> None: Args: completed: What the stage has covered so far, in the unit the stage counts in. """ - if completed != self._total and abs(completed - self._reported) < self._interval: + if not self._rate.take(completed): return - self._reported = completed self._emit( ServiceProgress( completed=completed, diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index 56483ea9a..13e1c5b19 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -112,6 +112,7 @@ def shutdown(self) -> None: self._stop_pool() self._join_thread() + self._release_channel() self._reset_status() def is_running(self) -> bool: @@ -175,12 +176,16 @@ def _start_pump(self) -> None: self._pump.start() def _release_channel(self) -> None: - """Ends the reading and the channel, which the run does once its tasks are all heard from.""" - if self._pump is not None: - self._pump.stop() - self._pump = None + """Ends the reading and the channel, which the run does once its tasks are all heard from. + Every way a run can end reaches here, including one that built its tasks and never started, + since the channel is a process of its own to be reaped whatever became of the run. + """ with self._channel_lock: + if self._pump is not None: + self._pump.stop() + self._pump = None + if self._channel is not None: self._channel.close() self._channel = None @@ -326,6 +331,7 @@ def _join_thread(self) -> None: def _wait_for_cleanup(self) -> None: self._stop_pool() self._join_thread() + self._release_channel() self._reset_status() def _complete_process(self, results: List[T]) -> None: diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py index f2f2f7cd1..61d28a4fe 100644 --- a/src/sampletones_core/reconstructions/converter/converter.py +++ b/src/sampletones_core/reconstructions/converter/converter.py @@ -5,13 +5,13 @@ from sampletones_core.parallelization import TaskProcessor from sampletones_shared.logger import LoggerProtocol from sampletones_shared.logger import logger as default_logger -from sampletones_shared.utils.progress import silent_reporter from ..progress import ReconstructionReporter from ..reconstructor.reconstructor import Reconstructor from .conversion import reconstruct_job from .job import ConversionJob from .plan.protocol import ConversionPlan +from .progress import JobReporter class ReconstructionConverter(TaskProcessor[Path]): @@ -33,8 +33,6 @@ def __init__( self.plan: ConversionPlan = plan self.jobs: List[ConversionJob] = [] - self.current_file: Optional[str] = None - def start(self) -> None: if self.running: self.logger.warning("Reconstruction is already running") @@ -45,7 +43,7 @@ def start(self) -> None: def _create_tasks(self) -> List[Any]: reconstructor = Reconstructor(self.config) self.jobs = self.plan.jobs(self.config) - return [(reconstructor, job, silent_reporter) for job in self.jobs] + return [(reconstructor, job, JobReporter(self._task_reporter(index))) for index, job in enumerate(self.jobs)] def _get_task_function( self, @@ -57,8 +55,18 @@ def _process_results(self, results: List[Path]) -> Tuple[Path, ...]: return tuple(output_path for output_path in results if output_path.exists()) def _notify_progress(self) -> None: - if 0 < self.completed_tasks <= len(self.jobs): - self.current_file = str(self.jobs[self.completed_tasks - 1].sources[0]) - self.current_item = self.current_file - + self.current_item = self._running_source() super()._notify_progress() + + def _running_source(self) -> Optional[str]: + """The recording the job the run has been working on longest is reading. + + Jobs are answered in the order they were handed out, so the first one the run has yet to + count is the earliest still under way — and once every job is counted, the last one is + what the run finished on. + """ + if not self.jobs: + return None + + index = min(self.completed_tasks, len(self.jobs) - 1) + return str(self.jobs[index].sources[0]) diff --git a/src/sampletones_core/reconstructions/converter/progress.py b/src/sampletones_core/reconstructions/converter/progress.py new file mode 100644 index 000000000..4b315bd32 --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/progress.py @@ -0,0 +1,59 @@ +from typing import Optional + +from sampletones_core.parallelization.channel.protocol import StepReporter +from sampletones_core.parallelization.task import TaskStep +from sampletones_core.reconstructions.progress import ReconstructionProgress +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.utils.progress import ReportRate + + +class JobReporter: + """Carries one job's account of itself back to the run that handed it out. + + A reconstruction says where it stands frame by frame, far more often than a line can carry or a + bar can be redrawn, so each stage is reported at the spacing its own length sets. The stage is + weighed into a reading of the whole job before it travels, since the run counts jobs and knows + nothing of the frames one is made of. + + The line answers in both directions, so a job that hears the run has been let go of says so + where it stands and the reconstruction unwinds from that frame. + """ + + def __init__(self, report: StepReporter) -> None: + """Holds the line one job reports on. + + Args: + report: Carries a step to the run, and answers whether that run goes on. + """ + self._report = report + self._stage: Optional[ReconstructionStage] = None + self._rate: Optional[ReportRate] = None + + def __call__(self, progress: ReconstructionProgress) -> bool: + """Files the job's step where one is due, and answers whether the run goes on. + + Args: + progress: Where the reconstruction now stands. + + Returns: + bool: Whether the run still wants the reconstruction this job is building. + """ + if not self._rate_for(progress).take(progress.completed): + return True + + return self._report( + TaskStep( + stage=progress.stage.value, + completed=progress.completed, + total=progress.total, + fraction=progress.fraction, + ) + ) + + def _rate_for(self, progress: ReconstructionProgress) -> ReportRate: + """The spacing the stage is reported at, which each stage sets by its own length.""" + if self._rate is None or self._stage != progress.stage: + self._stage = progress.stage + self._rate = ReportRate(progress.total) + + return self._rate diff --git a/src/sampletones_shared/utils/progress.py b/src/sampletones_shared/utils/progress.py index 3085775ba..d2a49806c 100644 --- a/src/sampletones_shared/utils/progress.py +++ b/src/sampletones_shared/utils/progress.py @@ -1,4 +1,4 @@ -from typing import Final +from typing import Final, Optional PROGRESS_STEPS: Final[int] = 200 @@ -27,3 +27,40 @@ def report_interval(total: int) -> int: int: The count a stage covers between reports, which is at least one. """ return max(1, total // PROGRESS_STEPS) + + +class ReportRate: + """How often a stage measured against a total is worth saying something about. + + What a stage counts moves by its own rules: a render's samples rise toward the song's length, + while a compression's bytes fall as the dictionary earns its keep. A step is therefore a change + of either sign, and a stage landing exactly on its total is always due, so a reading arrives at + the end of every stage however it travelled there. + + The first reading a stage offers is due whatever it says, since a stage announcing where it + begins is news to whoever is watching for it. + """ + + def __init__(self, total: int) -> None: + """Holds the spacing a stage measured against ``total`` is reported at.""" + self._total = total + self._interval = report_interval(total) + self._reported: Optional[int] = None + + def take(self, completed: int) -> bool: + """Takes ``completed`` as the reading to report, and answers whether it is due. + + A reading it takes is what the next one is measured from, so a stage moving in small steps + is reported at the spacing this holds rather than at every step it makes. + + Args: + completed: What the stage has covered so far, in the unit the stage counts in. + + Returns: + bool: Whether the stage has moved far enough to be worth reporting. + """ + if self._reported is not None and completed != self._total and abs(completed - self._reported) < self._interval: + return False + + self._reported = completed + return True diff --git a/tests/unit/sampletones_application/services/test_progress.py b/tests/unit/sampletones_application/services/test_progress.py index 6fe70d597..d44b94f8e 100644 --- a/tests/unit/sampletones_application/services/test_progress.py +++ b/tests/unit/sampletones_application/services/test_progress.py @@ -8,6 +8,7 @@ TOTAL_SAMPLES: Final[int] = PROGRESS_STEPS * 100 STEP: Final[int] = TOTAL_SAMPLES // PROGRESS_STEPS +NOTHING_COVERED: Final[int] = 0 PROGRAM_AREA: Final[int] = 32429 FIRST_SIZE: Final[int] = 12689 SMALLER_SIZE: Final[int] = FIRST_SIZE - PROGRAM_AREA // PROGRESS_STEPS - 1 @@ -25,8 +26,15 @@ def test_a_step_reaches_the_subscriber(self) -> None: def test_a_move_short_of_a_step_is_held_back(self) -> None: reports: List[ServiceProgress[RenderStage]] = [] progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) - progress.advance(STEP - 1) - assert reports == [] + progress.advance(STEP) + progress.advance(STEP + STEP - 1) + assert [report.completed for report in reports] == [STEP] + + def test_a_stage_says_where_it_begins(self) -> None: + reports: List[ServiceProgress[RenderStage]] = [] + progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) + progress.advance(NOTHING_COVERED) + assert [report.completed for report in reports] == [NOTHING_COVERED] def test_a_stage_landing_on_its_total_is_always_reported(self) -> None: reports: List[ServiceProgress[RenderStage]] = [] diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py index 8eb435c76..15fc2c2cd 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -145,12 +145,14 @@ def test_leaves_out_a_job_that_wrote_nothing( class TestReconstructionConverterNotifyProgress: - def test_current_file_names_the_job_that_completed( - self, + """A run names the recording it is working on, which is what a reader watching it wants.""" + + @staticmethod + def _converter_over_two_recordings( config: Config, stems: StemsConfig, tmp_path: Path, - ) -> None: + ) -> ReconstructionConverter: (tmp_path / "a.wav").touch() (tmp_path / "b.wav").touch() converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems)) @@ -158,6 +160,34 @@ def test_current_file_names_the_job_that_completed( converter._create_tasks() converter.total_tasks = len(converter.jobs) - converter.completed_tasks = 1 - converter._notify_progress() - assert converter.current_file == str(converter.jobs[0].sources[0]) + return converter + + def test_the_run_names_the_job_it_is_working_on( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + converter = self._converter_over_two_recordings(config, stems, tmp_path) + try: + converter.completed_tasks = 1 + converter._notify_progress() + + assert converter.current_item == str(converter.jobs[1].sources[0]) + finally: + converter.shutdown() + + def test_a_finished_run_names_the_job_it_ended_on( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + converter = self._converter_over_two_recordings(config, stems, tmp_path) + try: + converter.completed_tasks = len(converter.jobs) + converter._notify_progress() + + assert converter.current_item == str(converter.jobs[-1].sources[0]) + finally: + converter.shutdown() diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_progress.py b/tests/unit/sampletones_core/reconstructions/converter/test_progress.py new file mode 100644 index 000000000..6d77df2c1 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/converter/test_progress.py @@ -0,0 +1,125 @@ +from typing import Final, List + +import pytest + +from sampletones_core.parallelization.task import TaskStep +from sampletones_core.reconstructions.converter.progress import JobReporter +from sampletones_core.reconstructions.progress import ReconstructionProgress, announce +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.utils.progress import PROGRESS_STEPS +from tests.suite.base import BaseTestSuite + +FRAMES: Final[int] = PROGRESS_STEPS * 10 +WHOLE_STAGE: Final[int] = 1 +STAGE_BEGUN: Final[int] = 0 + + +class RecordingLine: + """A line that keeps the steps a job filed, and withdraws the run after a chosen number. + + Stands in for the line a job reports on so a test reads what the job said without a worker + process on the other end of it. + """ + + def __init__(self, withdraw_after: int = 0) -> None: + self.steps: List[TaskStep] = [] + self._withdraw_after = withdraw_after + + def __call__(self, step: TaskStep) -> bool: + self.steps.append(step) + return not self._withdraw_after or len(self.steps) < self._withdraw_after + + +class TestWhatAJobFiles(BaseTestSuite): + """A job's step names the stage it is in and how far the whole job has come.""" + + def test_a_step_carries_the_stage_and_its_counts(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=0, total=FRAMES)) + + assert line.steps == [ + TaskStep( + stage=ReconstructionStage.MATCHING.value, + completed=0, + total=FRAMES, + fraction=ReconstructionStage.MATCHING.offset, + ) + ] + + def test_the_fraction_weighs_the_stage_into_the_whole_job(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=FRAMES, total=FRAMES)) + + filed = line.steps[-1] + assert filed.fraction == pytest.approx(ReconstructionStage.MATCHING.offset + ReconstructionStage.MATCHING.share) + + def test_every_stage_is_heard_from_as_it_begins(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + for stage in ReconstructionStage: + reporter(ReconstructionProgress(stage=stage, completed=STAGE_BEGUN, total=WHOLE_STAGE)) + + assert [step.stage for step in line.steps] == [stage.value for stage in ReconstructionStage] + + +class TestHowOftenAJobIsHeardFrom(BaseTestSuite): + """A frame-by-frame account is carried at a rate a line and a bar can keep up with.""" + + def test_a_long_stage_files_about_as_many_steps_as_a_bar_has(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + for frame in range(FRAMES + 1): + reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=frame, total=FRAMES)) + + assert len(line.steps) == pytest.approx(PROGRESS_STEPS + 1, abs=1) + + def test_a_stage_is_heard_from_where_it_lands(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + for frame in range(FRAMES + 1): + reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=frame, total=FRAMES)) + + assert line.steps[-1].completed == FRAMES + + def test_each_stage_is_spaced_by_its_own_length(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=0, total=FRAMES)) + reporter(ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=0, total=WHOLE_STAGE)) + reporter(ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=WHOLE_STAGE, total=WHOLE_STAGE)) + + assert [step.stage for step in line.steps] == [ + ReconstructionStage.MATCHING.value, + ReconstructionStage.DECODING.value, + ReconstructionStage.DECODING.value, + ] + + +class TestAWithdrawalReachingTheJob(BaseTestSuite): + """A job told the run has been let go of unwinds where it stands.""" + + def test_a_withdrawn_job_unwinds_at_its_next_frame(self) -> None: + line = RecordingLine(withdraw_after=1) + reporter = JobReporter(line) + + with pytest.raises(OperationCancelled): + for frame in range(FRAMES + 1): + announce(reporter, ReconstructionStage.MATCHING, frame, FRAMES) + + def test_a_job_the_run_still_wants_carries_on(self) -> None: + line = RecordingLine() + reporter = JobReporter(line) + + for frame in range(FRAMES + 1): + announce(reporter, ReconstructionStage.MATCHING, frame, FRAMES) + + assert line.steps diff --git a/tests/unit/sampletones_shared/utils/test_progress.py b/tests/unit/sampletones_shared/utils/test_progress.py new file mode 100644 index 000000000..c891154d4 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_progress.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import pytest + +from sampletones_shared.utils.progress import ( + PROGRESS_STEPS, + ReportRate, + report_interval, + silent_reporter, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +LONG_STAGE: Final[int] = PROGRESS_STEPS * 100 +SHORT_STAGE: Final[int] = 3 +UNMEASURED_STAGE: Final[int] = 0 +ONE_STEP: Final[int] = 1 + + +class TestHowOftenAStageIsWorthReporting(BaseTestSuite): + """The spacing between reports follows the length of the stage, never the size of its steps.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + total: int + + @property + def label(self) -> str: + return f"a_stage_counting_to_{self.total}" + + test_cases = ( + TestCase(total=UNMEASURED_STAGE, expected=ONE_STEP), + TestCase(total=SHORT_STAGE, expected=ONE_STEP), + TestCase(total=PROGRESS_STEPS, expected=ONE_STEP), + TestCase(total=LONG_STAGE, expected=LONG_STAGE // PROGRESS_STEPS), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_stage_is_reported_over_a_fixed_number_of_steps(self, test_case: TestCase) -> None: + assert report_interval(test_case.total) == test_case.expected + + def test_a_stage_shorter_than_the_steps_reports_every_count(self) -> None: + rate = ReportRate(SHORT_STAGE) + + assert [rate.take(completed) for completed in range(SHORT_STAGE + 1)] == [True] * (SHORT_STAGE + 1) + + +class TestTakingAReading(BaseTestSuite): + """A rate takes the readings that are due and lets the rest of a stage's steps pass.""" + + def test_a_long_stage_is_reported_about_as_often_as_it_has_steps(self) -> None: + rate = ReportRate(LONG_STAGE) + + taken = [completed for completed in range(LONG_STAGE + 1) if rate.take(completed)] + + assert len(taken) == pytest.approx(PROGRESS_STEPS + 1, abs=ONE_STEP) + + def test_a_stage_landing_on_its_total_is_always_reported(self) -> None: + rate = ReportRate(LONG_STAGE) + rate.take(0) + + assert rate.take(LONG_STAGE) + + def test_a_count_falling_toward_its_total_is_a_step_the_same_way(self) -> None: + rate = ReportRate(LONG_STAGE) + rate.take(LONG_STAGE) + + assert rate.take(LONG_STAGE // 2) + + def test_a_reading_is_measured_from_the_one_before_it(self) -> None: + rate = ReportRate(LONG_STAGE) + interval = report_interval(LONG_STAGE) + rate.take(0) + + assert not rate.take(interval - ONE_STEP) + assert rate.take(interval) + + +class TestACallerWatchingNothing(BaseTestSuite): + """A run reported to nobody still asks whether it goes on, and hears that it does.""" + + def test_the_silent_reporter_lets_every_run_through(self) -> None: + readings: Tuple[object, ...] = ("a stage", 1, None) + answers: List[bool] = [silent_reporter(reading) for reading in readings] + + assert answers == [True] * len(readings) From 8cec99b40bc3c6f7d8ab6bc9adb15858e99710c0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 01:26:50 +0200 Subject: [PATCH 100/142] Added: a colour for each kind of voice --- .../layout/tabs/sequencer/colors/tracker.py | 10 ++-- .../ui/panels/sequencer/voices/panel.py | 16 +++++- .../layout/tabs/sequencer/colors.yaml | 3 +- src/sampletones_config/palettes/dark.yaml | 3 ++ src/sampletones_config/palettes/light.yaml | 3 ++ src/sampletones_config/palettes/studio.yaml | 3 ++ .../sequencer/voices/test_kind_color.py | 53 +++++++++++++++++++ .../utils/palette/test_catalog.py | 11 ++++ 8 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py index 548db8fb9..5f5595b34 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py @@ -7,15 +7,19 @@ class TrackerColors(BaseModel, extra="forbid", frozen=True): """The semantic text colours shared across every tracker view. One palette feeds the pattern grid, the order table, and the history detail so a - concept keeps its colour everywhere: ``voice`` (the slot naming a voice, yellow like - ``sample``), ``transpose``, ``volume``, ``sample``, the ``frame`` and ``row`` - indices, and the ``order`` entries. Defining them once keeps every panel in step. + concept keeps its colour everywhere. Three of the tokens read a voice slot: ``voice`` + is what the slot wears while it names nothing, and ``sample`` and ``instrument`` are + the two kinds a named voice can be, so the slot's colour reports what it holds. + ``transpose`` and ``volume`` carry the other two slots, and the ``frame`` and ``row`` + indices and the ``order`` entries carry the grids around them. Defining them once + keeps every panel in step. """ voice: WrittenColor transpose: WrittenColor volume: WrittenColor sample: WrittenColor + instrument: WrittenColor frame: WrittenColor row: WrittenColor order: WrittenColor diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index ebaf08674..c6892f76c 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -32,6 +32,7 @@ KeyEvent, KeyRouter, ) +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -273,13 +274,18 @@ def _build_kind_cell( row_id: int | str, entry: VoiceEntryViewModel, ) -> None: - """Marks which kind the row carries, so a converted voice reads apart from a written one.""" + """Marks which kind the row carries, so a converted voice reads apart from a written one. + + The glyph names the kind and its colour repeats it, which is the same pair the tracker's + voice slot wears — so a row and the cells naming it read as one thing across the two panels. + """ kind_cell = dpg.add_table_cell(parent=row_id) mark = dpg.add_text( parent=kind_cell, default_value=self._kind_glyph(entry.kind), ) FontRegistry.bind_to_item(mark, Font.ICON) + dpg_set_palette_color(mark, self._kind_color(entry.kind)) show_tooltip(mark, self._kind_tooltip(entry.kind)) def _kind_glyph(self, kind: VoiceKind) -> str: @@ -289,6 +295,14 @@ def _kind_glyph(self, kind: VoiceKind) -> str: case VoiceKind.INSTRUMENT: return self._glyphs.voices.instrument + def _kind_color(self, kind: VoiceKind) -> BaseColor: + text = self._layout.colors.text + match kind: + case VoiceKind.SAMPLE: + return text.sample + case VoiceKind.INSTRUMENT: + return text.instrument + def _kind_tooltip(self, kind: VoiceKind) -> str: match kind: case VoiceKind.SAMPLE: diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index 851461f25..c71944b98 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -32,7 +32,8 @@ text: voice: .tracker_reference transpose: .tracker_transpose volume: .tracker_volume - sample: .tracker_reference + sample: .voice_sample + instrument: .voice_instrument frame: .tracker_frame row: .tracker_row order: .tracker_order diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index e07fa2dfd..0befdd5cb 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -194,3 +194,6 @@ colors: tracker_frame: "#4fa6ffff" tracker_row: "#9a9aa2ff" tracker_order: "#c4d0e0ff" + + voice_sample: "#e0c860ff" + voice_instrument: "#f476d8ff" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 6b3a14ba5..d637fb3d6 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -194,3 +194,6 @@ colors: tracker_frame: "#0a5aa8ff" tracker_row: "#5f6773ff" tracker_order: "#28303dff" + + voice_sample: "#7a5200ff" + voice_instrument: "#9a137cff" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 14e0aeda5..09adc2386 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -194,3 +194,6 @@ colors: tracker_frame: "#22ccffff" tracker_row: "#a0a0a0ff" tracker_order: "#c8d0e0ff" + + voice_sample: "#e0c860ff" + voice_instrument: "#ff70dfff" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py new file mode 100644 index 000000000..6275210ea --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py @@ -0,0 +1,53 @@ +import pytest + +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.layout.tabs.sequencer import SequencerLayout +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.sequencer.voices import VoiceKind + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout: + return layout_config.tabs.sequencer + + +def _panel(sequencer_layout: SequencerLayout) -> GUISequencerVoicesPanel: + """Builds a panel without its DearPyGui-dependent constructor. + + The kind's colour is read from the layout alone, so a running GUI context is unnecessary here. + """ + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) + panel._layout = sequencer_layout + return panel + + +class TestWhatColourAKindWears: + def test_a_sample_wears_the_sample_colour(self, sequencer_layout: SequencerLayout) -> None: + panel = _panel(sequencer_layout) + + assert panel._kind_color(VoiceKind.SAMPLE) is sequencer_layout.colors.text.sample + + def test_an_instrument_wears_the_instrument_colour(self, sequencer_layout: SequencerLayout) -> None: + panel = _panel(sequencer_layout) + + assert panel._kind_color(VoiceKind.INSTRUMENT) is sequencer_layout.colors.text.instrument + + def test_the_two_kinds_are_told_apart(self, sequencer_layout: SequencerLayout) -> None: + """The colour carries the kind, so a list of one hue would say nothing the glyph does not.""" + panel = _panel(sequencer_layout) + + assert panel._kind_color(VoiceKind.SAMPLE).rgba != panel._kind_color(VoiceKind.INSTRUMENT).rgba diff --git a/tests/unit/sampletones_application/utils/palette/test_catalog.py b/tests/unit/sampletones_application/utils/palette/test_catalog.py index 4e58edbc0..b9f0f090d 100644 --- a/tests/unit/sampletones_application/utils/palette/test_catalog.py +++ b/tests/unit/sampletones_application/utils/palette/test_catalog.py @@ -77,3 +77,14 @@ def test_every_palette_declares_the_same_tokens(self, catalog: PaletteCatalog) - expected = set(catalog.default.colors) for name, palette in catalog.palettes.items(): assert set(palette.colors) == expected, f"Palette {name!r} token set differs from {DEFAULT_PALETTE_NAME!r}" + + def test_every_palette_tells_the_two_voice_kinds_apart(self, catalog: PaletteCatalog) -> None: + """A recording and a hand-written voice wear their colours in the tracker and the list alike. + + Each palette states the pair for itself, so a colour that separates on one ground can go + dark and saturated on another and the kinds stay apart in all of them. + """ + for name, palette in catalog.palettes.items(): + assert ( + palette.colors["voice_sample"] != palette.colors["voice_instrument"] + ), f"Palette {name!r} gives both voice kinds one colour" From f178da656d30fbefcb812e7f483c7dd5cf86b7b6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 01:56:08 +0200 Subject: [PATCH 101/142] Added: the voice kind to the history detail --- .../coordinators/tabs/sequencer.py | 5 +- .../logic/sequencer/history_detail.py | 87 ++++++++++++++----- .../logic/sequencer/voices.py | 17 ++++ .../ui/panels/sequencer/history.py | 4 +- .../view_model/shared/history.py | 7 +- .../logic/sequencer/test_history_detail.py | 82 ++++++++++++++--- .../logic/sequencer/test_voices.py | 22 +++++ .../sequencer/test_history_role_color.py | 74 ++++++++++++++++ 8 files changed, 257 insertions(+), 41 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index e6fb802ad..e4f0d48d2 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1478,10 +1478,7 @@ def _submit_rename(self, voice_id: str, name: str) -> None: """Applies an inline rename, ignoring a blank name so the sample keeps its current one.""" stripped = name.strip() if stripped: - detail = self._history_detail.rename_voice( - self._sequencer_voices_logic.voice_name(voice_id), - stripped, - ) + detail = self._history_detail.rename_voice(voice_id, stripped) with self._history.transaction( HistoryAction.RENAME_SAMPLE, detail=detail, diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index ecae5e82b..9adb84ba7 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -9,6 +9,7 @@ TrackerRegion, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_application.view_model.shared.history import ( HistoryDetail, HistoryDetailRole, @@ -40,6 +41,10 @@ SubColumn.TRANSPOSE: HistoryDetailRole.TRANSPOSE, SubColumn.VOLUME: HistoryDetailRole.VOLUME, } +_KIND_ROLES: Final[Dict[VoiceKind, HistoryDetailRole]] = { + VoiceKind.SAMPLE: HistoryDetailRole.SAMPLE, + VoiceKind.INSTRUMENT: HistoryDetailRole.INSTRUMENT, +} _FEATURE_LETTERS: Final[Dict[FeatureKey, str]] = { FeatureKey.INITIAL_PITCH: "i", FeatureKey.VOLUME: "v", @@ -63,6 +68,18 @@ def _span(first: int, last: int) -> str: return f"{display_id(first)}{_RANGE}{display_id(last)}" +def _kind_role(kind: Optional[VoiceKind]) -> HistoryDetailRole: + """The role a voice reads under, so its line wears the colour of the kind it is about. + + A voice the pool has stopped holding keeps the plain voice role, the same one the tracker's + voice slot wears while it names nothing. + """ + if kind is None: + return HistoryDetailRole.VOICE + + return _KIND_ROLES[kind] + + class SequencerHistoryDetail: """Builds the coloured detail line for each undoable sequencer gesture. @@ -74,7 +91,9 @@ class SequencerHistoryDetail: concatenated when a sample-column gesture spans several channels. Language-managed words — the loop on/off states — are emitted as :class:`HistoryDetailWordSegment` keys and translated when the history view is - built, keeping committed entries language-independent. + built, keeping committed entries language-independent. A gesture on the voice + pool names its voice in the colour of the kind that voice is, so a recording + and a hand-written one read apart down the list of entries. """ def __init__( @@ -97,7 +116,7 @@ def edit_row( segments = list(self._location(row_index, channel, affected)) if voice_id is not None: segments.append(self._arrow()) - segments.append(self._sample(voice_id)) + segments.append(self._voice(voice_id)) if transpose is not None: segments.append(self._subcolumn(SubColumn.TRANSPOSE)) @@ -242,15 +261,15 @@ def set_master_entry( ) def add_sample(self, name: str) -> Segments: - return (self._name(name),) + return (self._name(name, VoiceKind.SAMPLE),) def add_instrument(self, name: str) -> Segments: - return (self._name(name),) + return (self._name(name, VoiceKind.INSTRUMENT),) def remove_voice(self, voice_id: str) -> Segments: return ( - self._sample(voice_id, colon=True), - self._name(self._samples_logic.voice_name(voice_id)), + self._voice(voice_id, colon=True), + self._voice_name(voice_id), ) def replace_sample(self, voice_id: str, name: str) -> Segments: @@ -260,32 +279,41 @@ def replace_sample(self, voice_id: str, name: str) -> Segments: caller builds this detail while the sample still holds the reconstruction being replaced. """ return ( - self._sample(voice_id, colon=True), - self._name(self._samples_logic.voice_name(voice_id)), + self._voice(voice_id, colon=True), + self._voice_name(voice_id), self._arrow(), - self._name(name), + self._name(name, VoiceKind.SAMPLE), ) - def rename_voice(self, old_name: str, new_name: str) -> Segments: - return (self._name(old_name), self._arrow(), self._name(new_name)) + def rename_voice(self, voice_id: str, name: str) -> Segments: + """Describes a rename as the name the voice carries and the one it takes. + + Both read in the voice's own kind, which the caller builds this detail under while the + pool still holds the name being left behind. + """ + return ( + self._voice_name(voice_id), + self._arrow(), + self._name(name, self._samples_logic.voice_kind(voice_id)), + ) def move_voice(self, voice_id: str, to_index: int) -> Segments: return ( - self._sample(voice_id), + self._voice(voice_id), self._arrow(), self._value(display_id(to_index)), ) def duplicate_voice(self, voice_id: str) -> Segments: return ( - self._sample(voice_id, colon=True), - self._name(self._samples_logic.voice_name(voice_id)), + self._voice(voice_id, colon=True), + self._voice_name(voice_id), ) def set_sample_loop(self, voice_id: str, loop: bool) -> Segments: word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF return ( - self._sample(voice_id, colon=True), + self._voice(voice_id, colon=True), HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE), ) @@ -302,7 +330,7 @@ def edit_reconstruction( tab plots it with — mirroring the tracker rows. """ return ( - self._sample(voice_id, colon=True), + self._voice(voice_id, colon=True), self._channel([channel_name]), self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]), ) @@ -310,8 +338,8 @@ def edit_reconstruction( def remove_stem(self, voice_id: str, stem_name: str) -> Segments: """Describes a recording taken out of a sample's reconstruction: its position and name.""" return ( - self._sample(voice_id, colon=True), - self._name(stem_name), + self._voice(voice_id, colon=True), + self._name(stem_name, self._samples_logic.voice_kind(voice_id)), ) def value(self, number: int) -> Segments: @@ -428,13 +456,21 @@ def _subcolumn(self, subcolumn: SubColumn) -> HistoryDetailSegment: _SUBCOLUMN_ROLES[subcolumn], ) - def _name(self, text: str) -> HistoryDetailSegment: - return HistoryDetailSegment( - text=text, - role=HistoryDetailRole.NAME, + def _name( + self, + text: str, + kind: Optional[VoiceKind], + ) -> HistoryDetailSegment: + return HistoryDetailSegment(text=text, role=_kind_role(kind)) + + def _voice_name(self, voice_id: str) -> HistoryDetailSegment: + """The name a voice in the pool carries, read in the colour of the kind it is.""" + return self._name( + self._samples_logic.voice_name(voice_id), + self._samples_logic.voice_kind(voice_id), ) - def _sample( + def _voice( self, voice_id: str, *, @@ -442,7 +478,10 @@ def _sample( ) -> HistoryDetailSegment: position = self._samples_logic.voice_position(voice_id) text = f"{position}:" if colon else position - return HistoryDetailSegment(text=text, role=HistoryDetailRole.SAMPLE) + return HistoryDetailSegment( + text=text, + role=_kind_role(self._samples_logic.voice_kind(voice_id)), + ) def _arrow(self) -> HistoryDetailSegment: return HistoryDetailSegment( diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 9373483ca..a87987d1a 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -14,6 +14,7 @@ from sampletones_application.view_model.sequencer.voices import ( SequencerVoicesViewModel, VoiceEntryViewModel, + VoiceKind, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.audio import AudioDeviceManager @@ -235,6 +236,22 @@ def voice_position(self, voice_id: str) -> str: voice_id=voice_id, ) + def voice_kind(self, voice_id: str) -> Optional[VoiceKind]: + """Which of the two kinds a voice in the pool is, telling a recording from a written one. + + Args: + voice_id: The voice being asked about. + + Returns: + Optional[VoiceKind]: The kind the pool holds it as, or ``None`` while the pool holds + no such voice. + """ + voice = self._controller.project.voices.get(voice_id) + if voice is None: + return None + + return voice_kind(voice) + def remove_voice(self, voice_id: str) -> None: self._controller.remove_voice(voice_id) diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index e165270c7..0c523400a 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -343,8 +343,10 @@ def _role_color(self, role: HistoryDetailRole) -> BaseColor: return text.volume case HistoryDetailRole.VALUE: return roles.value - case HistoryDetailRole.SAMPLE | HistoryDetailRole.NAME: + case HistoryDetailRole.SAMPLE: return text.sample + case HistoryDetailRole.INSTRUMENT: + return text.instrument case HistoryDetailRole.FEATURE_VOLUME: return self._feature_colors.volume case HistoryDetailRole.FEATURE_ARPEGGIO: diff --git a/src/sampletones_application/view_model/shared/history.py b/src/sampletones_application/view_model/shared/history.py index c74b9cacf..4668283f4 100644 --- a/src/sampletones_application/view_model/shared/history.py +++ b/src/sampletones_application/view_model/shared/history.py @@ -9,7 +9,10 @@ class HistoryDetailRole(StrEnum): A role is a semantic tag chosen by the logic layer; the panel maps it to a concrete colour, keeping the detail-producing code free of any visual - concern. + concern. Three of them read a voice: ``SAMPLE`` and ``INSTRUMENT`` name the + kind a line is about, so its position and its name wear that kind's colour, + and ``VOICE`` carries a voice reference the kind says nothing about — the + tracker's voice slot, and a voice the pool has stopped holding. """ FRAME = "frame" @@ -20,7 +23,7 @@ class HistoryDetailRole(StrEnum): VOLUME = "volume" VALUE = "value" SAMPLE = "sample" - NAME = "name" + INSTRUMENT = "instrument" FEATURE_VOLUME = "feature_volume" FEATURE_ARPEGGIO = "feature_arpeggio" FEATURE_PITCH = "feature_pitch" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index bef0d2fa7..edfe24278 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -26,7 +26,9 @@ HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ChannelName, FeatureKey -from tests.suite.sequencer import sample_reconstruction +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.utils.display import display_id +from tests.suite.sequencer import UNKNOWN_SAMPLE_ID, sample_reconstruction Pair = Tuple[str, HistoryDetailRole] @@ -305,11 +307,11 @@ def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: ] -class TestSampleDetails: +class TestVoiceDetails: def test_add_sample_shows_the_name(self) -> None: formatter = _formatter(_controller()) - assert _pairs(formatter.add_sample("Bass")) == [("Bass", HistoryDetailRole.NAME)] + assert _pairs(formatter.add_sample("Bass")) == [("Bass", HistoryDetailRole.SAMPLE)] def test_remove_sample_shows_position_and_name(self) -> None: controller = _controller() @@ -318,7 +320,7 @@ def test_remove_sample_shows_position_and_name(self) -> None: assert _pairs(formatter.remove_voice(sample.id)) == [ ("00:", HistoryDetailRole.SAMPLE), - ("Bass", HistoryDetailRole.NAME), + ("Bass", HistoryDetailRole.SAMPLE), ] def test_replace_sample_shows_position_and_both_names(self) -> None: @@ -328,18 +330,20 @@ def test_replace_sample_shows_position_and_both_names(self) -> None: assert _pairs(formatter.replace_sample(sample.id, "Kick")) == [ ("00:", HistoryDetailRole.SAMPLE), - ("Bass", HistoryDetailRole.NAME), + ("Bass", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), - ("Kick", HistoryDetailRole.NAME), + ("Kick", HistoryDetailRole.SAMPLE), ] def test_rename_sample_shows_old_and_new(self) -> None: - formatter = _formatter(_controller()) + controller = _controller() + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") + formatter = _formatter(controller) - assert _pairs(formatter.rename_voice("Bass", "Kick")) == [ - ("Bass", HistoryDetailRole.NAME), + assert _pairs(formatter.rename_voice(sample.id, "Kick")) == [ + ("Bass", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), - ("Kick", HistoryDetailRole.NAME), + ("Kick", HistoryDetailRole.SAMPLE), ] def test_move_sample_shows_source_position_and_destination(self) -> None: @@ -377,6 +381,64 @@ def test_value_wraps_a_number(self) -> None: assert _pairs(formatter.value(150)) == [("150", HistoryDetailRole.VALUE)] +class TestWhichKindADetailNames: + """A line about the pool reads in the colour of the kind of voice it is about.""" + + def test_a_written_voice_is_added_under_its_own_kind(self) -> None: + formatter = _formatter(_controller()) + + assert _pairs(formatter.add_instrument("Pad")) == [("Pad", HistoryDetailRole.INSTRUMENT)] + + def test_a_written_voice_is_removed_under_its_own_kind(self) -> None: + controller = _controller() + controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + assert _pairs(formatter.remove_voice(instrument.id)) == [ + ("01:", HistoryDetailRole.INSTRUMENT), + ("Pad", HistoryDetailRole.INSTRUMENT), + ] + + def test_a_written_voice_is_renamed_under_its_own_kind(self) -> None: + controller = _controller() + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + assert _pairs(formatter.rename_voice(instrument.id, "Strings")) == [ + ("Pad", HistoryDetailRole.INSTRUMENT), + (">", HistoryDetailRole.SEPARATOR), + ("Strings", HistoryDetailRole.INSTRUMENT), + ] + + def test_the_kinds_read_apart_where_one_gesture_serves_both(self) -> None: + """Moving is one gesture over the whole pool, so its line says which kind moved.""" + controller = _controller() + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + assert formatter.move_voice(sample.id, 1)[0].role is HistoryDetailRole.SAMPLE + assert formatter.move_voice(instrument.id, 0)[0].role is HistoryDetailRole.INSTRUMENT + + def test_a_placed_voice_names_its_kind_in_the_tracker(self) -> None: + controller = _controller() + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + segments = formatter.edit_row(0, ChannelName.PULSE2, instrument.id, None, None) + + assert _pairs(segments)[-1] == ("00", HistoryDetailRole.INSTRUMENT) + + def test_a_voice_the_pool_no_longer_holds_keeps_the_plain_role(self) -> None: + """An id nothing answers for states no kind, so it reads as the voice slot itself does.""" + formatter = _formatter(_controller()) + + segments = formatter.edit_row(0, ChannelName.PULSE1, UNKNOWN_SAMPLE_ID, None, None) + + assert _pairs(segments)[-1] == (display_id(None), HistoryDetailRole.VOICE) + + class TestReconstructionDetails: def test_edit_reconstruction_names_position_channel_and_feature(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 0d995046e..b61c65b1a 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -86,6 +86,28 @@ def test_returns_the_voice_name( assert logic.voice_name(sample.id) == "lead" +class TestWhichKindAVoiceIs: + def test_a_recording_answers_as_a_sample( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + controller, logic = _logic() + sample = controller.add_sample(reconstruction_factory(), name="lead") + + assert logic.voice_kind(sample.id) is VoiceKind.SAMPLE + + def test_a_written_voice_answers_as_an_instrument(self) -> None: + controller, logic = _logic() + instrument = controller.add_instrument(Instrument(name="pad")) + + assert logic.voice_kind(instrument.id) is VoiceKind.INSTRUMENT + + def test_a_voice_the_pool_does_not_hold_answers_with_nothing(self) -> None: + _, logic = _logic() + + assert logic.voice_kind("a-voice-no-project-holds") is None + + class TestIsSampleUsed: def test_false_for_unreferenced_sample( self, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py new file mode 100644 index 000000000..e397871b8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py @@ -0,0 +1,74 @@ +import pytest + +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.shared.history import HistoryDetailRole + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def panel(layout_config: LayoutConfig) -> GUISequencerHistoryPanel: + """Builds a panel without its DearPyGui-dependent constructor. + + A role's colour is read from the layout alone, so a running GUI context is unnecessary here. + """ + instance = GUISequencerHistoryPanel.__new__(GUISequencerHistoryPanel) + instance._layout = layout_config.tabs.sequencer + instance._feature_colors = layout_config.general.colors.features + return instance + + +class TestWhatColourAVoiceRoleWears: + """A history line names its voice in the colour of the kind that voice is.""" + + def test_a_sample_wears_the_sample_colour( + self, + panel: GUISequencerHistoryPanel, + layout_config: LayoutConfig, + ) -> None: + text = layout_config.tabs.sequencer.colors.text + + assert panel._role_color(HistoryDetailRole.SAMPLE) is text.sample + + def test_an_instrument_wears_the_instrument_colour( + self, + panel: GUISequencerHistoryPanel, + layout_config: LayoutConfig, + ) -> None: + text = layout_config.tabs.sequencer.colors.text + + assert panel._role_color(HistoryDetailRole.INSTRUMENT) is text.instrument + + def test_a_voice_of_no_stated_kind_wears_the_slot_colour( + self, + panel: GUISequencerHistoryPanel, + layout_config: LayoutConfig, + ) -> None: + """The tracker's voice slot and a voice the pool dropped read as one thing.""" + text = layout_config.tabs.sequencer.colors.text + + assert panel._role_color(HistoryDetailRole.VOICE) is text.voice + + def test_the_two_kinds_are_told_apart(self, panel: GUISequencerHistoryPanel) -> None: + sample = panel._role_color(HistoryDetailRole.SAMPLE) + instrument = panel._role_color(HistoryDetailRole.INSTRUMENT) + + assert sample.rgba != instrument.rgba + + def test_every_role_answers_with_a_colour(self, panel: GUISequencerHistoryPanel) -> None: + """The panel paints whatever the logic tags, so each role states what it wears.""" + for role in HistoryDetailRole: + assert panel._role_color(role) is not None From 221ce21174832a0eea3aaddb8f3c4c1cd9b8fd79 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 03:31:14 +0200 Subject: [PATCH 102/142] Added: a conversion's bar reading the reconstruction under way --- docs/concepts/reconstruction.md | 7 + docs/development/architecture.md | 3 +- docs/development/packages.md | 3 +- docs/development/progress.md | 179 ++++++++++++++++++ docs/index.md | 1 + .../coordinators/tabs/main.py | 2 +- .../coordinators/tabs/sequencer.py | 2 +- .../logic/export/logic.py | 8 +- .../logic/main/converter.py | 76 +++++--- .../logic/render/logic.py | 2 +- .../services/__init__.py | 14 +- .../services/conversion/__init__.py | 9 + .../services/conversion/result.py | 54 ++++++ .../{conversion.py => conversion/service.py} | 57 +++++- .../services/regeneration/__init__.py | 8 + .../services/regeneration/result.py | 30 +++ .../service.py} | 21 +- .../services/result.py | 41 ++-- .../services/retune/__init__.py | 2 +- .../services/retune/{retune.py => service.py} | 0 .../song_player/{player.py => service.py} | 0 .../boundaries/general.yaml | 5 +- src/sampletones_config/lang/en.yaml | 5 + .../parallelization/processor.py | 1 + .../parallelization/progress.py | 161 ++++++++-------- .../reconstructions/progress.py | 14 +- .../reconstructor/reconstructor.py | 7 +- src/sampletones_core/reconstructions/stage.py | 48 +++-- .../test_conversion_progress.py | 105 ++++++++++ .../services/test_conversion.py | 4 +- .../services/test_regeneration.py | 2 +- tests/suite/conversion.py | 75 ++++++++ tests/suite/parallelization.py | 16 +- tests/suite/release.py | 25 +++ .../coordinators/test_reconstruction.py | 2 +- .../logic/main/test_converter.py | 99 +++++++++- .../services/song_player/test_song_player.py | 10 +- .../services/test_conversion.py | 7 +- .../services/test_regeneration.py | 8 +- .../reconstructions/test_progress.py | 52 +++-- 40 files changed, 931 insertions(+), 234 deletions(-) create mode 100644 docs/development/progress.md create mode 100644 src/sampletones_application/services/conversion/__init__.py create mode 100644 src/sampletones_application/services/conversion/result.py rename src/sampletones_application/services/{conversion.py => conversion/service.py} (66%) create mode 100644 src/sampletones_application/services/regeneration/__init__.py create mode 100644 src/sampletones_application/services/regeneration/result.py rename src/sampletones_application/services/{regeneration.py => regeneration/service.py} (89%) rename src/sampletones_application/services/retune/{retune.py => service.py} (100%) rename src/sampletones_application/services/song_player/{player.py => service.py} (100%) create mode 100644 tests/integration/reconstruction/test_conversion_progress.py create mode 100644 tests/suite/conversion.py create mode 100644 tests/suite/release.py diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 7ca07a486..335066d87 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -70,6 +70,13 @@ input through a fixed sequence of stages: Stages 3–6 are where the algorithms described below live; the rest is preparation and playback. +A run says which of these it is in as it passes through them, so a reader watching a +conversion sees it move rather than waiting for the file. `ReconstructionStage` gathers +the eight steps into the four a reader is told apart — loading, matching, decoding, +rendering — and states the share each holds of the whole run; +[`progress.md`](../development/progress.md) describes how that account reaches the +screen from the worker process it is made in. + ## 3. Representing a frame ### 3.1 The candidate catalogue (library) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 7fd45ff67..e0f00b432 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -2,7 +2,7 @@ This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs. -Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, the YAML configuration package has `docs/development/config-organization.md`, and the packages the repository divides into have `docs/development/packages.md`. +Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, the YAML configuration package has `docs/development/config-organization.md`, how a long operation says how far it has come has `docs/development/progress.md`, and the packages the repository divides into have `docs/development/packages.md`. --- @@ -303,6 +303,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m - Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)`, and `_emit(result)`. - `_emit` always posts the result to `CallbackQueue`; it never calls a handler directly from the background thread. - Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCancelled`, enabling exhaustive `match` handling by subscribers. +- A service is one subpackage holding `service.py` and `result.py`, so its implementation and the contract its subscribers type against are reached separately; the generic contracts every service reports through are `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws, counting the item under way for the part of it that is done — see `docs/development/progress.md`. - Services hold no references to panels, view models, or logic objects. **May import:** `sampletones_core`, `sampletones_shared`, `utils/callbacks/`. diff --git a/docs/development/packages.md b/docs/development/packages.md index 260751b3b..bf8cba574 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -7,7 +7,8 @@ tables in the form the import-boundary check runs on every commit, and a diverge document and that configuration is itself a defect. The layering of `sampletones_application` has its own document, -[`architecture.md`](architecture.md), which the same check enforces. +[`architecture.md`](architecture.md), which the same check enforces. How a long operation reports how far it +has come — inside one process and across the pool's workers — is [`progress.md`](progress.md). --- diff --git a/docs/development/progress.md b/docs/development/progress.md new file mode 100644 index 000000000..15e737423 --- /dev/null +++ b/docs/development/progress.md @@ -0,0 +1,179 @@ +# Progress + +This document governs how a long operation says how far it has come, and how that account reaches +the reader watching it. Consult it when adding an operation that takes long enough to be watched, +when changing what one reports, or when the report has to cross a process boundary. + +The subsystem spans three packages: the operations that report live in `sampletones_core` and +`sampletones_player`, the line a report crosses between processes is +`sampletones_core/parallelization/channel/`, and the layer that draws it is +`sampletones_application/services/` and `logic/`. Layering between those packages is +[`packages.md`](packages.md); the application's own layers are +[`architecture.md`](architecture.md). + +--- + +## Principles + +### 1. A run reports where it stands and hears whether it is still wanted + +Every long operation reports through one shape: a callable taking what the run has reached and +answering whether the run goes on. + +```python +ExportReporter = Callable[[ExportProgress], bool] +``` + +Each domain names its own progress type — `ExportProgress`, `WalkProgress`, `CodecProgress`, +`ReconstructionProgress` — and its own `announce`, which builds that type, offers it, and raises +`OperationCancelled` where the answer is no. One reporter therefore carries both directions: an +operation is watched and withdrawn over the same line, and a caller that wants neither passes +`silent_reporter` (`sampletones_shared/utils/progress.py`) and hears the run through to its end. + +### 2. A stage names the unit its counts are in + +A run passes through stages counting in units of their own — a song's ticks, a dictionary's bytes, +a recording's frames, a batch's files. A report therefore names its stage, and what the counts mean +is read from that name. `ExportStage` and `ReconstructionStage` are the two vocabularies today. + +Where the stages differ in what they cost, the enum states the share each holds of the whole run +(`ReconstructionStage.share`), so one reading spans a run that changes what it is counting several +times over. The shares are an arbitrary split justified by what they achieve: a bar that tracks the +time a run actually takes. + +### 3. A report is filed as often as the run moves, and carried as often as it is worth reading + +An operation announces every step it makes — every frame, every row — because that is what it +knows. Deciding how often that is worth passing on belongs to whoever carries it: `ReportRate` +(`sampletones_shared/utils/progress.py`) spaces reports over `PROGRESS_STEPS` whatever the stage +counts in, always takes a stage's first reading and its last, and treats a count that falls as a +step the same way one that rises. + +Both carriers use it: `StageProgress` in the application services, and `JobReporter` in the +conversion, which throttles on the worker side so the line between processes carries only what a +bar can be redrawn at. + +### 4. A run reads as the items it finished plus the part of the one under way + +An operation counts the items it is measured in — files, samples, jobs. An item that reports its own +progress makes the count alone a poor reading: a conversion of one recording stands at nothing out +of one for its whole length. Both layers therefore carry the same pair: + +| Layer | Type | The counts | The work under way | +|-------|------|-----------|--------------------| +| Core | `TaskProgress` | `completed` / `total` | `steps`, one per running task | +| Application | `ServiceProgress` | `completed` / `total` | `partial`, in items | + +and both derive `fraction` from them. The counts keep naming the items a reader recognises — a +status line still reads *Progress: 2/5 files* — while every bar in the application draws +`fraction`, so one reading answers for a batch of files and for a single reconstruction alike. + +### 5. A task reaching another process reports over a channel + +Where an operation runs in a worker process, its reports reach the run over a `ProgressChannel` +(`sampletones_core/parallelization/channel/`). The channel carries both directions of principle 1 +across the boundary, and callers depend on the Protocol rather than on any one way of crossing it. + +```python +class ProgressChannel(Protocol): + def reporter(self, index: int) -> StepReporter: ... + def poll(self, timeout: float) -> Optional[TaskReport]: ... + def withdraw(self) -> None: ... + def close(self) -> None: ... +``` + +`ProcessProgressChannel` is the implementation. A manager stands beside the pool and owns both +ends — a queue reports travel up and a flag a withdrawal travels down — under the same spawn +context the pool's workers run in. Each end reaches a task as an ordinary value it is built with, +so a worker started as a fresh interpreter reconnects to them on its own, which is what makes one +channel serve every platform. + +--- + +## Mechanics + +### The conversation a run has with its tasks + +```mermaid +sequenceDiagram + participant REC as Reconstructor + participant JOB as JobReporter + participant CH as ProcessProgressChannel + participant PUMP as ProgressPump + participant RUN as TaskProcessor + participant SVC as ConversionService + + Note over REC,JOB: worker process + REC->>JOB: announce(MATCHING, frame, frames) + JOB->>CH: TaskStep, where a step is due + CH-->>JOB: whether the run goes on + Note over CH,PUMP: process boundary + PUMP->>CH: poll, then drain what waits + PUMP->>RUN: TaskSteps.record + notify once + RUN->>SVC: TaskProgress(completed, total, steps) + SVC->>SVC: ServiceProgress(partial=…, current_item=ConversionItem) +``` + +A run's monitor thread waits on the results the pool hands back, so it cannot also read the +channel: `ProgressPump` is the thread that does. It takes everything already waiting in one turn +and announces once, which holds the announcements to the rate it reads at however many steps the +tasks file in between. + +### Who owns what + +| Concern | Owner | +|---------|-------| +| The reporter shape, the silent one, and the spacing between reports | `sampletones_shared/utils/progress.py` | +| A reconstruction's stages, their shares, and its own `announce` | `sampletones_core/reconstructions/{stage,progress}.py` | +| Weighing a job's stage and carrying it to the run | `JobReporter` (`reconstructions/converter/progress.py`) | +| The line between a run and its tasks | `sampletones_core/parallelization/channel/` | +| Where the running tasks stand, and dropping a finished one | `TaskSteps` (`parallelization/steps.py`) | +| Opening the channel, reading it, and reaping it | `TaskProcessor` (`parallelization/processor.py`) | +| How long a run has left, from what it has covered | `ETAEstimator` (`parallelization/progress.py`) | +| Turning a run's account into a result the application reads | `ConversionService` (`services/conversion/`) | +| The bar, the status line, and the stage's name | `ConverterLogic` (`logic/main/converter.py`) | + +### A finished task stays finished + +A task's reports travel a line the run reads at its own pace, so one filed before its result +arrived may be read after it. `TaskSteps` records the tasks the run has counted and lets their +later reports go, which is what keeps a task's own progress and the run's completed count from +describing the same work twice — and keeps the reading inside the run it describes. + +### The channel's lifetime + +The channel is a process of its own, opened on the first task that asks for a line and reaped once +the run ends, whatever became of it. A run whose tasks report nothing never opens one. + +### Adding an operation that reports + +1. Give the domain a stage enum and a progress type, with an `announce` beside them, following + `sampletones_core/exports/progress.py`. +2. Thread the reporter through the calls that know how far the work has come, and announce every + step they make. +3. Where the work runs in this process, hand it a carrier that throttles and emits — `StageProgress` + for a service. Where it runs in a worker, ask `TaskProcessor._task_reporter` for a line and hand + the task a reporter built on it. +4. Read the run through `fraction`, and name the stage from `LanguageManager` in the logic layer. + +--- + +## Testing + +Progress is one path with two halves, and each is tested where it is cheap to test: + +| What | Where | +|------|-------| +| A stage's share, and a run read as a fraction | `tests/unit/sampletones_core/reconstructions/test_progress.py` | +| The spacing between reports | `tests/unit/sampletones_shared/utils/test_progress.py` | +| Where the running tasks stand, and a late report | `tests/unit/sampletones_core/parallelization/test_steps.py` | +| A job's stage weighed and throttled | `tests/unit/sampletones_core/reconstructions/converter/test_progress.py` | +| The real pipeline reporting its stages, in this process | `tests/integration/reconstruction/test_conversion_jobs.py` | +| The line carrying steps and a withdrawal between real processes | `tests/integration/sampletones_core/parallelization/test_progress_channel.py` | +| A conversion reporting itself end to end | `tests/integration/reconstruction/test_conversion_progress.py` | + +The two cross-process suites run real worker processes and stand something cheap in for the work — +counting in one, a walk through the stages in the other — so what they measure is the wiring rather +than a reconstruction. Both hold their worker at a chosen point until the test has taken the reading +it is asserting on (`tests/suite/release.py`), so an assertion about work under way is made while +that work is provably under way rather than resting on the scheduler. diff --git a/docs/index.md b/docs/index.md index be4c7046a..66a91188f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. +- [Progress](development/progress.md) — how a long operation says how far it has come, in one process and across the pool's workers. - [Console player](development/player.md) — the 6502 driver an `.nsf` carries, the codec that fits a song beside it, and how both are verified. - [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index ae78d9229..3a417f074 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -16,7 +16,7 @@ from sampletones_application.logic.main.explorer import ExplorerLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.main import MainTabParameters -from sampletones_application.services.conversion import ConversionService +from sampletones_application.services.conversion.service import ConversionService from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_PANEL_CENTER, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index e6fb802ad..84d5e3752 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -57,7 +57,7 @@ from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters -from sampletones_application.services.song_player.player import SongPlayerService +from sampletones_application.services.song_player.service import SongPlayerService from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_PANEL_CENTER, diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index a4d528d45..85e418c82 100644 --- a/src/sampletones_application/logic/export/logic.py +++ b/src/sampletones_application/logic/export/logic.py @@ -117,7 +117,7 @@ def _on_progress(self, progress: ServiceProgress[ExportStage]) -> None: self._reach(stage) self._travelling = stage in TRAVELLING_STAGES - self._progress = self._fraction(progress) + self._progress = progress.fraction self._figure = self._figure_text(progress) self._emit_view() @@ -125,12 +125,6 @@ def _reach(self, stage: ExportStage) -> None: if stage not in self._stages: self._stages.append(stage) - def _fraction(self, progress: ServiceProgress[ExportStage]) -> float: - if progress.total <= NOTHING_MEASURED: - return NO_PROGRESS - - return progress.completed / progress.total - def _figure_text(self, progress: ServiceProgress[ExportStage]) -> str: """What the stage under way has covered, stated where the stage travels toward no end. diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 0d91ded2c..258c72843 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, FrozenSet, Optional, Protocol, Sequence, Tuple +from typing import Callable, Dict, Final, FrozenSet, Optional, Protocol, Sequence, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -13,8 +13,8 @@ derive_conversion_setup, effective_channels, ) +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult from sampletones_application.services.result import ( - ConversionResult, ServiceCancelled, ServiceError, ServiceIntermediate, @@ -42,13 +42,14 @@ group_output_path, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.system.paths import to_path SINGLE_JOB: Final[int] = 1 +SYSTEM_PROGRESS_STEPS: Final[int] = 1000 @dataclass(frozen=True) @@ -104,6 +105,12 @@ def __init__( self._is_operation_active = is_operation_active self._msg_idle = language_manager["main.converter.message.status_idle"] self._msg_cancelling = language_manager["main.converter.message.status_cancelling"] + self._stage_messages: Dict[ReconstructionStage, str] = { + ReconstructionStage.LOADING: language_manager["main.converter.message.stage_loading"], + ReconstructionStage.MATCHING: language_manager["main.converter.message.stage_matching"], + ReconstructionStage.DECODING: language_manager["main.converter.message.stage_decoding"], + ReconstructionStage.RENDERING: language_manager["main.converter.message.stage_rendering"], + } self._phase: ConversionPhase = ConversionPhase.IDLE self._input_path: Optional[Path] = None @@ -336,40 +343,39 @@ def _on_service_result(self, result: ConversionResult) -> None: case ServiceCancelled(): self._on_cancellation_complete() - def _handle_progress_result(self, progress: ServiceProgress[Path]) -> None: + def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None: if self._phase == ConversionPhase.CANCELLING: - total = max(progress.total, 1) - self._emit_view_model( - self._msg_cancelling, - progress.completed / total, - ) + self._emit_view_model(self._msg_cancelling, progress.fraction) return self._phase = ConversionPhase.RUNNING - self._system_progress.set(progress.completed, progress.total) - eta_string = ETAEstimator.format_duration(progress.eta_seconds) - total = max(progress.total, 1) - status_text = self._compose_progress_text(progress) - if eta_string: - status_text += self._language_manager["global.dialog.template.time_estimation"].format( - eta_string=eta_string - ) - - display_input_path = ( - to_path(str(progress.current_item)) if progress.current_item is not None else self._input_path + self._system_progress.set( + round(progress.fraction * SYSTEM_PROGRESS_STEPS), + SYSTEM_PROGRESS_STEPS, ) self._emit_view_model( - status_text, - progress.completed / total, - input_path=display_input_path, + self._compose_progress_text(progress), + progress.fraction, + input_path=self._display_input_path(progress), ) - def _compose_progress_text(self, progress: ServiceProgress[Path]) -> str: - """What the run is doing: the reconstruction being built, or how far a batch has come. + def _display_input_path(self, progress: ServiceProgress[ConversionItem]) -> Optional[Path]: + """The recording the run names itself by, or the one the reader chose.""" + if progress.current_item is None: + return self._input_path + + return progress.current_item.source + + def _compose_progress_text(self, progress: ServiceProgress[ConversionItem]) -> str: + """What the run is doing, how far it has come, and how long it has left. A batch is many reconstructions and a count says where it stands; a single job counts to - one, so it names the document it is writing instead. + one, so it names the document it is writing instead. Either way the reconstruction under + way says which stage it is in, which is the whole of what a reader watching one job has. """ + return self._run_text(progress) + self._stage_text(progress) + self._estimate_text(progress) + + def _run_text(self, progress: ServiceProgress[ConversionItem]) -> str: if progress.total > SINGLE_JOB: return self._language_manager["main.converter.template.progress_template"].format( progress.completed, progress.total @@ -379,6 +385,24 @@ def _compose_progress_text(self, progress: ServiceProgress[Path]) -> str: self._reconstruction_name() ) + def _stage_text(self, progress: ServiceProgress[ConversionItem]) -> str: + step = progress.current_item.step if progress.current_item is not None else None + if step is None: + return "" + + return self._language_manager["main.converter.template.stage_template"].format( + stage=self._stage_messages[step.stage], + completed=step.completed, + total=step.total, + ) + + def _estimate_text(self, progress: ServiceProgress[ConversionItem]) -> str: + eta_string = ETAEstimator.format_duration(progress.eta_seconds) + if not eta_string: + return "" + + return self._language_manager["global.dialog.template.time_estimation"].format(eta_string=eta_string) + def _reconstruction_name(self) -> str: """The document a single job writes, which is what a run of one is making.""" if self._output_path is not None: diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py index 0cc91e353..90dbc1d48 100644 --- a/src/sampletones_application/logic/render/logic.py +++ b/src/sampletones_application/logic/render/logic.py @@ -217,7 +217,7 @@ def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None: self._phase = RenderPhase.RENDERING stage = progress.current_item status_text = self._status_text if stage is None else self._stage_status(stage, progress.eta_seconds) - self._report(status_text, progress.completed / max(progress.total, 1)) + self._report(status_text, progress.fraction) def _stage_status(self, stage: RenderStage, eta_seconds: Optional[float]) -> str: """What the pass is doing, and how long it has left where an estimate stands.""" diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index 59f0dcca7..87e2f6f68 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -1,22 +1,26 @@ from sampletones_application.services.base import ServiceBase -from sampletones_application.services.conversion import ConversionService +from sampletones_application.services.conversion.result import ( + ConversionItem, + ConversionResult, + ReconstructionStep, +) +from sampletones_application.services.conversion.service import ConversionService from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.regeneration import ( +from sampletones_application.services.regeneration.result import ( RegeneratedInstrument, RegenerationResult, - RegenerationService, ) +from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.render import ( RenderResult, RenderStage, SongRenderService, ) from sampletones_application.services.result import ( - ConversionResult, ServiceCancelled, ServiceError, ServiceIntermediate, @@ -28,6 +32,7 @@ from sampletones_application.services.synthesis import RowSynthesizerProtocol __all__ = [ + "ConversionItem", "ConversionResult", "ConversionService", "ExportError", @@ -35,6 +40,7 @@ "ExportResult", "ExportService", "ExportSuccess", + "ReconstructionStep", "RegeneratedInstrument", "RegenerationResult", "RegenerationService", diff --git a/src/sampletones_application/services/conversion/__init__.py b/src/sampletones_application/services/conversion/__init__.py new file mode 100644 index 000000000..b89ec3cfe --- /dev/null +++ b/src/sampletones_application/services/conversion/__init__.py @@ -0,0 +1,9 @@ +from .result import ConversionItem, ConversionResult, ReconstructionStep +from .service import ConversionService + +__all__ = [ + "ConversionItem", + "ConversionResult", + "ConversionService", + "ReconstructionStep", +] diff --git a/src/sampletones_application/services/conversion/result.py b/src/sampletones_application/services/conversion/result.py new file mode 100644 index 000000000..bd94fc7c9 --- /dev/null +++ b/src/sampletones_application/services/conversion/result.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import Optional, Tuple, Union + +from pydantic import BaseModel, ConfigDict + +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceIntermediate, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_core.parallelization import TaskProgress +from sampletones_core.reconstructions.stage import ReconstructionStage + + +class ReconstructionStep(BaseModel): + """What the reconstruction under way is doing, in the unit that work counts in. + + A conversion counts the files it writes, which for one recording — or one set of stems, since + those are one reconstruction too — counts to one. The stage and its counts are the whole of + what a reader watching a single conversion has to go on. + """ + + model_config = ConfigDict(frozen=True) + + stage: ReconstructionStage + completed: int + total: int + + +class ConversionItem(BaseModel): + """The reconstruction a conversion is building, and what it is doing to build it. + + A run knows which recording it is reading from the moment it starts, and hears what that + reconstruction is doing once the reconstruction has something to say, so the step arrives on + an item that already names its source. + """ + + model_config = ConfigDict(frozen=True) + + source: Path + step: Optional[ReconstructionStep] = None + + +ConversionResult = Union[ + ServiceStarted, + ServiceProgress[ConversionItem], + ServiceIntermediate[TaskProgress], + ServiceSuccess[Tuple[Path, ...]], + ServiceError, + ServiceCancelled, +] diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion/service.py similarity index 66% rename from src/sampletones_application/services/conversion.py rename to src/sampletones_application/services/conversion/service.py index a72021b0e..fa555ee24 100644 --- a/src/sampletones_application/services/conversion.py +++ b/src/sampletones_application/services/conversion/service.py @@ -2,8 +2,12 @@ from typing import Optional, Tuple from sampletones_application.services.base import ServiceBase -from sampletones_application.services.result import ( +from sampletones_application.services.conversion.result import ( + ConversionItem, ConversionResult, + ReconstructionStep, +) +from sampletones_application.services.result import ( ServiceCancelled, ServiceError, ServiceIntermediate, @@ -13,7 +17,9 @@ ) from sampletones_core.configs import Config from sampletones_core.parallelization import ETAEstimator, TaskProgress, TaskStatus +from sampletones_core.parallelization.task import TaskStep from sampletones_core.reconstructions.converter import ConversionPlan, ReconstructionConverter +from sampletones_core.reconstructions.stage import ReconstructionStage from sampletones_shared.logger import logger from sampletones_shared.utils.system.paths import to_path @@ -86,24 +92,59 @@ def _on_progress( task_status: TaskStatus, task_progress: TaskProgress, ) -> None: - current_item: Optional[Path] = None - if task_progress.current_item is not None: - current_item = to_path(task_progress.current_item) - match task_status: case TaskStatus.RUNNING | TaskStatus.CANCELLING: - eta_seconds = self._eta_estimator.update(task_progress.completed) if self._eta_estimator else None self._emit( ServiceProgress( completed=task_progress.completed, total=task_progress.total, - current_item=current_item, - eta_seconds=eta_seconds, + current_item=self._item(task_progress), + eta_seconds=self._estimate(task_progress), + partial=task_progress.partial, ) ) case _: pass + def _estimate(self, task_progress: TaskProgress) -> Optional[float]: + """How long the run has left, read from the whole of what it has covered. + + The run's own reading counts the reconstruction under way, so an estimate taken from it + moves while a single conversion runs rather than waiting for the file to be written. + """ + if self._eta_estimator is None: + return None + + return self._eta_estimator.update(task_progress.completed + task_progress.partial) + + @classmethod + def _item(cls, task_progress: TaskProgress) -> Optional[ConversionItem]: + """The reconstruction the run is building, where it has a recording to name. + + A run works on as many reconstructions as it has workers and names the one it has been at + longest, whose step it carries; the reading a bar draws counts them all. + """ + if task_progress.current_item is None: + return None + + return ConversionItem( + source=to_path(task_progress.current_item), + step=cls._step(task_progress), + ) + + @staticmethod + def _step(task_progress: TaskProgress) -> Optional[ReconstructionStep]: + """What that reconstruction is doing, once it has said something about itself.""" + if not task_progress.steps: + return None + + step: TaskStep = task_progress.steps[0] + return ReconstructionStep( + stage=ReconstructionStage(step.stage), + completed=step.completed, + total=step.total, + ) + def _on_completed(self, written: Tuple[Path, ...]) -> None: self._emit(ServiceSuccess(value=written)) diff --git a/src/sampletones_application/services/regeneration/__init__.py b/src/sampletones_application/services/regeneration/__init__.py new file mode 100644 index 000000000..2f6415ed7 --- /dev/null +++ b/src/sampletones_application/services/regeneration/__init__.py @@ -0,0 +1,8 @@ +from .result import RegeneratedInstrument, RegenerationResult +from .service import RegenerationService + +__all__ = [ + "RegeneratedInstrument", + "RegenerationResult", + "RegenerationService", +] diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py new file mode 100644 index 000000000..fc3a5362e --- /dev/null +++ b/src/sampletones_application/services/regeneration/result.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import Union + +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceSuccess, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class RegeneratedInstrument: + """A regeneration result paired with the generator and feature that changed. + + Carrying the request context alongside the fresh reconstruction lets the + history record which channel and feature an edit touched. + """ + + reconstruction: Reconstruction + channel_name: ChannelName + feature_key: FeatureKey + + +RegenerationResult = Union[ + ServiceSuccess[RegeneratedInstrument], + ServiceError, + ServiceCancelled, +] diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration/service.py similarity index 89% rename from src/sampletones_application/services/regeneration.py rename to src/sampletones_application/services/regeneration/service.py index ac5d2c0ac..2874f2c10 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -1,9 +1,12 @@ -from dataclasses import dataclass from typing import List, cast import numpy as np from sampletones_application.services.base import ServiceBase +from sampletones_application.services.regeneration.result import ( + RegeneratedInstrument, + RegenerationResult, +) from sampletones_application.services.result import ( ServiceCancelled, ServiceError, @@ -18,22 +21,6 @@ from sampletones_core.types.feature import FeatureValue -@dataclass(frozen=True) -class RegeneratedInstrument: - """A regeneration result paired with the generator and feature that changed. - - Carrying the request context alongside the fresh reconstruction lets the - history record which channel and feature an edit touched. - """ - - reconstruction: Reconstruction - channel_name: ChannelName - feature_key: FeatureKey - - -RegenerationResult = ServiceSuccess[RegeneratedInstrument] | ServiceError | ServiceCancelled - - class RegenerationService(ServiceBase[RegenerationResult]): """Recomputes one generator's instructions and audio for a reconstruction. diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index 2f237ea9e..635b53203 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -1,11 +1,11 @@ from dataclasses import dataclass -from pathlib import Path -from typing import Generic, Optional, Tuple, TypeVar, Union - -from sampletones_core.parallelization import TaskProgress +from typing import Final, Generic, Optional, TypeVar T = TypeVar("T") +NOTHING_TO_DO: Final[int] = 0 +NOTHING_UNDER_WAY: Final[float] = 0.0 + @dataclass(frozen=True) class ServiceStarted: @@ -14,10 +14,33 @@ class ServiceStarted: @dataclass(frozen=True) class ServiceProgress(Generic[T]): + """How far an operation has come, in the items it is measured in. + + An operation whose items report their own progress states what the one under way has covered + as ``partial``, so the run reads as a whole while the counts keep naming the items a reader + recognises. + + Attributes: + completed: The items the operation has finished. + total: The items the operation is measured against. + current_item: What the operation is working on, as the operation names it. + eta_seconds: How long the operation has left, where its rate says. + partial: The work under way beyond ``completed``, counted in items. + """ + completed: int total: int current_item: Optional[T] = None eta_seconds: Optional[float] = None + partial: float = NOTHING_UNDER_WAY + + @property + def fraction(self) -> float: + """How full the operation stands, the item under way counted for the part of it done.""" + if self.total == NOTHING_TO_DO: + return 0.0 + + return (self.completed + self.partial) / self.total @dataclass(frozen=True) @@ -38,13 +61,3 @@ class ServiceCancelled: @dataclass(frozen=True) class ServiceIntermediate(Generic[T]): data: T - - -ConversionResult = Union[ - ServiceStarted, - ServiceProgress[Path], - ServiceIntermediate[TaskProgress], - ServiceSuccess[Tuple[Path, ...]], - ServiceError, - ServiceCancelled, -] diff --git a/src/sampletones_application/services/retune/__init__.py b/src/sampletones_application/services/retune/__init__.py index 963b47d82..eff8e9f1e 100644 --- a/src/sampletones_application/services/retune/__init__.py +++ b/src/sampletones_application/services/retune/__init__.py @@ -1,6 +1,6 @@ from sampletones_application.services.retune.result import RetuneResult -from sampletones_application.services.retune.retune import SampleRetuneService from sampletones_application.services.retune.sample import RetunedSample +from sampletones_application.services.retune.service import SampleRetuneService __all__ = [ "RetuneResult", diff --git a/src/sampletones_application/services/retune/retune.py b/src/sampletones_application/services/retune/service.py similarity index 100% rename from src/sampletones_application/services/retune/retune.py rename to src/sampletones_application/services/retune/service.py diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/service.py similarity index 100% rename from src/sampletones_application/services/song_player/player.py rename to src/sampletones_application/services/song_player/service.py diff --git a/src/sampletones_config/boundaries/general.yaml b/src/sampletones_config/boundaries/general.yaml index 5c84c680d..e9ff67e9e 100644 --- a/src/sampletones_config/boundaries/general.yaml +++ b/src/sampletones_config/boundaries/general.yaml @@ -6,6 +6,9 @@ groups: service_contracts: - sampletones_application.services.result - - sampletones_application.services.render.result + - sampletones_application.services.conversion.result - sampletones_application.services.export.result + - sampletones_application.services.regeneration.result + - sampletones_application.services.render.result + - sampletones_application.services.retune.result - sampletones_application.services.song_player.result diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 2d289b2bc..7139b6fde 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -362,6 +362,10 @@ main.converter.message.status_idle: "No tasks in progress." main.converter.message.status_waiting: "Waiting to start..." main.converter.message.status_generating_library: "Generating instructions library... (this may take a while)" main.converter.message.status_cancelling: "Aborting the conversion..." +main.converter.message.stage_loading: "reading the recordings" +main.converter.message.stage_matching: "matching frames" +main.converter.message.stage_decoding: "reading the channels" +main.converter.message.stage_rendering: "rendering the frames" main.converter.message.status_cancelled: "Conversion cancelled." main.converter.message.status_input_label: "Input:" main.converter.message.status_output_label: "Output:" @@ -375,6 +379,7 @@ main.converter.message.load_file_prompt: "The reconstruction is ready. Load it n main.converter.message.load_directory_prompt: "The reconstructions are ready. Open the Reconstruction tab?" main.converter.message.cancel_prompt: "Stop the current reconstruction?" main.converter.template.progress_template: "Progress: {}/{} files" +main.converter.template.stage_template: " — {stage} {completed}/{total}" main.converter.template.single_progress_template: "Reconstructing {}..." main.converter.template.convert_label_template: "{}: {}" main.converter.label.stems_mode: "Stems mode" diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index 13e1c5b19..f47f0edf6 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -251,6 +251,7 @@ def _process_tasks(self) -> None: except KeyboardInterrupt as exception: raise CancelledError() from exception except OperationCancelled: + self.cancelling = True self._finalize_cancellation() return except CancelledError: diff --git a/src/sampletones_core/parallelization/progress.py b/src/sampletones_core/parallelization/progress.py index f95759630..19bf09379 100644 --- a/src/sampletones_core/parallelization/progress.py +++ b/src/sampletones_core/parallelization/progress.py @@ -1,77 +1,84 @@ -from collections import deque -from time import monotonic -from typing import Deque, Final, Optional, Tuple - -ESTIMATION_MEASUREMENTS_SAMPLES: Final[float] = 0.05 - - -class ETAEstimator: - def __init__( - self, - total: int, - ems: float = ESTIMATION_MEASUREMENTS_SAMPLES, - ) -> None: - self._total = total - self._ems = self._get_estimation_measurements_samples(ems) - self._samples_window: Deque[Tuple[float, int]] = deque(maxlen=self._ems) - self._processed_items: int = 0 - - def update(self, completed_items: int) -> Optional[float]: - now = monotonic() - self._processed_items = completed_items - self._samples_window.append((now, completed_items)) - - if completed_items >= self._total: - return 0.0 - if len(self._samples_window) < 2: - return None - - return self._estimate_remaining_seconds(completed_items, now) - - @classmethod - def format_duration(cls, seconds: Optional[float]) -> str: - if seconds is None: - return "?" - if seconds <= 0: - return "0s" - - secs = int(seconds) - if secs <= 0: - return "0s" - - minutes, seconds_remaining = divmod(secs, 60) - hours, minutes = divmod(minutes, 60) - - if hours: - return f"{hours}h {minutes:02d}m {seconds_remaining:02d}s" - if minutes: - return f"{minutes}m {seconds_remaining:02d}s" - - return f"{seconds_remaining}s" - - def _get_estimation_measurements_samples(self, ems: float) -> int: - if isinstance(ems, float): - ems = round(ems * self._total) - - return max(3, int(ems)) - - def _estimate_remaining_seconds( - self, - completed_items: int, - current_time: float, - ) -> Optional[float]: - if completed_items >= self._total: - return 0.0 - if len(self._samples_window) < 2: - return None - - first_time, first_completed = self._samples_window[0] - delta_completed = completed_items - first_completed - delta_time = current_time - first_time - - if delta_time <= 0 or delta_completed <= 0: - return None - - rate = delta_completed / delta_time - remaining = self._total - completed_items - return remaining / rate +from collections import deque +from time import monotonic +from typing import Deque, Final, Optional, Tuple, Union + +ESTIMATION_MEASUREMENTS_SAMPLES: Final[float] = 0.05 + + +class ETAEstimator: + """How long a run has left, read from the rate it has been covering its work at. + + What a run has covered is a measure rather than a count: an item reporting its own progress + stands part of the way through, and a rate taken from whole items alone would hold still for + as long as one takes to finish. + """ + + def __init__( + self, + total: Union[int, float], + ems: float = ESTIMATION_MEASUREMENTS_SAMPLES, + ) -> None: + self._total = total + self._ems = self._get_estimation_measurements_samples(ems) + self._samples_window: Deque[Tuple[float, float]] = deque(maxlen=self._ems) + self._processed_items: float = 0.0 + + def update(self, completed_items: Union[int, float]) -> Optional[float]: + now = monotonic() + self._processed_items = completed_items + self._samples_window.append((now, completed_items)) + + if completed_items >= self._total: + return 0.0 + if len(self._samples_window) < 2: + return None + + return self._estimate_remaining_seconds(completed_items, now) + + @classmethod + def format_duration(cls, seconds: Optional[float]) -> str: + if seconds is None: + return "?" + if seconds <= 0: + return "0s" + + secs = int(seconds) + if secs <= 0: + return "0s" + + minutes, seconds_remaining = divmod(secs, 60) + hours, minutes = divmod(minutes, 60) + + if hours: + return f"{hours}h {minutes:02d}m {seconds_remaining:02d}s" + if minutes: + return f"{minutes}m {seconds_remaining:02d}s" + + return f"{seconds_remaining}s" + + def _get_estimation_measurements_samples(self, ems: float) -> int: + if isinstance(ems, float): + ems = round(ems * self._total) + + return max(3, int(ems)) + + def _estimate_remaining_seconds( + self, + completed_items: float, + current_time: float, + ) -> Optional[float]: + if completed_items >= self._total: + return 0.0 + if len(self._samples_window) < 2: + return None + + first_time, first_completed = self._samples_window[0] + delta_completed = completed_items - first_completed + delta_time = current_time - first_time + + if delta_time <= 0 or delta_completed <= 0: + return None + + rate = delta_completed / delta_time + remaining = self._total - completed_items + return remaining / rate diff --git a/src/sampletones_core/reconstructions/progress.py b/src/sampletones_core/reconstructions/progress.py index 6009e11ec..4652dddbd 100644 --- a/src/sampletones_core/reconstructions/progress.py +++ b/src/sampletones_core/reconstructions/progress.py @@ -1,13 +1,17 @@ from dataclasses import dataclass from typing import Callable, Final -from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_core.reconstructions.stage import TOTAL_STAGE_WEIGHT, ReconstructionStage from sampletones_shared.exceptions import OperationCancelled from sampletones_shared.utils.arrays import clamp STAGE_BEGUN: Final[int] = 0 WHOLE_STAGE: Final[int] = 1 +PREPARATIONS: Final[int] = 3 +RECORDINGS_LOADED: Final[int] = 1 +FRAMES_PREPARED: Final[int] = 2 + NOTHING_DONE: Final[float] = 0.0 WHOLE_RUN: Final[float] = 1.0 @@ -31,13 +35,15 @@ def fraction(self) -> float: """How much of the whole reconstruction stands finished, the stage weighed by its share. The stages a run passes through count in units of their own, so a reading that spans them - all is each stage's own progress taken through the share it holds of the run. The reading - stays within the run it describes, so whoever draws it is handed a fraction of one. + all is each stage's own progress taken through the weight it carries. The weights are + divided once, here, which is what lets the last stage of a run arrive exactly at its end, + and the reading stays within the run it describes. """ if self.total <= 0: return self.stage.offset - reached = self.stage.offset + self.stage.share * (self.completed / self.total) + covered = self.completed / self.total + reached = (self.stage.preceding_weight + self.stage.weight * covered) / TOTAL_STAGE_WEIGHT return clamp(reached, NOTHING_DONE, WHOLE_RUN) diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 1b3a3d6e3..a24c042be 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -16,6 +16,9 @@ from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibrary, InstructionLibraryData from sampletones_core.reconstructions.progress import ( + FRAMES_PREPARED, + PREPARATIONS, + RECORDINGS_LOADED, STAGE_BEGUN, WHOLE_STAGE, ReconstructionReporter, @@ -127,9 +130,11 @@ def reconstruct( OperationCancelled: If the run is withdrawn while it is under way. """ checked_paths = self._check_stem_paths(paths, stems_config) - announce(report, ReconstructionStage.LOADING, STAGE_BEGUN, WHOLE_STAGE) + announce(report, ReconstructionStage.LOADING, STAGE_BEGUN, PREPARATIONS) recordings = self._load_stem_recordings(checked_paths) + announce(report, ReconstructionStage.LOADING, RECORDINGS_LOADED, PREPARATIONS) stem_frames, coefficient = self._prepare_stem_frames(recordings, stems_config) + announce(report, ReconstructionStage.LOADING, FRAMES_PREPARED, PREPARATIONS) worker = self._build_worker(common_length(recordings)) assignment = self._assign_stem_frames(stem_frames, stems_config, worker, report) self._drop_resting_channels(assignment) diff --git a/src/sampletones_core/reconstructions/stage.py b/src/sampletones_core/reconstructions/stage.py index 8e6725379..686ef53a9 100644 --- a/src/sampletones_core/reconstructions/stage.py +++ b/src/sampletones_core/reconstructions/stage.py @@ -11,9 +11,15 @@ class ReconstructionStage(StrEnum): the stage it names. Matching visits the library once per frame per stem and is what a run spends its time on, which - is what :data:`STAGE_SHARES` states: the bulk of the reading belongs to matching so a bar tracks + is what :data:`STAGE_WEIGHTS` states: the bulk of a reading belongs to matching so a bar tracks the time a run actually takes, while the stages around it keep enough of it to move visibly as - they pass. + they pass. The weights are approximations measured over whole runs, and matching earns a larger + share the longer the recording is, so the stages around it are given what they hold on a short + one — where a bar standing still is noticed. + + The weights are counts rather than fractions, so what a stage is worth is stated against the + others and a reading is one division at the point of use — which is what lets the last stage + arrive exactly at the whole run. """ LOADING = "loading" @@ -22,26 +28,38 @@ class ReconstructionStage(StrEnum): RENDERING = "rendering" @property - def share(self) -> float: - """How much of a whole reconstruction this stage stands for.""" - return STAGE_SHARES[self] + def weight(self) -> int: + """What this stage costs, against the other stages of a run.""" + return STAGE_WEIGHTS[self] @property - def offset(self) -> float: - """How much of a reconstruction stands finished when this stage begins.""" - offset = 0.0 + def preceding_weight(self) -> int: + """What the stages before this one cost together.""" + preceding = 0 for stage in ReconstructionStage: if stage is self: break - offset += stage.share + preceding += stage.weight + + return preceding + + @property + def share(self) -> float: + """How much of a whole reconstruction this stage stands for.""" + return self.weight / TOTAL_STAGE_WEIGHT - return offset + @property + def offset(self) -> float: + """How much of a reconstruction stands finished when this stage begins.""" + return self.preceding_weight / TOTAL_STAGE_WEIGHT -STAGE_SHARES: Final[Mapping[ReconstructionStage, float]] = { - ReconstructionStage.LOADING: 0.05, - ReconstructionStage.MATCHING: 0.80, - ReconstructionStage.DECODING: 0.05, - ReconstructionStage.RENDERING: 0.10, +STAGE_WEIGHTS: Final[Mapping[ReconstructionStage, int]] = { + ReconstructionStage.LOADING: 8, + ReconstructionStage.MATCHING: 82, + ReconstructionStage.DECODING: 2, + ReconstructionStage.RENDERING: 8, } + +TOTAL_STAGE_WEIGHT: Final[int] = sum(STAGE_WEIGHTS.values()) diff --git a/tests/integration/reconstruction/test_conversion_progress.py b/tests/integration/reconstruction/test_conversion_progress.py new file mode 100644 index 000000000..a48df5471 --- /dev/null +++ b/tests/integration/reconstruction/test_conversion_progress.py @@ -0,0 +1,105 @@ +from contextlib import contextmanager +from functools import partial +from pathlib import Path +from typing import Final, Iterator, Tuple +from unittest.mock import patch + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter import GroupConversion, ReconstructionConverter +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage +from tests.suite.conversion import FakeReconstructor, write_silent_recording +from tests.suite.parallelization import ProgressRecorder, stands_partway + +RECONSTRUCTOR_PATCH: Final[str] = "sampletones_core.reconstructions.converter.converter.Reconstructor" +READING_TIMEOUT: Final[float] = 60.0 +POOL_TIMEOUT: Final[float] = 120.0 +LONE_WORKER: Final[int] = 1 +ONE_JOB: Final[int] = 1 +WHOLE_RUN: Final[float] = 1.0 +RELEASE_NAME: Final[str] = "release" + + +def _config(tmp_path: Path) -> Config: + """A configuration writing under the test's own directory, with one worker to run in.""" + general = Config().general.model_copy( + update={"reconstructions_directory": str(tmp_path / "out"), "max_workers": LONE_WORKER} + ) + return Config().model_copy(update={"general": general}) + + +@contextmanager +def conversion_run(tmp_path: Path) -> Iterator[Tuple[ReconstructionConverter, ProgressRecorder, Path]]: + """Runs one conversion through the pool with the reconstruction itself standing in. + + The release file is written on the way out whatever the test did, so a run whose assertion + failed before releasing its job still ends rather than holding a worker at its halfway mark. + """ + config = _config(tmp_path) + source = write_silent_recording(tmp_path / "kick.wav") + release_path = tmp_path / RELEASE_NAME + stems = StemsConfig.single_entry(list(config.generation.channels)) + plan = GroupConversion(sources=(source,), stems=stems) + + recorder = ProgressRecorder() + converter = ReconstructionConverter(config=config, plan=plan) + converter.set_callbacks(on_progress=recorder) + standing_in = partial(FakeReconstructor, release_path=release_path) + with patch(RECONSTRUCTOR_PATCH, standing_in): + converter.start() + try: + yield converter, recorder, release_path + finally: + release_path.touch(exist_ok=True) + converter.shutdown() + + +class TestASingleConversionReportsItself: + """One recording is one job, and the run says how far that job has come while it runs. + + This is the whole path the reader watches: the run hands the job a line, the job walks its + stages over it from a worker process, and the run reads the stages back as one climbing + figure. The reconstruction itself stands in, since what is under test is the wiring. + """ + + def test_the_run_stands_between_its_ends_while_its_one_job_runs(self, tmp_path: Path) -> None: + with conversion_run(tmp_path) as (converter, recorder, release_path): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + + release_path.touch() + converter.wait(POOL_TIMEOUT) + + def test_the_run_names_the_recording_it_is_reading(self, tmp_path: Path) -> None: + with conversion_run(tmp_path) as (converter, recorder, release_path): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + release_path.touch() + converter.wait(POOL_TIMEOUT) + + named = {progress.current_item for _, progress in recorder.readings if progress.current_item} + + assert named == {str(tmp_path / "kick.wav")} + + def test_the_stages_reach_the_run_as_the_job_named_them(self, tmp_path: Path) -> None: + """A reading is where the job stands, so a stage passed through in one step may be read + together with the one after it; what every reading carries is a stage the job named.""" + with conversion_run(tmp_path) as (converter, recorder, release_path): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + release_path.touch() + converter.wait(POOL_TIMEOUT) + + stages = {step.stage for step in recorder.steps} + + assert stages <= {stage.value for stage in ReconstructionStage} + assert ReconstructionStage.MATCHING.value in stages + + def test_the_run_climbs_to_its_end(self, tmp_path: Path) -> None: + with conversion_run(tmp_path) as (converter, recorder, release_path): + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + release_path.touch() + converter.wait(POOL_TIMEOUT) + + fractions = recorder.fractions + assert fractions == sorted(fractions) + assert max(fractions) == pytest.approx(WHOLE_RUN) diff --git a/tests/integration/sampletones_application/services/test_conversion.py b/tests/integration/sampletones_application/services/test_conversion.py index 8d8336c6e..fb03df1b6 100644 --- a/tests/integration/sampletones_application/services/test_conversion.py +++ b/tests/integration/sampletones_application/services/test_conversion.py @@ -2,7 +2,7 @@ from typing import Any, Dict from unittest.mock import MagicMock, patch -from sampletones_application.services.conversion import ConversionService +from sampletones_application.services.conversion.service import ConversionService from sampletones_core.configs import Config from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion from sampletones_core.reconstructions.converter.plan.protocol import ConversionPlan @@ -22,7 +22,7 @@ class TestConversionServiceArgumentRouting: """ def _start_with_captured_kwargs(self, config: Config, plan: ConversionPlan) -> Dict[str, Any]: - with patch("sampletones_application.services.conversion.ReconstructionConverter") as mock_class: + with patch("sampletones_application.services.conversion.service.ReconstructionConverter") as mock_class: mock_class.return_value = MagicMock() ConversionService().start(config, plan) diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 90ea39be5..3fe56d701 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -6,7 +6,7 @@ import pytest from sampletones_application.logic.reconstruction.feature import FeatureData -from sampletones_application.services.regeneration import RegenerationService +from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceError, ServiceSuccess from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_core.constants.enums import ChannelName, FeatureKey diff --git a/tests/suite/conversion.py b/tests/suite/conversion.py new file mode 100644 index 000000000..1f74c573d --- /dev/null +++ b/tests/suite/conversion.py @@ -0,0 +1,75 @@ +from pathlib import Path +from typing import Final, FrozenSet, Sequence + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.progress import ( + STAGE_BEGUN, + WHOLE_STAGE, + ReconstructionReporter, + announce, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.types.path import Pathlike +from tests.suite.release import wait_for_release + +FAKE_FRAMES: Final[int] = 40 +HALFWAY: Final[int] = FAKE_FRAMES // 2 +COUNTED_STAGES: Final[FrozenSet[ReconstructionStage]] = frozenset( + {ReconstructionStage.MATCHING, ReconstructionStage.RENDERING} +) + + +class FakeReconstructor: + """A reconstructor that walks the stages a real one does and builds nothing. + + Reconstructing needs an instruction library and seconds of arithmetic per frame, none of which + the wiring under test depends on: what a run hands its jobs, what those jobs report, and how + the run reads it back. Walking the stages stands in for the work, and the run is left with no + file to write, which is what a job whose reconstruction came to nothing already does. + + Travels to a worker with the job, the way the real one does, so it is a plain picklable object + built from a configuration the same way — which is what lets a run construct one in its place. + The walk pauses halfway through matching until the test releases it, so an assertion about a + reconstruction under way is made while that reconstruction is provably under way. + """ + + def __init__(self, config: Config, release_path: Path, *, frames: int = FAKE_FRAMES) -> None: + self.config = config + self.release_path = release_path + self.frames = frames + + def reconstruct( + self, + paths: Sequence[Pathlike], + stems_config: StemsConfig, + *, + report: ReconstructionReporter, + ) -> None: + """Walks the stages of a reconstruction, reporting each, and answers with nothing built. + + The walk follows the stages in the order they are declared, so it passes through them the + way a reconstruction does however that order comes to change. + + Raises: + OperationCancelled: If the walk is withdrawn while it is under way. + """ + for stage in ReconstructionStage: + if stage not in COUNTED_STAGES: + announce(report, stage, STAGE_BEGUN, WHOLE_STAGE) + continue + + self._walk(report, stage) + + def _walk(self, report: ReconstructionReporter, stage: ReconstructionStage) -> None: + for frame in range(self.frames + 1): + announce(report, stage, frame, self.frames) + if stage == ReconstructionStage.MATCHING and frame == HALFWAY: + wait_for_release(self.release_path) + + +def write_silent_recording(path: Path) -> Path: + """Leaves a file where a recording would be, since the walk above reads none.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path diff --git a/tests/suite/parallelization.py b/tests/suite/parallelization.py index 02cfef70c..ee534e608 100644 --- a/tests/suite/parallelization.py +++ b/tests/suite/parallelization.py @@ -1,5 +1,4 @@ import threading -import time from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Final, FrozenSet, List, Optional, Tuple @@ -8,12 +7,11 @@ from sampletones_core.parallelization.processor import TaskProcessor from sampletones_core.parallelization.task import TaskProgress, TaskStatus, TaskStep from sampletones_shared.exceptions import OperationCancelled +from tests.suite.release import wait_for_release COUNTING_STAGE: Final[str] = "counting" STEP_COUNT: Final[int] = 8 HALFWAY: Final[int] = STEP_COUNT // 2 -RELEASE_POLL_SECONDS: Final[float] = 0.01 -RELEASE_TIMEOUT_SECONDS: Final[float] = 30.0 NOTHING_COMPLETED: Final[int] = 0 WHOLE_TASK: Final[float] = 1.0 ONE_STEP: Final[int] = 1 @@ -30,9 +28,7 @@ class CountingTask: Counting stands in for the work, so what a test of the channel measures is the channel. The task waits at the halfway mark until the file at ``release_path`` appears, which is how a - test can assert that a partial report arrived while the task was provably still running. The - wait gives up after ``RELEASE_TIMEOUT_SECONDS`` so a test that never releases it fails on its - own assertion rather than holding the run open. + test can assert that a partial report arrived while the task was provably still running. """ index: int @@ -59,17 +55,11 @@ def count_task(task: CountingTask) -> int: raise OperationCancelled(f"task {task.index} was withdrawn at {completed}") if completed == HALFWAY: - _wait_for_release(task.release_path) + wait_for_release(task.release_path) return task.index -def _wait_for_release(release_path: Path) -> None: - deadline = time.monotonic() + RELEASE_TIMEOUT_SECONDS - while not release_path.exists() and time.monotonic() < deadline: - time.sleep(RELEASE_POLL_SECONDS) - - class CountingProcessor(TaskProcessor[int]): """A run whose tasks only count, so what a test reads is the channel they count over.""" diff --git a/tests/suite/release.py b/tests/suite/release.py new file mode 100644 index 000000000..7ef7b4e99 --- /dev/null +++ b/tests/suite/release.py @@ -0,0 +1,25 @@ +import time +from pathlib import Path +from typing import Final + +RELEASE_POLL_SECONDS: Final[float] = 0.01 +RELEASE_TIMEOUT_SECONDS: Final[float] = 30.0 + + +def wait_for_release(release_path: Path) -> None: + """Holds a worker until the test that started it writes ``release_path``. + + A test asserting that a reading arrived while work was still under way has to know the work + was still under way when it looked, and a task quick enough to finish first would leave that + assertion resting on the scheduler. Holding the task where the test wants to see it settles + the question. + + The wait gives up after ``RELEASE_TIMEOUT_SECONDS`` and lets the task finish, so a test that + never releases it fails on its own assertion rather than holding a run open. + + Args: + release_path: The file whose appearance lets the worker carry on. + """ + deadline = time.monotonic() + RELEASE_TIMEOUT_SECONDS + while not release_path.exists() and time.monotonic() < deadline: + time.sleep(RELEASE_POLL_SECONDS) diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index d89b37a26..3e505776f 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -8,7 +8,7 @@ from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator from sampletones_application.logic.reconstruction.edit import StemRemoval from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.services.regeneration import RegeneratedInstrument +from sampletones_application.services.regeneration.service import RegeneratedInstrument from sampletones_application.services.result import ServiceSuccess from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.reconstructions import Reconstruction diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 15b698ec4..831fc3eef 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, Optional from unittest.mock import MagicMock, patch import pytest @@ -9,6 +9,10 @@ ConversionSuccess, ConverterLogic, ) +from sampletones_application.services.conversion.result import ( + ConversionItem, + ReconstructionStep, +) from sampletones_application.services.result import ServiceProgress from sampletones_application.view_model.main.converter import ( ACTIVE_PHASES, @@ -19,6 +23,7 @@ from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.stage import ReconstructionStage from tests.suite.language import FakeLanguageManager TEXTS: Final[Dict[str, str]] = { @@ -28,9 +33,16 @@ "main.converter.template.convert_label_template": "{}: {}", "main.converter.template.progress_template": "Progress: {}/{} files", "main.converter.template.single_progress_template": "Reconstructing {}...", + "main.converter.template.stage_template": " - {stage} {completed}/{total}", + "main.converter.message.stage_loading": "reading", + "main.converter.message.stage_matching": "matching", + "main.converter.message.stage_decoding": "decoding", + "main.converter.message.stage_rendering": "rendering", "global.dialog.template.time_estimation": "", } +FRAMES: Final[int] = 1100 + def _config_writing_under(reconstructions_directory: Path) -> Config: """A configuration whose reconstructions are written under ``reconstructions_directory``.""" @@ -703,13 +715,31 @@ def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: Co class TestProgressText: """A batch counts the files it has written; a single job names the reconstruction it is making.""" - def _progress(self, completed: int, total: int) -> ServiceProgress[Path]: - return ServiceProgress(completed=completed, total=total, eta_seconds=None, current_item=None) + @staticmethod + def _progress( + completed: int, + total: int, + item: Optional[ConversionItem] = None, + partial: float = 0.0, + ) -> ServiceProgress[ConversionItem]: + return ServiceProgress( + completed=completed, + total=total, + eta_seconds=None, + current_item=item, + partial=partial, + ) - def _status(self, converter_logic: ConverterLogic) -> str: + @staticmethod + def _status(converter_logic: ConverterLogic) -> str: view_model = converter_logic.on_view_changed.call_args.args[0] return str(view_model.status_text) + @staticmethod + def _bar(converter_logic: ConverterLogic) -> float: + view_model = converter_logic.on_view_changed.call_args.args[0] + return float(view_model.progress) + def test_a_batch_counts_its_files(self, converter_logic: ConverterLogic) -> None: converter_logic._handle_progress_result(self._progress(2, 5)) @@ -729,3 +759,64 @@ def test_a_single_job_falls_back_to_the_selected_input(self, converter_logic: Co converter_logic._handle_progress_result(self._progress(0, 1)) assert self._status(converter_logic) == "Reconstructing kick..." + + +class TestASingleJobShowsItsProgress: + """One reconstruction is one job, so what moves its bar is the reconstruction's own account. + + Without this a whole conversion reads as nothing done out of one file until the moment it is + written, which tells a reader watching it nothing at all. + """ + + @staticmethod + def _item(stage: ReconstructionStage, completed: int) -> ConversionItem: + return ConversionItem( + source=Path("/audio/kick.wav"), + step=ReconstructionStep(stage=stage, completed=completed, total=FRAMES), + ) + + def test_the_bar_reads_the_work_under_way(self, converter_logic: ConverterLogic) -> None: + converter_logic._handle_progress_result( + TestProgressText._progress(0, 1, item=self._item(ReconstructionStage.MATCHING, 412), partial=0.35) + ) + + assert TestProgressText._bar(converter_logic) == pytest.approx(0.35) + + def test_the_status_names_the_stage_and_its_counts(self, converter_logic: ConverterLogic) -> None: + converter_logic._input_path = Path("/audio/kick.wav") + converter_logic._output_path = None + + converter_logic._handle_progress_result( + TestProgressText._progress(0, 1, item=self._item(ReconstructionStage.MATCHING, 412), partial=0.35) + ) + + assert TestProgressText._status(converter_logic) == "Reconstructing kick... - matching 412/1100" + + def test_a_run_yet_to_say_anything_still_names_its_recording( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic._output_path = None + converter_logic._input_path = Path("/audio/kick.wav") + item = ConversionItem(source=Path("/audio/kick.wav")) + + converter_logic._handle_progress_result(TestProgressText._progress(0, 1, item=item)) + + assert TestProgressText._status(converter_logic) == "Reconstructing kick..." + assert TestProgressText._bar(converter_logic) == pytest.approx(0.0) + + def test_a_batch_counts_the_reconstruction_under_way_toward_its_files( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic._handle_progress_result( + TestProgressText._progress( + 2, + 5, + item=self._item(ReconstructionStage.RENDERING, FRAMES), + partial=0.5, + ) + ) + + assert TestProgressText._bar(converter_logic) == pytest.approx(0.5) + assert TestProgressText._status(converter_logic) == "Progress: 2/5 files - rendering 1100/1100" diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index 40db1bcbd..b92ef0065 100644 --- a/tests/unit/sampletones_application/services/song_player/test_song_player.py +++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py @@ -4,16 +4,16 @@ import numpy as np -from sampletones_application.services.song_player.player import ( - SongPlayerService, - _RenderedRow, -) from sampletones_application.services.song_player.result import ( SongPlaybackError, SongPlaybackStopped, SongPlayerResult, SongPositionUpdate, ) +from sampletones_application.services.song_player.service import ( + SongPlayerService, + _RenderedRow, +) from sampletones_core.project.song_position import SongPosition SAMPLE_RATE: Final[int] = 44100 @@ -21,7 +21,7 @@ WAIT_TIMEOUT: Final[float] = 5.0 SHORT_JOIN_TIMEOUT: Final[float] = 0.05 WRITE_RELEASE_DELAY: Final[float] = 0.05 -JOIN_TIMEOUT_TARGET: Final[str] = "sampletones_application.services.song_player.player.STOP_JOIN_TIMEOUT" +JOIN_TIMEOUT_TARGET: Final[str] = "sampletones_application.services.song_player.service.STOP_JOIN_TIMEOUT" def _make_service( diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index d7580c142..6485df495 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -5,7 +5,7 @@ import pytest -from sampletones_application.services.conversion import ConversionService +from sampletones_application.services.conversion.service import ConversionService from sampletones_application.services.result import ( ServiceCancelled, ServiceError, @@ -22,7 +22,7 @@ @pytest.fixture def mock_converter_class() -> Iterator[MockConverterClass]: - with patch("sampletones_application.services.conversion.ReconstructionConverter") as cls: + with patch("sampletones_application.services.conversion.service.ReconstructionConverter") as cls: instance = MagicMock() instance.is_running.return_value = False instance.status = TaskStatus.COMPLETED @@ -122,7 +122,8 @@ def test_on_progress_running_emits_service_progress( assert isinstance(result, ServiceProgress) assert result.completed == 2 assert result.total == 5 - assert result.current_item == Path("/some/file.wav") + assert result.current_item is not None + assert result.current_item.source == Path("/some/file.wav") def test_on_progress_cancelling_emits_service_progress( self, diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index c0a8ac868..153f2d09f 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -6,7 +6,7 @@ import numpy as np import pytest -from sampletones_application.services.regeneration import RegenerationService +from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ( ServiceCancelled, ServiceError, @@ -66,7 +66,7 @@ def synthesis_mocks() -> Iterator[SynthesisMocks]: channel_name = ChannelName.PULSE1 with patch( - "sampletones_application.services.regeneration.CHANNEL_TO_EXPORTER_MAP", + "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {channel_name: mock_exporter}, ): yield SimpleNamespace( @@ -329,7 +329,7 @@ def test_run_exception_emits_service_error( mock_exporter.get_generator_type.side_effect = exception with patch( - "sampletones_application.services.regeneration.CHANNEL_TO_EXPORTER_MAP", + "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {ChannelName.PULSE1: mock_exporter}, ): service._run( @@ -354,7 +354,7 @@ def test_run_exception_does_not_update_reconstruction( mock_exporter.get_generator_type.side_effect = RuntimeError("fail") with patch( - "sampletones_application.services.regeneration.CHANNEL_TO_EXPORTER_MAP", + "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", {ChannelName.PULSE1: mock_exporter}, ): service._run( diff --git a/tests/unit/sampletones_core/reconstructions/test_progress.py b/tests/unit/sampletones_core/reconstructions/test_progress.py index 514e14c16..7cc0d50c1 100644 --- a/tests/unit/sampletones_core/reconstructions/test_progress.py +++ b/tests/unit/sampletones_core/reconstructions/test_progress.py @@ -7,7 +7,7 @@ ReconstructionProgress, announce, ) -from sampletones_core.reconstructions.stage import STAGE_SHARES, ReconstructionStage +from sampletones_core.reconstructions.stage import STAGE_WEIGHTS, ReconstructionStage from sampletones_shared.exceptions import OperationCancelled from sampletones_shared.utils.progress import silent_reporter from tests.suite.base import BaseTestSuite @@ -18,19 +18,19 @@ FRAMES: int = 8 -class TestStageShares(BaseTestSuite): - """Every stage a reconstruction passes through holds a share, and the shares are the whole run. +class TestStageWeights(BaseTestSuite): + """Every stage a reconstruction passes through carries a weight, and together they are the run. - A bar crossing the stages reads each one through its share, so a stage the shares forgot would - leave the bar standing still while that stage ran, and shares summing to anything other than - the whole would leave it short of its end or past it. + A bar crossing the stages reads each one through its weight, so a stage the weights forgot + would leave the bar standing still while that stage ran. What each weight is worth is a tuning + choice; that they divide the whole run between them is the contract. """ - def test_every_stage_holds_a_share(self) -> None: - assert set(STAGE_SHARES) == set(ReconstructionStage) + def test_every_stage_carries_a_weight(self) -> None: + assert set(STAGE_WEIGHTS) == set(ReconstructionStage) def test_the_shares_are_the_whole_run(self) -> None: - assert sum(STAGE_SHARES.values()) == pytest.approx(WHOLE) + assert sum(stage.share for stage in ReconstructionStage) == pytest.approx(WHOLE) def test_a_stage_begins_where_the_stages_before_it_end(self) -> None: stages = list(ReconstructionStage) @@ -40,9 +40,12 @@ def test_a_stage_begins_where_the_stages_before_it_end(self) -> None: for stage, offset, following in zip(stages, offsets, offsets[1:]): assert following == pytest.approx(offset + stage.share) - def test_the_last_stage_ends_on_the_whole_run(self) -> None: + def test_a_finished_run_arrives_exactly_at_its_end(self) -> None: + """A bar drawn from this reads full only where the reading lands on the whole run.""" last = list(ReconstructionStage)[-1] - assert last.offset + last.share == pytest.approx(WHOLE) + finished = ReconstructionProgress(stage=last, completed=FRAMES, total=FRAMES) + + assert finished.fraction == WHOLE class TestReconstructionFraction(BaseTestSuite): @@ -57,29 +60,36 @@ class TestReconstructionFraction(BaseTestSuite): class TestCase(BaseAutolabelTestCase): expected: float stage: ReconstructionStage - completed: int - total: int + covered: float @property def label(self) -> str: - return f"{self.stage}_{self.completed}_of_{self.total}" + return f"{self.stage}_{self.covered:.0%}_through" test_cases = ( - TestCase(stage=ReconstructionStage.LOADING, completed=0, total=1, expected=0.0), - TestCase(stage=ReconstructionStage.MATCHING, completed=0, total=FRAMES, expected=0.05), - TestCase(stage=ReconstructionStage.MATCHING, completed=FRAMES, total=FRAMES, expected=0.85), - TestCase(stage=ReconstructionStage.RENDERING, completed=FRAMES, total=FRAMES, expected=WHOLE), + TestCase(stage=ReconstructionStage.LOADING, covered=0.0, expected=0.0), + TestCase(stage=ReconstructionStage.MATCHING, covered=0.0, expected=0.0), + TestCase(stage=ReconstructionStage.MATCHING, covered=0.5, expected=0.5), + TestCase(stage=ReconstructionStage.MATCHING, covered=WHOLE, expected=WHOLE), + TestCase(stage=ReconstructionStage.RENDERING, covered=WHOLE, expected=WHOLE), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_fraction_weighs_the_stage_by_its_share(self, test_case: TestCase) -> None: + """A stage part of the way through reads as that part of the share it holds. + + The expectation is derived from the stage's own share rather than restating it, so tuning + what a stage is worth leaves the rule it must satisfy standing. + """ + completed = round(test_case.covered * FRAMES) progress = ReconstructionProgress( stage=test_case.stage, - completed=test_case.completed, - total=test_case.total, + completed=completed, + total=FRAMES, ) + expected = test_case.stage.offset + test_case.stage.share * test_case.expected - assert progress.fraction == pytest.approx(test_case.expected) + assert progress.fraction == pytest.approx(expected) def test_a_stage_measured_against_nothing_reads_as_its_own_beginning(self) -> None: progress = ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=0, total=0) From 60cfb913d943943193984b481cc9c1ee3f4809f8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 03:32:42 +0200 Subject: [PATCH 103/142] Documented: how a long operation says how far it has come --- docs/development/progress.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/development/progress.md b/docs/development/progress.md index 15e737423..704ec78b0 100644 --- a/docs/development/progress.md +++ b/docs/development/progress.md @@ -36,10 +36,11 @@ A run passes through stages counting in units of their own — a song's ticks, a a recording's frames, a batch's files. A report therefore names its stage, and what the counts mean is read from that name. `ExportStage` and `ReconstructionStage` are the two vocabularies today. -Where the stages differ in what they cost, the enum states the share each holds of the whole run -(`ReconstructionStage.share`), so one reading spans a run that changes what it is counting several -times over. The shares are an arbitrary split justified by what they achieve: a bar that tracks the -time a run actually takes. +Where the stages differ in what they cost, the enum states what each is worth against the others +(`STAGE_WEIGHTS`), so one reading spans a run that changes what it is counting several times over. +The weights are approximations measured over whole runs, justified by what they achieve: a bar that +tracks the time a run actually takes. They are counts rather than fractions, and a reading divides +them once at the point of use, which is what lets a finished run arrive exactly at its end. ### 3. A report is filed as often as the run moves, and carried as often as it is worth reading @@ -114,10 +115,10 @@ sequenceDiagram SVC->>SVC: ServiceProgress(partial=…, current_item=ConversionItem) ``` -A run's monitor thread waits on the results the pool hands back, so it cannot also read the -channel: `ProgressPump` is the thread that does. It takes everything already waiting in one turn -and announces once, which holds the announcements to the rate it reads at however many steps the -tasks file in between. +A run's monitor thread waits on the results the pool hands back, so reading the channel belongs +to a thread of its own: `ProgressPump`. It takes everything already waiting in one turn and +announces once, which holds the announcements to the rate it reads at however many steps the tasks +file in between. ### Who owns what From 3f6e3f6716b736d95f3716d37dadc5bb0ee3d3e5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 09:45:17 +0200 Subject: [PATCH 104/142] Colored: tracker voice slot --- docs/concepts/stems.md | 2 +- docs/development/guidelines.md | 1 + docs/guide/interface.md | 2 +- .../coordinators/tabs/sequencer.py | 6 +- .../logic/sequencer/history_detail.py | 2 +- .../ui/panels/sequencer/display.py | 2 + .../ui/panels/sequencer/history.py | 2 +- .../ui/panels/sequencer/order.py | 2 +- .../ui/panels/sequencer/tracker.py | 199 ++++++++--- .../utils/gui/palette/palette.py | 2 +- .../view_model/sequencer/tracker.py | 12 + .../view_model/shared/history.py | 4 +- .../layout/tabs/sequencer/colors.yaml | 2 +- src/sampletones_config/palettes/dark.yaml | 1 - src/sampletones_config/palettes/light.yaml | 1 - src/sampletones_config/palettes/studio.yaml | 1 - src/sampletones_shared/logger/main.py | 2 +- src/sampletones_shared/utils/color.py | 2 +- .../ui/elements/tree/test_favorites_filter.py | 8 +- .../ui/panels/sequencer/test_block_keys.py | 1 + .../sequencer/test_tracker_cell_themes.py | 323 ++++++++++++++++++ .../panels/sequencer/test_tracker_channels.py | 23 +- .../sequencer/test_tracker_typed_voice.py | 40 +++ .../utils/gui/test_palette.py | 2 +- .../view_model/sequencer/test_tracker.py | 66 ++++ 25 files changed, 635 insertions(+), 73 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index aa1127c45..e5e2fc86b 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -214,7 +214,7 @@ directory holding them. The reconstruction tab's Stems card turns the recorded assignment into a listener the user can steer. It draws the same list the converter's card draws: each row carries one stem under the level it was picked on, named by its -recording, with a leading master box and a coloured box on every channel the +recording, with a leading master box and a colored box on every channel the stem holds frames on. A setup line above the rows names the assignment's hierarchy mode and channel cap, and a **Collapse levels** toggle draws every row in one table where the banding is in the way. Ticking a box admits that stem's diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 5ae4a5e60..b61e0ed30 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -82,6 +82,7 @@ These rules govern the Python in this repository. They complement 1. Write for a reader who never saw the history. A document is not a changelog or a devlog: do not argue against past states, resolved problems, or rejected alternatives the reader never knew existed. The design as it stands carries its own justification; history belongs in commit messages and release notes. 1. Reach for a negative example only when the contrast teaches something the positive statement cannot, and use it sparingly. One well-placed "what to avoid" illuminates; a document written mostly in negatives is noise. 1. State each fact once, in the document that owns it, and cross-reference sibling documents rather than repeating them. +1. Use American English. ## Tests diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 91dc1d3b4..047a9ecc3 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -108,7 +108,7 @@ A reconstruction mixed from several recordings has a **Stems** card. It lists each recording under the level it was picked on — the same list the converter showed you while you were gathering. -Each row has a coloured box for every channel that recording actually took, and +Each row has a colored box for every channel that recording actually took, and a box at the front that moves all of them at once. Untick one and those frames go silent everywhere: in the waveform, in playback, in the original audio, and in a WAV export. That is how you hear what each recording contributed, channel diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index e4f0d48d2..e027862b3 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -90,7 +90,9 @@ from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel -from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel +from sampletones_application.ui.panels.sequencer.voices.panel import ( + GUISequencerVoicesPanel, +) from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.file_dialogs.api import open_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter @@ -914,7 +916,7 @@ def _undoable( Every mutation the wrapped callback triggers is grouped under ``action``; a gesture that changes nothing records no entry. ``detail`` computes the - entry's coloured description segments from the same arguments the hook + entry's colored description segments from the same arguments the hook receives, and ``coalesce`` computes the gesture's target key from them: consecutive gestures sharing the same action and target collapse into a single entry. diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 9adb84ba7..eb562a68a 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -81,7 +81,7 @@ def _kind_role(kind: Optional[VoiceKind]) -> HistoryDetailRole: class SequencerHistoryDetail: - """Builds the coloured detail line for each undoable sequencer gesture. + """Builds the colored detail line for each undoable sequencer gesture. Every method mirrors the signature of the coordinator hook it describes, so it can be handed straight to ``_undoable`` as the ``detail`` callable. Each returns diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index 01d90bfc1..027a455bd 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -4,11 +4,13 @@ from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import SequencerCellViewModel +from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id, display_transpose, display_volume CellKey = Tuple[int, Optional[ChannelName], SubColumn] CellValues = Dict[CellKey, str] +CellKinds = Dict[CellKey, Optional[VoiceKind]] CELL_TITLE_SEPARATOR: Final[str] = " | " diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 0c523400a..173b8eac6 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -270,7 +270,7 @@ def _create_entry( *, before: int, ) -> None: - """Renders one entry as a full-width selectable with coloured text on top. + """Renders one entry as a full-width selectable with colored text on top. A ``span_columns`` selectable backs the whole row, so clicking anywhere jumps to that entry and the current entry keeps the native selected diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 0b4e81ce7..0fea714b5 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -571,7 +571,7 @@ def repaint(self) -> None: DearPyGui keeps a row, column or cell highlight on the table rather than on an item, so a colour reaches it only by being pushed again. Gathering the pushes here gives - the palette one call to make and keeps a rebuilt table and a recoloured one identical. + the palette one call to make and keeps a rebuilt table and a recolored one identical. """ if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE): return diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 4dd527477..e858e2275 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -7,7 +7,11 @@ ) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.constants.tracker import DEFAULT_OCTAVE, MAX_OCTAVE, MIN_OCTAVE +from sampletones_application.constants.tracker import ( + DEFAULT_OCTAVE, + MAX_OCTAVE, + MIN_OCTAVE, +) from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( @@ -49,7 +53,11 @@ tracker_table_column, tracker_table_row, ) -from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues +from sampletones_application.ui.panels.sequencer.display import ( + CellKey, + CellKinds, + CellValues, +) from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.grid.scroll.axis import VerticalScroll from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand @@ -58,13 +66,18 @@ BlockShortcuts, ClipboardItems, ) -from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( + GridEditSurface, +) from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget -from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import ( + TrackerCursor, + TrackerInputState, +) from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, @@ -81,7 +94,10 @@ KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS, SIGN_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_application.utils.gui.keyboard.modifiers import ( + Modifier, + capture_modifiers, +) from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource @@ -93,7 +109,10 @@ SequencerChannelsViewModel, ) from sampletones_application.view_model.sequencer.kind import places_across_channels -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) @@ -110,13 +129,18 @@ from sampletones_application.view_model.sequencer.voices import ( SequencerVoicesViewModel, VoiceEntryViewModel, + VoiceKind, ) from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import speaks_in_periods from sampletones_core.project.song_position import SongPosition from sampletones_core.utils.display import NOTE_OFF, display_id -from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES, SEMITONE_STEP +from sampletones_shared.constants.music import ( + OCTAVE_OFFSET, + OCTAVE_SEMITONES, + SEMITONE_STEP, +) from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback @@ -135,6 +159,7 @@ OnPasteBlockCallback = Callable[[TrackerCell], None] CanPasteBlockQuery = Callable[[], bool] TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] +ThemeKey = Tuple[SubColumn, Optional[VoiceKind]] VOLUME_FINE_STEP: Final[int] = 1 @@ -253,8 +278,9 @@ def __init__( band=self._travel_band, elapsed=dpg.get_delta_time, ) - self._subcolumn_themes: Dict[SubColumn, int] = {} - self._muted_subcolumn_themes: Dict[SubColumn, int] = {} + self._cell_kinds: CellKinds = {} + self._subcolumn_themes: Dict[ThemeKey, int] = {} + self._muted_subcolumn_themes: Dict[ThemeKey, int] = {} self._row_number_theme: int = 0 self._header_theme: int = 0 self._muted_header_theme: int = 0 @@ -435,21 +461,26 @@ def _create_themes(self) -> None: self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row) def _create_subcolumn_themes(self) -> None: - """Builds each subcolumn's text theme in its full and its dimmed colour. + """Builds every text theme a cell can wear, in its full and its dimmed colour. - The dimmed variant keeps the subcolumn's own hue at reduced alpha, so a silenced - channel's values stay readable and editable while the others are worked on. + The voice slot carries one theme per kind of voice it can name, beside the shade it takes + while it names none, so the colour of a cell reports what stands in it. Transpose and volume + speak for themselves and take one each. The dimmed variant keeps the same hue at reduced + alpha, so a silenced channel's values stay readable and editable while the others are + worked on. """ - subcolumn_colors = self._layout.colors.text - theme_colors = { - SubColumn.VOICE: subcolumn_colors.voice, - SubColumn.TRANSPOSE: subcolumn_colors.transpose, - SubColumn.VOLUME: subcolumn_colors.volume, + text = self._layout.colors.text + theme_colors: Dict[ThemeKey, BaseColor] = { + (SubColumn.VOICE, None): text.voice, + (SubColumn.VOICE, VoiceKind.SAMPLE): text.sample, + (SubColumn.VOICE, VoiceKind.INSTRUMENT): text.instrument, + (SubColumn.TRANSPOSE, None): text.transpose, + (SubColumn.VOLUME, None): text.volume, } fraction = self._layout.tracker.muted_text_fraction - for subcolumn, color in theme_colors.items(): - self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color) - self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme( + for theme_key, color in theme_colors.items(): + self._subcolumn_themes[theme_key] = create_selectable_text_theme(color) + self._muted_subcolumn_themes[theme_key] = create_selectable_text_theme( FadedColor( color=color, fraction=fraction, @@ -577,10 +608,12 @@ def update_tracker(self, view_model: SequencerTrackerViewModel) -> None: the edit cursor that a full rebuild would otherwise discard. """ cell_values = self._compute_cell_values(view_model) + cell_kinds = self._compute_cell_kinds(view_model) self._show_frame(view_model.frame_index) if len(view_model.rows) != self._current_row_count: - self._rebuild_table(view_model, cell_values) + self._rebuild_table(view_model, cell_values, cell_kinds) else: + self._reconcile_cell_kinds(cell_kinds) self._editable_cells.reconcile(cell_values, self._render_cell) def _show_frame(self, frame_index: int) -> None: @@ -600,6 +633,7 @@ def _rebuild_table( self, view_model: SequencerTrackerViewModel, cell_values: CellValues, + cell_kinds: CellKinds, ) -> None: """Replaces the table body, and re-reveals the sounding row once the new body has laid out. @@ -614,6 +648,7 @@ def _rebuild_table( self._selection.reset() self._travel.rest() self._editable_cells.reset(cell_values) + self._cell_kinds = dict(cell_kinds) self._build_table(view_model) self.repaint() FrameCallbackManager.set_frame_callback(self._reveal_playing_row) @@ -623,7 +658,7 @@ def repaint(self) -> None: DearPyGui keeps a row, column or cell highlight on the table rather than on an item, so a colour reaches it only by being pushed again. Gathering the pushes here gives - the palette one call to make and keeps a rebuilt table and a recoloured one identical. + the palette one call to make and keeps a rebuilt table and a recolored one identical. """ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): return @@ -791,6 +826,36 @@ def _compute_cell_values( return cell_values + def _compute_cell_kinds( + self, + view_model: SequencerTrackerViewModel, + ) -> CellKinds: + """Which kind of voice each voice slot names, which is the colour that slot wears. + + Only the voice slot reports a kind, so the map covers those cells alone and a refresh + re-themes as many of them as the edit touched. + """ + cell_kinds: CellKinds = {} + for row in view_model.rows: + cell_kinds[(row.index, None, SubColumn.VOICE)] = row.sample_kind + for channel in ChannelName.items(): + cell_kinds[(row.index, channel, SubColumn.VOICE)] = row.cells[channel].kind + + return cell_kinds + + def _reconcile_cell_kinds(self, cell_kinds: CellKinds) -> None: + """Re-themes the voice slots whose kind changed, leaving the rest of the grid bound. + + The labels are diffed the same way, so a refresh costs what the edit did rather than what + the grid holds. + """ + for key, kind in cell_kinds.items(): + if self._cell_kinds.get(key) == kind: + continue + + self._cell_kinds[key] = kind + self._bind_cell_theme(key) + def _build_table(self, view_model: SequencerTrackerViewModel) -> None: self._rows = {} self._current_row_count = len(view_model.rows) @@ -931,7 +996,7 @@ def _add_subcolumn_selectable( callback=self._on_cell_clicked, ) FontRegistry.bind_to_item(selectable, font) - dpg.bind_item_theme(selectable, self._subcolumn_themes[subcolumn]) + dpg.bind_item_theme(selectable, self._cell_theme(key)) dpg.bind_item_handler_registry(selectable, self._cell_handler_tag) self._editable_cells.register(key, selectable) @@ -1017,12 +1082,31 @@ def _bind_header_themes(self) -> None: ) def _bind_channel_cell_themes(self, channel: ChannelName) -> None: - themes = self._muted_subcolumn_themes if self._is_muted(channel) else self._subcolumn_themes for row_index in range(self._current_row_count): for subcolumn in SubColumn: - cell_id = self._editable_cells.widget((row_index, channel, subcolumn)) - if cell_id is not None: - dpg.bind_item_theme(cell_id, themes[subcolumn]) + self._bind_cell_theme((row_index, channel, subcolumn)) + + def _bind_cell_theme(self, key: CellKey) -> None: + """Gives one cell the theme it currently answers to, where the grid holds that cell.""" + cell_id = self._editable_cells.widget(key) + if cell_id is not None: + dpg.bind_item_theme(cell_id, self._cell_theme(key)) + + def _cell_theme(self, key: CellKey) -> int: + """The theme a cell wears: its slot's colour, dimmed while its channel is silenced. + + A voice slot takes the colour of the kind of voice standing in it, so a reader tells a + recording from a hand-written one across the whole grid; a slot naming none takes the + neutral shade the other slots' colours are read against. + """ + _, channel, subcolumn = key + kind = self._cell_kinds.get(key) + themes = self._muted_subcolumn_themes if self._is_muted_cell(channel) else self._subcolumn_themes + return themes[(subcolumn, kind)] + + def _is_muted_cell(self, channel: Optional[ChannelName]) -> bool: + """Whether a cell's channel is silenced, the sample column speaking for every channel.""" + return channel is not None and self._is_muted(channel) def _is_muted(self, channel: ChannelName) -> bool: return self._current_channels is not None and self._current_channels.is_muted(channel) @@ -1058,16 +1142,17 @@ def _update_caret(self) -> None: clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, ) - def _resolve_voice_id( + def _resolve_voice( self, sample_index: int, channel: Optional[ChannelName], - ) -> Optional[Tuple[int, str]]: + ) -> Optional[Tuple[int, VoiceEntryViewModel]]: """The voice a typed number names, where the column it was typed in takes that voice. A number past the end of the pool reads as the last voice, so a reader typing freely lands on something. The sample column stands by for a voice it cannot spread over channels, and - answering nothing here is what leaves the cell showing the value it already held. + answering nothing here is what leaves the cell showing the value it already held. The whole + entry comes back, so the cell takes the voice's kind along with its number. """ if not self._current_samples or not self._current_samples.voices: return None @@ -1078,7 +1163,7 @@ def _resolve_voice_id( if not self._column_takes(channel, voice): return None - return sample_index, voice.voice_id + return sample_index, voice def _handle_edit_action(self, action: EditAction) -> None: """Commits a single-subcolumn edit. @@ -1090,19 +1175,22 @@ def _handle_edit_action(self, action: EditAction) -> None: row, channel = action.row, action.channel if action.note_off: - self._editable_cells.values[(row, channel, SubColumn.VOICE)] = NOTE_OFF + self._show_voice(row, channel, NOTE_OFF, None) self.call(self.on_set_note_off, row, channel) return voice_id: Optional[str] = None if action.sample_index is not None: - resolved = self._resolve_voice_id(action.sample_index, channel) + resolved = self._resolve_voice(action.sample_index, channel) if resolved is not None: - sample_index, voice_id = resolved - self._editable_cells.values[(row, channel, SubColumn.VOICE)] = tracker_display.format_committed( - SubColumn.VOICE, - sample_index, + sample_index, voice = resolved + voice_id = voice.voice_id + self._show_voice( + row, + channel, + tracker_display.format_committed(SubColumn.VOICE, sample_index), + voice.kind, ) if action.transpose is not None: @@ -1126,6 +1214,30 @@ def _handle_edit_action(self, action: EditAction) -> None: action.volume, ) + def _show_voice( + self, + row: int, + channel: Optional[ChannelName], + label: str, + kind: Optional[VoiceKind], + ) -> None: + """Shows a voice slot's new reading before the project answers, colour and number together. + + The panel holds both caches, so an edit that the logic goes on to refuse leaves the cell + reading exactly what it held. + """ + key = (row, channel, SubColumn.VOICE) + self._editable_cells.values[key] = label + self._cell_kinds[key] = kind + self._bind_cell_theme(key) + + def _forget_voice(self, row: int, channel: Optional[ChannelName]) -> None: + """Empties a voice slot's caches, so a cleared cell drops its number and its colour as one.""" + key = (row, channel, SubColumn.VOICE) + self._editable_cells.values.pop(key, None) + self._cell_kinds[key] = None + self._bind_cell_theme(key) + def _handle_clear_action(self, action: ClearAction) -> None: if action.subcolumn is None: for subcolumn in SubColumn: @@ -1133,12 +1245,17 @@ def _handle_clear_action(self, action: ClearAction) -> None: (action.row, action.channel, subcolumn), None, ) + self._forget_voice(action.row, action.channel) self.call(self.on_clear_row, action.row, action.channel) else: - self._editable_cells.values.pop( - (action.row, action.channel, action.subcolumn), - None, - ) + if action.subcolumn is SubColumn.VOICE: + self._forget_voice(action.row, action.channel) + else: + self._editable_cells.values.pop( + (action.row, action.channel, action.subcolumn), + None, + ) + self.call( self.on_clear_subcolumn, action.row, diff --git a/src/sampletones_application/utils/gui/palette/palette.py b/src/sampletones_application/utils/gui/palette/palette.py index e060e4ac3..664e70a65 100644 --- a/src/sampletones_application/utils/gui/palette/palette.py +++ b/src/sampletones_application/utils/gui/palette/palette.py @@ -23,7 +23,7 @@ class PaletteBindings: :meth:`apply` hands DearPyGui the value each token carries now. One argument of one item holds one colour, so binding it again replaces what is recorded - for it: an item recoloured on every hover stays a single entry. + for it: an item recolored on every hover stays a single entry. """ _arguments: ClassVar[Dict[ArgumentKey, ArgumentBinding]] = {} diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index 9668c8120..b848f4235 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -10,6 +10,7 @@ display_transpose, display_volume, ) +from sampletones_shared.utils.agreement import Agreement class SequencerCellViewModel(BaseModel, frozen=True): @@ -75,6 +76,17 @@ def subcolumn_channels(self) -> FrozenSet[ChannelName]: def sample(self) -> str: return self._aggregate(_sample_reading, display_id(None)) + @property + def sample_kind(self) -> Optional[VoiceKind]: + """The kind of voice the sample column's slot names, absent where its channels disagree. + + The slot speaks for the channels the row's samples cover, so it states a kind where every + one of those channels names a voice of that kind. A row naming no sample covers none and + states none, which is what an empty slot and a cut row are. + """ + kinds = Agreement.collapse(self.cells[channel].kind for channel in self.sample_channels) + return kinds.resolve(absent=None, mixed=None) + @property def transpose(self) -> str: return self._aggregate(lambda cell: cell.transpose, display_transpose(None)) diff --git a/src/sampletones_application/view_model/shared/history.py b/src/sampletones_application/view_model/shared/history.py index 4668283f4..62df52b73 100644 --- a/src/sampletones_application/view_model/shared/history.py +++ b/src/sampletones_application/view_model/shared/history.py @@ -44,14 +44,14 @@ class HistoryDetailWord(StrEnum): class HistoryDetailSegment(BaseModel, frozen=True): - """One coloured token of a history entry's detail line.""" + """One colored token of a history entry's detail line.""" text: str role: HistoryDetailRole class HistoryDetailWordSegment(BaseModel, frozen=True): - """One coloured token whose text is looked up from the language manager when rendered.""" + """One colored token whose text is looked up from the language manager when rendered.""" word: HistoryDetailWord role: HistoryDetailRole diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index c71944b98..02ff04055 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -29,7 +29,7 @@ history: value: .history_value separator: .history_separator text: - voice: .tracker_reference + voice: .text_disabled transpose: .tracker_transpose volume: .tracker_volume sample: .voice_sample diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 0befdd5cb..403b17c28 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -188,7 +188,6 @@ colors: history_value: "#d0d0d4ff" history_separator: "#6c6c74ff" - tracker_reference: "#e0c860ff" tracker_transpose: "#c0c0c4ff" tracker_volume: "#7ee787ff" tracker_frame: "#4fa6ffff" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index d637fb3d6..71c7b8af6 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -188,7 +188,6 @@ colors: history_value: "#1d222aff" history_separator: "#8f97a4ff" - tracker_reference: "#7a5200ff" tracker_transpose: "#3d434dff" tracker_volume: "#16702eff" tracker_frame: "#0a5aa8ff" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 09adc2386..1db0dc9ed 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -188,7 +188,6 @@ colors: history_value: "#d0d0d0ff" history_separator: "#707070ff" - tracker_reference: "#e0c860ff" tracker_transpose: "#c0c0c0ff" tracker_volume: "#64dc64ff" tracker_frame: "#22ccffff" diff --git a/src/sampletones_shared/logger/main.py b/src/sampletones_shared/logger/main.py index 9c9f256c4..da5d29401 100644 --- a/src/sampletones_shared/logger/main.py +++ b/src/sampletones_shared/logger/main.py @@ -16,7 +16,7 @@ class Logger(metaclass=SingletonMeta): """The application-wide logger, backed by a Rich console handler. A single instance serves the whole process (through :class:`SingletonMeta`) and - writes formatted, coloured records to the terminal. The severity methods + writes formatted, colored records to the terminal. The severity methods ``debug``, ``info``, ``warning``, ``error``, and ``critical`` each forward a message to the underlying :mod:`logging` logger at that level. """ diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index dbc220c5e..daa8d01c9 100644 --- a/src/sampletones_shared/utils/color.py +++ b/src/sampletones_shared/utils/color.py @@ -56,7 +56,7 @@ def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA: def to_grayscale(color: ColorRGBA) -> ColorRGBA: """Return ``color`` desaturated to its luminance-preserving gray, keeping its alpha. - The RGB channels collapse to one perceptual-luminance value, so a coloured line reads + The RGB channels collapse to one perceptual-luminance value, so a colored line reads as an inactive gray while its alpha stays under the caller's separate control. """ red, green, blue, alpha = color diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 2e34b4202..8f2fd33f9 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -643,7 +643,7 @@ def test_the_star_reads_muted_while_the_mode_is_off(self, corpus: BrowserCorpus) panel = build_browser_panel(corpus, set(), favorites_only=False) assert panel._favorites_glyph_color() == TREE_COLORS.muted - def test_the_star_is_coloured_with_the_token_the_mode_names( + def test_the_star_is_colored_with_the_token_the_mode_names( self, corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, @@ -651,16 +651,16 @@ def test_the_star_is_coloured_with_the_token_the_mode_names( """The colour reaches the star as a token, so the star follows a palette swapped in place.""" panel = build_browser_panel(corpus, set(), favorites_only=True) panel._favorites_glyph_tag = GLYPH_TAG - coloured: List[Tuple[str, BaseColor]] = [] + colored: List[Tuple[str, BaseColor]] = [] monkeypatch.setattr( tree_module, "dpg_set_palette_color", - lambda item, color: coloured.append((item, color)), + lambda item, color: colored.append((item, color)), ) panel._apply_favorites_glyph_color() - assert coloured == [(GLYPH_TAG, TREE_COLORS.favorite)] + assert colored == [(GLYPH_TAG, TREE_COLORS.favorite)] class TestControlLock: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index adce97f48..fac7f2a64 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -86,6 +86,7 @@ def _panel( each gesture is read from what its hook receives. """ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._cell_kinds = {} panel._shortcuts = shipped_source() panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn)) panel._current_row_count = ROW_COUNT diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py new file mode 100644 index 000000000..19ccb6ab3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py @@ -0,0 +1,323 @@ +from types import SimpleNamespace +from typing import Any, Dict, FrozenSet, List, Optional, Tuple + +import pytest + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.display import CellKey, CellKinds +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel, ThemeKey +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.view_model.sequencer.channels import ( + SequencerChannelsViewModel, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.tracker import ( + SequencerCellViewModel, + SequencerRowViewModel, + SequencerTrackerViewModel, +) +from sampletones_application.view_model.sequencer.voices import VoiceKind +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import display_id, display_transpose, display_volume +from sampletones_shared.types.application import Sender + +ROW_COUNT = 2 +MUTED_TEXT_FRACTION = 0.25 + +THEME_IDS: Dict[ThemeKey, int] = { + (SubColumn.VOICE, None): 10, + (SubColumn.VOICE, VoiceKind.SAMPLE): 11, + (SubColumn.VOICE, VoiceKind.INSTRUMENT): 12, + (SubColumn.TRANSPOSE, None): 13, + (SubColumn.VOLUME, None): 14, +} +MUTED_THEME_IDS: Dict[ThemeKey, int] = {theme_key: theme + 100 for theme_key, theme in THEME_IDS.items()} + +TEXT_COLORS = SimpleNamespace( + voice=LiteralColor((118, 122, 142, 255)), + sample=LiteralColor((224, 200, 96, 255)), + instrument=LiteralColor((255, 112, 223, 255)), + transpose=LiteralColor((192, 192, 192, 255)), + volume=LiteralColor((100, 220, 100, 255)), +) + + +def _cell_widget(key: CellKey) -> int: + """A stable stand-in widget id per cell.""" + row_index, channel, subcolumn = key + column = 0 if channel is None else ChannelName.items().index(channel) + 1 + return 1000 + 100 * column + 10 * row_index + list(SubColumn).index(subcolumn) + + +def _keys() -> List[CellKey]: + return [ + (row_index, channel, subcolumn) + for row_index in range(ROW_COUNT) + for channel in (None, *ChannelName.items()) + for subcolumn in SubColumn + ] + + +def _panel( + *, + cell_kinds: Optional[CellKinds] = None, + muted: FrozenSet[ChannelName] = frozenset(), +) -> GUISequencerTrackerPanel: + """Builds a panel around the state a cell's theme is read from, with no DearPyGui context.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = SimpleNamespace( + colors=SimpleNamespace(text=TEXT_COLORS), + tracker=SimpleNamespace(muted_text_fraction=MUTED_TEXT_FRACTION), + ) + panel._cell_kinds = dict(cell_kinds or {}) + panel._subcolumn_themes = dict(THEME_IDS) + panel._muted_subcolumn_themes = dict(MUTED_THEME_IDS) + panel._current_channels = SequencerChannelsViewModel(muted=muted) + panel._current_row_count = ROW_COUNT + panel._editable_cells = EditableCells() + for key in _keys(): + panel._editable_cells.register(key, _cell_widget(key)) + + return panel + + +@pytest.fixture +def bound(monkeypatch: pytest.MonkeyPatch) -> Dict[Sender, int]: + """The theme each widget was last bound to.""" + themes: Dict[Sender, int] = {} + monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", themes.__setitem__) + return themes + + +def _view_model(rows: Tuple[SequencerRowViewModel, ...]) -> SequencerTrackerViewModel: + return SequencerTrackerViewModel(frame_index=0, frame_count=1, rows=rows) + + +def _row( + index: int, + *, + named: Optional[Tuple[ChannelName, VoiceKind]] = None, +) -> SequencerRowViewModel: + """A row whose channels stand empty, save one naming a voice of the given kind.""" + cells = { + channel: SequencerCellViewModel( + voice=display_id(None), + transpose=display_transpose(None), + volume=display_volume(None), + kind=None, + ) + for channel in ChannelName.items() + } + sample_channels: FrozenSet[ChannelName] = frozenset() + if named is not None: + channel, kind = named + cells[channel] = SequencerCellViewModel( + voice=display_id(0), + transpose=display_transpose(None), + volume=display_volume(None), + kind=kind, + ) + if kind is VoiceKind.SAMPLE: + sample_channels = frozenset({channel}) + + return SequencerRowViewModel(index=index, cells=cells, sample_channels=sample_channels) + + +class TestWhatColourAVoiceSlotWears: + """The slot takes the colour of the kind standing in it, so the grid reports what it holds.""" + + def test_a_slot_naming_nothing_takes_the_neutral_shade(self) -> None: + panel = _panel() + + theme = panel._cell_theme((0, ChannelName.PULSE1, SubColumn.VOICE)) + + assert theme == THEME_IDS[(SubColumn.VOICE, None)] + + @pytest.mark.parametrize( + "kind", + [VoiceKind.SAMPLE, VoiceKind.INSTRUMENT], + ids=lambda kind: kind.value, + ) + def test_a_slot_naming_a_voice_takes_that_kinds_colour(self, kind: VoiceKind) -> None: + key = (0, ChannelName.PULSE1, SubColumn.VOICE) + panel = _panel(cell_kinds={key: kind}) + + assert panel._cell_theme(key) == THEME_IDS[(SubColumn.VOICE, kind)] + + def test_the_sample_column_takes_the_kind_its_own_slot_names(self) -> None: + key = (0, None, SubColumn.VOICE) + panel = _panel(cell_kinds={key: VoiceKind.SAMPLE}) + + assert panel._cell_theme(key) == THEME_IDS[(SubColumn.VOICE, VoiceKind.SAMPLE)] + + @pytest.mark.parametrize( + "subcolumn", + [SubColumn.TRANSPOSE, SubColumn.VOLUME], + ids=lambda subcolumn: subcolumn.value, + ) + def test_the_other_slots_keep_their_own_colour(self, subcolumn: SubColumn) -> None: + """A pitch and a volume mean the same whatever voice sounds them.""" + panel = _panel(cell_kinds={(0, ChannelName.PULSE1, SubColumn.VOICE): VoiceKind.INSTRUMENT}) + + theme = panel._cell_theme((0, ChannelName.PULSE1, subcolumn)) + + assert theme == THEME_IDS[(subcolumn, None)] + + def test_a_silenced_channel_dims_the_kind_it_names(self) -> None: + key = (0, ChannelName.TRIANGLE, SubColumn.VOICE) + panel = _panel( + cell_kinds={key: VoiceKind.INSTRUMENT}, + muted=frozenset({ChannelName.TRIANGLE}), + ) + + assert panel._cell_theme(key) == MUTED_THEME_IDS[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] + + def test_the_sample_column_is_never_silenced(self) -> None: + """It speaks for every channel, so no one channel's mute reaches it.""" + key = (0, None, SubColumn.VOICE) + panel = _panel( + cell_kinds={key: VoiceKind.SAMPLE}, + muted=frozenset(ChannelName.items()), + ) + + assert panel._cell_theme(key) == THEME_IDS[(SubColumn.VOICE, VoiceKind.SAMPLE)] + + +class TestWhichKindsTheGridReads: + def test_every_voice_slot_is_covered(self) -> None: + panel = _panel() + + cell_kinds = panel._compute_cell_kinds(_view_model((_row(0), _row(1)))) + + assert set(cell_kinds) == {key for key in _keys() if key[2] is SubColumn.VOICE} + + def test_a_named_channel_reports_its_voices_kind(self) -> None: + panel = _panel() + + cell_kinds = panel._compute_cell_kinds( + _view_model((_row(0, named=(ChannelName.PULSE2, VoiceKind.INSTRUMENT)),)), + ) + + assert cell_kinds[(0, ChannelName.PULSE2, SubColumn.VOICE)] is VoiceKind.INSTRUMENT + + def test_the_sample_column_reports_the_rows_own_kind(self) -> None: + panel = _panel() + + cell_kinds = panel._compute_cell_kinds( + _view_model((_row(0, named=(ChannelName.PULSE1, VoiceKind.SAMPLE)),)), + ) + + assert cell_kinds[(0, None, SubColumn.VOICE)] is VoiceKind.SAMPLE + + def test_an_instrument_leaves_the_sample_column_stating_nothing(self) -> None: + panel = _panel() + + cell_kinds = panel._compute_cell_kinds( + _view_model((_row(0, named=(ChannelName.PULSE1, VoiceKind.INSTRUMENT)),)), + ) + + assert cell_kinds[(0, None, SubColumn.VOICE)] is None + + +class TestWhatARefreshRebinds: + """A refresh re-themes the slots an edit changed, which is what keeps its cost with the edit.""" + + def test_a_changed_kind_rebinds_its_cell(self, bound: Dict[Sender, int]) -> None: + key = (0, ChannelName.PULSE1, SubColumn.VOICE) + panel = _panel() + + panel._reconcile_cell_kinds({key: VoiceKind.SAMPLE}) + + assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, VoiceKind.SAMPLE)] + assert panel._cell_kinds[key] is VoiceKind.SAMPLE + + def test_an_unchanged_kind_leaves_its_cell_alone(self, bound: Dict[Sender, int]) -> None: + key = (0, ChannelName.PULSE1, SubColumn.VOICE) + panel = _panel(cell_kinds={key: VoiceKind.SAMPLE}) + + panel._reconcile_cell_kinds({key: VoiceKind.SAMPLE}) + + assert not bound + + def test_only_the_changed_cells_are_rebound(self, bound: Dict[Sender, int]) -> None: + moved = (0, ChannelName.PULSE1, SubColumn.VOICE) + standing = (1, ChannelName.NOISE, SubColumn.VOICE) + panel = _panel(cell_kinds={moved: VoiceKind.SAMPLE, standing: VoiceKind.SAMPLE}) + + panel._reconcile_cell_kinds({moved: VoiceKind.INSTRUMENT, standing: VoiceKind.SAMPLE}) + + assert set(bound) == {_cell_widget(moved)} + + def test_a_voice_taken_out_returns_its_cell_to_the_neutral_shade(self, bound: Dict[Sender, int]) -> None: + key = (0, ChannelName.PULSE1, SubColumn.VOICE) + panel = _panel(cell_kinds={key: VoiceKind.SAMPLE}) + + panel._reconcile_cell_kinds({key: None}) + + assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, None)] + + +class TestWhatAnEditShowsAtOnce: + """The number and the colour are written together, so a typed voice reads whole in one frame.""" + + def test_a_placed_voice_takes_its_colour_with_its_number(self, bound: Dict[Sender, int]) -> None: + panel = _panel() + key = (0, ChannelName.PULSE1, SubColumn.VOICE) + + panel._show_voice(0, ChannelName.PULSE1, display_id(3), VoiceKind.INSTRUMENT) + + assert panel._editable_cells.values[key] == display_id(3) + assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] + + def test_a_cleared_slot_drops_its_number_and_its_colour(self, bound: Dict[Sender, int]) -> None: + key = (0, ChannelName.PULSE1, SubColumn.VOICE) + panel = _panel(cell_kinds={key: VoiceKind.SAMPLE}) + panel._editable_cells.values[key] = display_id(3) + + panel._forget_voice(0, ChannelName.PULSE1) + + assert key not in panel._editable_cells.values + assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, None)] + + +class TestWhichThemesAreBuilt: + @staticmethod + def _built(monkeypatch: pytest.MonkeyPatch) -> Tuple[GUISequencerTrackerPanel, List[BaseColor]]: + colors: List[BaseColor] = [] + + def _record(color: BaseColor, *_arguments: Any) -> int: + colors.append(color) + return len(colors) + + monkeypatch.setattr(tracker_module, "create_selectable_text_theme", _record) + panel = _panel() + panel._subcolumn_themes = {} + panel._muted_subcolumn_themes = {} + panel._create_subcolumn_themes() + return panel, colors + + def test_the_voice_slot_is_built_in_a_colour_for_each_kind(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, _ = self._built(monkeypatch) + + voice_themes = {theme_key for theme_key in panel._subcolumn_themes if theme_key[0] is SubColumn.VOICE} + + assert voice_themes == { + (SubColumn.VOICE, None), + (SubColumn.VOICE, VoiceKind.SAMPLE), + (SubColumn.VOICE, VoiceKind.INSTRUMENT), + } + + def test_each_kind_is_built_in_its_own_colour(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, colors = self._built(monkeypatch) + + sample = colors[panel._subcolumn_themes[(SubColumn.VOICE, VoiceKind.SAMPLE)] - 1] + instrument = colors[panel._subcolumn_themes[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] - 1] + + assert (sample.rgba, instrument.rgba) == (TEXT_COLORS.sample.rgba, TEXT_COLORS.instrument.rgba) + + def test_every_theme_has_a_dimmed_twin(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, _ = self._built(monkeypatch) + + assert set(panel._muted_subcolumn_themes) == set(panel._subcolumn_themes) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index 3e07b4a22..546d517d6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -10,7 +10,7 @@ from sampletones_application.ui.panels.sequencer import channels as channels_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.columns import tracker_table_column -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel, ThemeKey from sampletones_application.utils.gui.keyboard.modifiers import ( CTRL, NO_MODIFIERS, @@ -41,15 +41,15 @@ HEADER_THEME = 1 MUTED_HEADER_THEME = 2 -SUBCOLUMN_THEMES: Dict[SubColumn, int] = { - SubColumn.VOICE: 10, - SubColumn.TRANSPOSE: 11, - SubColumn.VOLUME: 12, +SUBCOLUMN_THEMES: Dict[ThemeKey, int] = { + (SubColumn.VOICE, None): 10, + (SubColumn.TRANSPOSE, None): 11, + (SubColumn.VOLUME, None): 12, } -MUTED_SUBCOLUMN_THEMES: Dict[SubColumn, int] = { - SubColumn.VOICE: 20, - SubColumn.TRANSPOSE: 21, - SubColumn.VOLUME: 22, +MUTED_SUBCOLUMN_THEMES: Dict[ThemeKey, int] = { + (SubColumn.VOICE, None): 20, + (SubColumn.TRANSPOSE, None): 21, + (SubColumn.VOLUME, None): 22, } @@ -106,6 +106,7 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: ) panel._current_channels = SequencerChannelsViewModel(muted=muted) panel._current_row_count = ROW_COUNT + panel._cell_kinds = {} panel._header_theme = HEADER_THEME panel._muted_header_theme = MUTED_HEADER_THEME panel._subcolumn_themes = dict(SUBCOLUMN_THEMES) @@ -315,7 +316,7 @@ def test_each_subcolumn_keeps_its_own_hue_when_dimmed(self, recorder: _DearPyGui for subcolumn in SubColumn: widget = _cell_widget(ChannelName.NOISE, 0, subcolumn) - assert recorder.bound_themes[widget] == MUTED_SUBCOLUMN_THEMES[subcolumn] + assert recorder.bound_themes[widget] == MUTED_SUBCOLUMN_THEMES[(subcolumn, None)] def test_unmuting_restores_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None: panel = _panel(frozenset({ChannelName.NOISE})) @@ -324,7 +325,7 @@ def test_unmuting_restores_the_full_theme(self, recorder: _DearPyGuiRecorder) -> panel.update_channels(SequencerChannelsViewModel(muted=frozenset())) widget = _cell_widget(ChannelName.NOISE, 1, SubColumn.VOLUME) - assert recorder.bound_themes[widget] == SUBCOLUMN_THEMES[SubColumn.VOLUME] + assert recorder.bound_themes[widget] == SUBCOLUMN_THEMES[(SubColumn.VOLUME, None)] class TestCuesAwaitTheTable: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py index c130280ef..428dd4fe1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -31,6 +31,7 @@ class Panel: def __init__(self) -> None: self.panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) self.panel._editable_cells = EditableCells() + self.panel._cell_kinds = {} self.panel._current_samples = SequencerVoicesViewModel( voices=( VoiceEntryViewModel( @@ -67,6 +68,10 @@ def shown(self, channel: Optional[ChannelName]) -> str: """The label the cell cache holds, which is what the cell shows once the commit settles.""" return self.panel._editable_cells.values.get((0, channel, SubColumn.VOICE), STORED_LABEL) + def kind(self, channel: Optional[ChannelName]) -> Optional[VoiceKind]: + """The kind the cell cache holds, which is the colour the slot takes with its number.""" + return self.panel._cell_kinds.get((0, channel, SubColumn.VOICE)) + @pytest.fixture def panel() -> Panel: @@ -121,3 +126,38 @@ def test_typing_into_an_empty_pool_names_no_voice(self, panel: Panel) -> None: assert panel.writes == [(0, ChannelName.PULSE1, None)] assert panel.shown(ChannelName.PULSE1) == STORED_LABEL + + +class TestWhatColourATypedVoiceTakes: + """The cell takes the kind with the number, so a typed voice reads whole before the project answers.""" + + def test_a_typed_sample_takes_the_sample_kind(self, panel: Panel) -> None: + panel.type_voice(SAMPLE_INDEX, ChannelName.PULSE1) + + assert panel.kind(ChannelName.PULSE1) is VoiceKind.SAMPLE + + def test_a_typed_instrument_takes_the_instrument_kind(self, panel: Panel) -> None: + panel.type_voice(INSTRUMENT_INDEX, ChannelName.NOISE) + + assert panel.kind(ChannelName.NOISE) is VoiceKind.INSTRUMENT + + def test_a_refused_voice_leaves_the_cell_its_own_kind(self, panel: Panel) -> None: + """Nothing is written, so the slot keeps the colour it already wore.""" + panel.type_voice(INSTRUMENT_INDEX, None) + + assert panel.kind(None) is None + + def test_a_cut_cell_states_no_kind(self, panel: Panel) -> None: + panel.panel.on_set_note_off = lambda row, channel: None + panel.panel._handle_edit_action( + EditAction( + row=0, + channel=ChannelName.TRIANGLE, + sample_index=None, + transpose=None, + volume=None, + note_off=True, + ) + ) + + assert panel.kind(ChannelName.TRIANGLE) is None diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py index d011a4305..92e9d4112 100644 --- a/tests/unit/sampletones_application/utils/gui/test_palette.py +++ b/tests/unit/sampletones_application/utils/gui/test_palette.py @@ -107,7 +107,7 @@ def test_recolouring_one_argument_leaves_one_entry( context: None, accent: BaseColor, ) -> None: - """A hovered item is recoloured on every frame it is under the pointer.""" + """A hovered item is recolored on every frame it is under the pointer.""" item = _add_text() for _ in range(5): diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index f10f695f8..595924e32 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -222,3 +222,69 @@ def test_sample_column_aggregates_over_the_channels_it_spans( assert row.sample == case.expected_sample assert row.transpose == case.expected_transpose assert row.volume == case.expected_volume + + +class TestWhichKindTheSampleColumnNames: + """The slot's kind is what colours it, so it states one only where its channels agree.""" + + @staticmethod + def _row( + cells: Dict[ChannelName, SequencerCellViewModel], + sample_channels: FrozenSet[ChannelName], + ) -> SequencerRowViewModel: + return SequencerRowViewModel( + index=0, + cells=cells, + sample_channels=sample_channels, + ) + + def test_a_row_naming_nothing_states_no_kind(self) -> None: + row = self._row(_row_cells(), frozenset()) + + assert row.sample_kind is None + + def test_a_sample_across_its_channels_states_the_sample_kind(self) -> None: + row = self._row( + _row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED), + frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}), + ) + + assert row.sample_kind is VoiceKind.SAMPLE + + def test_a_sample_missing_from_one_of_its_channels_states_no_kind(self) -> None: + """The reading is mixed there, and a mixed cell speaks for no one voice.""" + row = self._row( + _row_cells(pulse1=_OCCUPIED), + frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}), + ) + + assert row.sample_kind is None + + def test_an_instrument_alone_on_a_row_states_no_kind(self) -> None: + """It is placed in its own channel column, so the sample column speaks for none of it.""" + row = self._row( + _row_cells(pulse1=_cell(voice=display_id(3), kind=VoiceKind.INSTRUMENT)), + frozenset(), + ) + + assert row.sample_kind is None + + def test_an_instrument_beside_a_sample_leaves_the_sample_kind_standing(self) -> None: + row = self._row( + _row_cells( + pulse1=_OCCUPIED, + noise=_cell(voice=display_id(3), kind=VoiceKind.INSTRUMENT), + ), + frozenset({ChannelName.PULSE1}), + ) + + assert row.sample_kind is VoiceKind.SAMPLE + + def test_a_cut_row_states_no_kind(self) -> None: + """A cut names no voice, so the slot reads it in the shade an empty one takes.""" + row = self._row( + {channel: _cell(voice=NOTE_OFF) for channel in ChannelName.items()}, + frozenset(), + ) + + assert row.sample_kind is None From c22487b79a45807dc45636068affd957988cc60a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 10:08:36 +0200 Subject: [PATCH 105/142] Updated: documentation --- conftest.py | 2 +- docs/concepts/compression.md | 2 +- docs/concepts/instruction-library.md | 6 +- docs/concepts/reconstruction.md | 10 +-- docs/development/architecture.md | 34 ++++---- docs/development/browser.md | 12 +-- docs/development/bugs-and-todos.md | 8 ++ docs/development/config-organization.md | 8 +- docs/development/dependencies.md | 8 +- docs/development/guidelines.md | 12 +-- docs/development/playback.md | 18 ++-- docs/development/player.md | 4 +- docs/development/sequencer-blocks.md | 8 +- docs/formats/bitphase.md | 2 +- docs/formats/famitracker.md | 55 ++++++++++-- docs/formats/instruction-libraries.md | 2 +- docs/formats/nsf.md | 2 +- docs/glossary.md | 14 ++- docs/guide/configuration.md | 2 +- docs/guide/interface.md | 26 +++--- docs/guide/sequencer.md | 85 +++++++++++++------ docs/index.md | 4 +- .../config/session/application/config.py | 2 +- .../coordinators/tabs/sequencer.py | 2 +- .../reconstruction/browser/tree/collapse.py | 2 +- .../logic/sequencer/history_detail.py | 2 +- .../logic/sequencer/order/reader.py | 2 +- .../logic/sequencer/playback/song_player.py | 2 +- .../logic/sequencer/tracker/reader.py | 17 +++- .../view_model/sequencer/order.py | 2 +- src/sampletones_core/exporters/lengths.py | 2 +- .../reconstructor/decoder/greedy.py | 2 +- tests/suite/application.py | 10 ++- tests/suite/language.py | 2 +- tests/suite/sequencer.py | 4 +- .../coordinators/tabs/test_sequencer.py | 11 ++- .../logic/sequencer/tracker/test_tracker.py | 2 +- .../services/test_regeneration.py | 2 +- .../sampletones_application/test_startup.py | 2 +- .../sequencer/test_tracker_context_menu.py | 7 +- .../exporters/test_exporter.py | 2 +- 41 files changed, 257 insertions(+), 144 deletions(-) diff --git a/conftest.py b/conftest.py index 1fee4a4cc..48d3aa476 100644 --- a/conftest.py +++ b/conftest.py @@ -18,7 +18,7 @@ def pytest_ignore_collect(collection_path: Path) -> Optional[bool]: Keeps collection to the modules the running platform imports. ``jeepney`` is declared for Linux alone, so what speaks to the desktop portal is collected - where that library is installed. The behaviour those modules describe belongs to the Linux + where that library is installed. The behavior those modules describe belongs to the Linux desktop, and the Linux runs of the suite cover it. Args: diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md index 8c805d380..46d506202 100644 --- a/docs/concepts/compression.md +++ b/docs/concepts/compression.md @@ -112,7 +112,7 @@ So the encoder does not pick a reading by rules of thumb; it searches for the ch one. The plane becomes a graph: each tick is a node, each token that could start there is an edge to the tick after the ones it covers, and the edge's weight is the bytes that token takes. **The cheapest path across the plane is its encoding** — and because -the weights are bytes, the search optimises the very quantity that has to fit in the +the weights are bytes, the search optimizes the very quantity that has to fit in the program area. ### 4.1 The edges diff --git a/docs/concepts/instruction-library.md b/docs/concepts/instruction-library.md index 65aba3baa..dc6a0e381 100644 --- a/docs/concepts/instruction-library.md +++ b/docs/concepts/instruction-library.md @@ -1,13 +1,13 @@ # Instruction library -An instruction library is the catalogue of NES sounds that _SampleToNES_ +An instruction library is the catalog of NES sounds that _SampleToNES_ searches when it reconstructs audio. It holds every instruction a channel can play — each combination of pitch, volume, timbre and on/off state — together with the waveform that instruction produces and a description of its frequency -content. Reconstruction is then a matter of searching this catalogue: for each +content. Reconstruction is then a matter of searching this catalog: for each slice of the input, the engine looks for the library entries whose combined sound is closest to that slice. [Reconstruction algorithms](reconstruction.md) -describes that search; this page describes the catalogue it searches. +describes that search; this page describes the catalog it searches. ## Why the library is precomputed diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 7ca07a486..2b6d1a229 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -30,12 +30,12 @@ models live in `sampletones_core.generators` and the instruction value types in Reconstruction is a **search problem**. The input is cut into short, fixed-length frames, and within each frame at most one instruction per channel is in effect. For -every frame the system must pick, from a large but finite catalogue of NES +every frame the system must pick, from a large but finite catalog of NES waveforms, the combination of instructions whose mixed output best matches that slice of audio. Two ingredients define the system: - a **criterion** that scores how well a candidate matches the target (§4), and -- a **selection strategy** that searches the catalogue efficiently (§5). +- a **selection strategy** that searches the catalog efficiently (§5). Everything is compared in a perceptually-weighted **frequency** representation rather than raw samples, because two sounds that are perceptually identical can @@ -72,7 +72,7 @@ and playback. ## 3. Representing a frame -### 3.1 The candidate catalogue (library) +### 3.1 The candidate catalog (library) Before any reconstruction, `sampletones_core.library` precomputes a **library**: for every possible instruction it renders the waveform its generator produces and stores @@ -116,7 +116,7 @@ sharper frequency resolution requires a longer time window, and vice versa): milliseconds), so brief events are smeared in time at the low end. _SampleToNES_ computes the CQT **once over the whole signal** with a hop of one frame (`calculate_cqt_spectrum_columns`), so each frame's energy is reported at its own - time position and the per-frame columns line up with the FFT path's frame centres. + time position and the per-frame columns line up with the FFT path's frame centers. The target and the library candidates are always described by the *same* method, so their features are directly comparable bin by bin. All three methods share one scale @@ -303,7 +303,7 @@ Package map: | NES channel models | `sampletones_core.generators` | | instruction value types | `sampletones_core.instructions` | | windowing, spectra, features | `sampletones_core.fft` | -| candidate catalogue | `sampletones_core.library` | +| candidate catalog | `sampletones_core.library` | | scoring | `sampletones_core.reconstructions.criterion` | | selection + assembly | `sampletones_core.reconstructions.reconstructor` | | audio I/O and level | `sampletones_core.audio` | diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 7fd45ff67..965d406a9 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -1,6 +1,6 @@ # Application Architecture -This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs. +This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honor, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs. Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, the YAML configuration package has `docs/development/config-organization.md`, and the packages the repository divides into have `docs/development/packages.md`. @@ -99,7 +99,7 @@ language_manager[ ### 9. `tags/` holds only DPG identifiers -The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole tags, and `SUF_*`/`PRE_*` fragments that compose into them. Dimensions, colours, timings, and display strings live in YAML configuration loaded at startup (`layout/`). +The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole tags, and `SUF_*`/`PRE_*` fragments that compose into them. Dimensions, colors, timings, and display strings live in YAML configuration loaded at startup (`layout/`). **`compose_tag` is the one composer.** `tags/compose.py` owns `TAG_SEPARATOR` and the joiner; every tag reaches its final spelling through it. Each part is lowercased and its whitespace runs become single underscores, so a tag built from a runtime name — a sample title, a layer label — reads the same however that name arrives cased or spaced, and a part already holding a composed tag contributes its own segments, which is how a child tag extends its parent. Fragments hold bare segments (`SUF_GRAPH_PLOT = "plot"`) and gain separators only from the joiner, so a fragment reads as the segment it names and either end composes onto it. @@ -126,7 +126,7 @@ A new exclusive operation joins by contributing its `is_active` to the authority ### 11. Platform and external-tool differences hide behind a backend Protocol -Where behaviour depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`locate_program`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. +Where behavior depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`locate_program`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. `utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector, reports the one the user picked, and is told which window a dialog belongs to, since the desktop draws it in another process, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. @@ -138,7 +138,7 @@ DearPyGui gives every key handler the same global reach and no way for one to st Each keyboard consumer registers one scope through `register(handle, *, priority, active)`, where `active()` reports whether the scope wants keys at this moment and `handle(event) -> bool` acts on the press and reports whether it claimed it. Three priorities order the whole application: -| Priority | Scope | Active when | Behaviour | +| Priority | Scope | Active when | Behavior | |----------|-------|-------------|-----------| | `MODAL` (100) | the open dialog's navigator | a modal dialog holds the keyboard | routes Tab/Enter/Escape to the dialog's focus ring and claims every press, so a dialog owns the keyboard exclusively while it is shown | | `PANEL` (60) | a sequencer sub-panel (grid / order / samples) | its tab is in front and that sub-panel holds the cursor or selection | handles its tracker keys and yields the combinations it does not own so a higher-reaching shortcut still wins | @@ -162,21 +162,21 @@ A preference layers over the shipped scheme. `ShortcutsConfig` holds the scheme **A scheme is edited through a draft.** `ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched — the combination each was given, or nothing where it was left unbound — so what reaches the preference is those actions alone while every other key follows the scheme beneath. An assignment displaces: giving an action a combination its category already answers takes the key from the holder in the same step, which is what makes every scheme a draft produces a valid one, and the dialog names the holder and asks before that step is taken. The draft is what the dialog edits, and a commit is what activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. -**A scheme belongs to a platform; an action does not.** `ShortcutId` and `ShortcutCategory` are the same on every platform, and `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) states which scheme each one ships — the choice a profile makes once, at creation, after which the stored name selects. The modifier table reads every spelling on every platform while `Modifier.SUPER` displays as the name the machine is labelled with, so a scheme written for one keyboard loads, validates and reads on another, and the completeness validation holds every shipped scheme to the same action set. +**A scheme belongs to a platform; an action does not.** `ShortcutId` and `ShortcutCategory` are the same on every platform, and `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) states which scheme each one ships — the choice a profile makes once, at creation, after which the stored name selects. The modifier table reads every spelling on every platform while `Modifier.SUPER` displays as the name the machine is labeled with, so a scheme written for one keyboard loads, validates and reads on another, and the completeness validation holds every shipped scheme to the same action set. The router is constructed at the composition root and injected into every consumer (principle 7); its one global handler is bound in `shell.py` once the DPG context exists. -### 13. A colour is a token, resolved where it is drawn +### 13. A color is a token, resolved where it is drawn -A colour is written as a palette token and stays one until it reaches DearPyGui. `BaseColor` (`utils/palette/colors/`) carries what was written, and its `rgba` property answers with the palette active at the moment of the read, so whoever holds the colour follows a palette swap. Every annotation names `BaseColor` — a dataclass field, a signature, a dictionary key — and `WrittenColor` appears only on the Pydantic field that validates a YAML entry. The read happens where the value is handed to a widget, and what a consumer keeps is the token. +A color is written as a palette token and stays one until it reaches DearPyGui. `BaseColor` (`utils/palette/colors/`) carries what was written, and its `rgba` property answers with the palette active at the moment of the read, so whoever holds the color follows a palette swap. Every annotation names `BaseColor` — a dataclass field, a signature, a dictionary key — and `WrittenColor` appears only on the Pydantic field that validates a YAML entry. The read happens where the value is handed to a widget, and what a consumer keeps is the token. A shade is composed by naming its form. `utils/palette/colors/` is a flat star: `base.py` declares the abstract `rgba`, and each form is a peer module beside it (`literal`, `named`, `faded`, `grayscale`, `blended`, `layered`), answering with a `BaseColor` of its own — `FadedColor(color=GrayscaleColor(color=token), fraction=0.3)`. Every form is a module-level frozen dataclass, so two identical compositions are one value and a theme cache keyed on a shade hits. -What DearPyGui has already taken a copy of is registered rather than remembered by whoever set it. `PaletteBindings` (`utils/gui/palette/`) records each `(item, argument)` a palette colour reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a colour gets there. A palette change is then one switch: `PaletteSource.activate` fires the composition root's listener, which re-applies the bindings, refreshes the viewport clear colour, and repaints the sequencer for the row and cell highlights DearPyGui holds as table state. The `palette-colors` hook holds all three rules (see Enforcement). +What DearPyGui has already taken a copy of is registered rather than remembered by whoever set it. `PaletteBindings` (`utils/gui/palette/`) records each `(item, argument)` a palette color reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a color gets there. A palette change is then one switch: `PaletteSource.activate` fires the composition root's listener, which re-applies the bindings, refreshes the viewport clear color, and repaints the sequencer for the row and cell highlights DearPyGui holds as table state. The `palette-colors` hook holds all three rules (see Enforcement). ### 14. An action is declared once; whoever shows it prints it -An **action** is one `ShortcutId` — the name a key press, a menu item and a context item all reach one behaviour by. Declaring one is a chain of four links, and the `shortcut-actions` check holds every one of them (see Enforcement): +An **action** is one `ShortcutId` — the name a key press, a menu item and a context item all reach one behavior by. Declaring one is a chain of four links, and the `shortcut-actions` check holds every one of them (see Enforcement): | Link | Where | What it states | |------|-------|----------------| @@ -185,7 +185,7 @@ An **action** is one `ShortcutId` — the name a key press, a menu item and a co | Its call | `shell.py` — a `ShortcutBindings` field and the entry naming it in the binding map, or membership of `FAMILY_SHORTCUT_IDS` | the one call the action makes | | Its label | a `KeybindingActionElements` member and its `en.yaml` entry | how the keybindings editor lists it | -Two kinds of action state their call differently, and the check knows both. One that a whole enum parameterises — an export item per format, an item per channel — is a **family**: a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. A family is *declared*, not recognised: `FAMILY_SHORTCUT_IDS` names the mappings that are ones, so what excuses an action from stating a call of its own is written down rather than inferred from the shape of a dictionary — `SHORTCUT_IDS_BY_NAME` answers with every action and is deliberately not among them. A **panel-scope** action states no call at all, because its key scope (principle 12) acts on the press itself. A `DIALOG` action is named nowhere in the editor, since a dialog is operated by the keys its category holds. +Two kinds of action state their call differently, and the check knows both. One that a whole enum parameterises — an export item per format, an item per channel — is a **family**: a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. A family is *declared*, not recognized: `FAMILY_SHORTCUT_IDS` names the mappings that are ones, so what excuses an action from stating a call of its own is written down rather than inferred from the shape of a dictionary — `SHORTCUT_IDS_BY_NAME` answers with every action and is deliberately not among them. A **panel-scope** action states no call at all, because its key scope (principle 12) acts on the press itself. A `DIALOG` action is named nowhere in the editor, since a dialog is operated by the keys its category holds. **A menu item is a view of an action, never a second declaration of it.** `ShortcutManager.add_menu_item(shortcut_id, ...)` is how a menu names one: it takes both the accelerator and the call from the action, and keeps the item under it, so a rebind re-prints the key already on screen. An item passes a `callback` of its own only where it carries a state to show, and then that call is the one switching the state it shows. @@ -208,7 +208,7 @@ Two mechanisms keep the codebase aligned with this document. | `language-keys` | `language_keys.py` | Code and `en.yaml` against each other, in both directions: a literal key names an entry, every entry is reached by some lookup, and a lookup states values the check can read (principle 8) | | `tag-names` | `tag_names.py` | A tag constant's name against the tag it composes (principle 9) | | `unused-tags` | `unused_tags.py` | Every `TAG_*`/`SUF_*`/`PRE_*` the `tags/` package declares against the reads of it across `src/`, `tests/`, and `scripts/`, where an import alone stands at no reads | -| `palette-colors` | `palette_colors.py` | A colour as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme colour filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) | +| `palette-colors` | `palette_colors.py` | A color as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme color filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) | | `shortcut-actions` | `shortcut_actions.py` | Every action against the links it needs: a combination in every shipped scheme, a name the keybindings editor lists it by, and — for an application-scope action — the call it makes, whether its own binding or a family (principle 14) | They read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members; the palette and shortcut checks read the shipped YAML beside it. That layer derives each package directory from its own location and reports a root it finds nothing at, so a check that sweeps nothing fails loudly where it would otherwise pass clean. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames. @@ -239,7 +239,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m |------|------| | `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar, and `MenuSection` — a run of menu items restated each time its menu is opened | | `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold), the `card()` context manager and the `well()` inset region, driven declaratively by tab coordinators | -| `ui/panels/` | Domain-level composite panels, organised by feature area | +| `ui/panels/` | Domain-level composite panels, organized by feature area | | `ui/themes/` | DPG themes and per-widget style helpers | | `ui/resources/` | Icons and image resources loaded at startup | | `ui/menu.py` | `MenuBar` — the application's top menu bar | @@ -320,11 +320,11 @@ There are two coordinator kinds: *Tab coordinators* own everything for one tab: they instantiate its panels, logic objects, and tab-scoped services, wire their callbacks together, and provide `create_tab()` — the single method that builds the DPG widget tree for that tab. Tab coordinators present a narrow public API of intent-level methods (`set_input_path`, `display_reconstruction`, …) and keep their panels and logic objects private. -`create_tab()` is the sole authority for the tab's layout: it declares the column and card arrangement through the shared `ui/elements/layout` primitives (`TabColumns`, `card()`) and injects each panel's parent container via `create_panel(parent)`. It builds widgets only — initial view population (pushing the first view models, refreshing trees) runs afterwards from the coordinator's post-build initialisation, invoked once the whole tree exists, rather than inside `create_tab()`. +`create_tab()` is the sole authority for the tab's layout: it declares the column and card arrangement through the shared `ui/elements/layout` primitives (`TabColumns`, `card()`) and injects each panel's parent container via `create_panel(parent)`. It builds widgets only — initial view population (pushing the first view models, refreshing trees) runs afterwards from the coordinator's post-build initialization, invoked once the whole tree exists, rather than inside `create_tab()`. **Contracts:** - A coordinator touches DPG only on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog that must wait for the next frame is deferred through `FrameCallbackManager`. All other presentation goes through `DialogsRenderer`. -- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a cancelled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform. +- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a canceled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform. - A coordinator holds no domain state. It delegates reads and writes to the managers and controllers it was given; what it caches is presentation wiring — resolved language strings, panels, logic objects, callbacks. - Callbacks received from `Application` as constructor parameters are stored and forwarded as-is. The one sanctioned wrapper is an intent-level guard that a contract requires — e.g. a busy-authority start-time guard (principle 10) wrapping an operation's entry point. - Error dialogs, confirmations, and notices are presented here, with text resolved from `LanguageManager` here (see the Error Handling Policy). @@ -352,7 +352,7 @@ There are two coordinator kinds: `ApplicationShell.setup()` creates the DPG context, registers shortcuts, binds the `KeyRouter`'s single global key-press handler, builds the main window (menu bar + tab bar + status bar), and starts the `CallbackQueue` worker thread. Tab coordinators are passed to the shell so it can call their `create_tab()` methods in sequence. -**Must not import:** `logic/`, `services/`. The shell reaches domain behaviour only through the coordinators and callbacks it was handed. +**Must not import:** `logic/`, `services/`. The shell reaches domain behavior only through the coordinators and callbacks it was handed. --- @@ -362,10 +362,10 @@ There are two coordinator kinds: |---------|---------| | `config/` | `ConfigManager` (domain generation config), `SessionManager` (runtime session: last paths, audio device, window geometry). Presentation-free: it records load outcomes (`ConfigLoadOutcome`) as domain data for `ConfigCoordinator` to present. Must not import the visual packages, `coordinators/`, or `application.py` | | `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy, the `AbstractElement` base and the panel element enums under `categories/elements/`, and the key grammar under `categories/key/` | -| `constants/` | Application-scope facts that carry no behaviour, one module per subject — `keybindings.py` names the scheme a build ships, which both the shortcut catalog and the session config read, and `playback.py` names the follow mode, which the session config, the song player, the view models and the menu all state. A fact shared beyond the application belongs to `sampletones_shared/constants/` | +| `constants/` | Application-scope facts that carry no behavior, one module per subject — `keybindings.py` names the scheme a build ships, which both the shortcut catalog and the session config read, and `playback.py` names the follow mode, which the session config, the song player, the view models and the menu all state. A fact shared beyond the application belongs to `sampletones_shared/constants/` | | `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` | | `tags/` | DPG widget tags (`TAG_*`), the fragments composing into them (`SUF_*`, `PRE_*`), and `compose_tag` | -| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/backends/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | +| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, color, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/backends/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers | | `viewport.py` | Manages DPG viewport geometry and fullscreen state | --- diff --git a/docs/development/browser.md b/docs/development/browser.md index c8798b023..ce82c113e 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -55,7 +55,7 @@ drive, and `get_all_reconstruction_files` reads the scan. | Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it | | Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree | | Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation configuration headings and names it by its channels, so the rows leading to it spell its display name; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | -| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration | +| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labeled by its configuration | | Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first | | Containers | `tree/containers.py` | `find_or_create_group`, `find_or_create_config_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each heading is looked up among the siblings of its own kind and class, so a folder and an audio sharing a name stay two rows | @@ -95,7 +95,7 @@ configuration is what distinguishes one row from the next. show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk. * **Collapse** (`collapse_single_child_containers`) — a heading standing above a single row folds into that row, which takes the joined name (`DISPLAY_SEPARATOR` between levels) and rises into its place. - The surviving row keeps its node type, path, configuration and children, so its click behaviour, + The surviving row keeps its node type, path, configuration and children, so its click behavior, theme, context menu and favorite star carry over. A fold that would repeat a name already beside it stays open instead, and the branch roots stay in place. With a single configuration present the configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a @@ -122,11 +122,11 @@ The browsers form one line of inheritance, each level owning what it shares: handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states what its card and refresh control read. * `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the - rows the two branches hold, the colour a group and a sample read in, and the context menus. The + rows the two branches hold, the color a group and a sample read in, and the context menus. The Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what opening a reconstruction means in that tab. -The Main tab's filesystem explorer and the Instructions tab's library catalogue sit on +The Main tab's filesystem explorer and the Instructions tab's library catalog sit on `GUIFileBrowserPanel` as well, so the card, the search and the rebuild machinery are shared with them. **A rebuild** starts on the tree worker: `_launch_rebuild` takes the tree lock, brings the model up to @@ -263,10 +263,10 @@ empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no filter's answer reads where the rows would be. **The control** is a checkbox under the search box carrying the favorite glyph, which reads in the -favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states +favorite color while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It follows the tree's lock, a rebuild being what it asks for, and its label reads in the pair every -checkbox reads — the text colour while it can be clicked, the muted one while a rebuild holds it — so +checkbox reads — the text color while it can be clicked, the muted one while a rebuild holds it — so the shade states whether the control is live. Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 6122b38f2..5d58aa503 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -14,6 +14,10 @@ ### Tracker +The first four entries are also what an imported `.fti` reports as left to the file +(section C of `formats/famitracker.md`), so each one closed is a dimension the import +starts carrying. + * Pitch and hi-pitch envelopes: a per-tick period bend, where an instruction's pitch is a whole semitone. Sounding them needs a sub-semitone offset in the instruction model and raw timer values in the NSF planes, which reaches the reconstruction search space, the instruction library and the @@ -26,6 +30,10 @@ * A loop point per envelope: a voice states one point, applied to every populated sequence. * A sample's loop point is offered as a switch in the voice list, though the model carries the point for both kinds of voice. +* A transpose or a volume typed in the sample column of a row holding no sample reaches every + channel. The column summarizes the channels its samples cover, and a row covering none falls + back to all four so a value typed there lands somewhere; the reference slot keeps the narrower + reading and stays empty. ### Workflow diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md index 1ecb9877c..d2994b588 100644 --- a/docs/development/config-organization.md +++ b/docs/development/config-organization.md @@ -47,8 +47,8 @@ on their own terms. [Domains](#domains)). A new domain is a new top-level directory with its own schema owner and loader. -Palettes are a domain of their own because two other domains resolve against them: a colour -field in `layout/` and a colour entry in `theme/` both name a palette token, and the palette +Palettes are a domain of their own because two other domains resolve against them: a color +field in `layout/` and a color entry in `theme/` both name a palette token, and the palette is what turns that name into a value. A directory holds one file per palette, named after the palette it declares, and every palette answers the same token set — an entry names one token and each palette must have an answer for it. @@ -114,7 +114,7 @@ receives a view built for it: `parameters/` package gathers exactly what one tab needs: the shared six-field `TabGeometry` core, the flat integers its primitive sinks consume, and the cohesive feature models it forwards whole (`SchedulingBehavior`, `GraphsLayout`, the tab's own - `Layout`, the colour blocks). A small factory produces any narrowed slice a consumer + `Layout`, the color blocks). A small factory produces any narrowed slice a consumer needs (`TreeColors.create`, `PitchStepperStyle.from_general`). The type signals which side of the boundary a value is on: a frozen Pydantic model with @@ -141,7 +141,7 @@ each value sits in the tree stays in the factory. | Theme | `theme/` | `ThemeSpec` (`sampletones_application/ui/themes/spec.py`) | `ThemeLoader.load_all()` → `ThemeRegistry` | The palettes load first, and the source holding the active one is injected as validation -**context**, so any colour field in layout or theme keeps the token it was written as and +**context**, so any color field in layout or theme keeps the token it was written as and reads its value from the palette in place when it is drawn with. `PaletteCatalog` names the palette a preference selects and answers with the default (`studio`) for a name the build does not ship, so a preference outlives the build that wrote it. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 77f8b7c53..103d68085 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -54,7 +54,7 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser ## Application icon The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in -`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization +`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colors and rasterization settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The package writes the whole suite — the vector `sampletones.svg` and the rasters the application ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` @@ -92,9 +92,9 @@ running `make player` again and committing what it writes; the driver's test sui sources and holds the committed image to them wherever cc65 is installed. The wheel carries the assembled image alone, which is all an installed copy reads. -cc65 is distributed under the zlib licence, and the driver stays clear of it: the link line names +cc65 is distributed under the zlib license, and the driver stays clear of it: the link line names our own object files and our own `nsf.cfg`, so nothing of cc65's start-up code or libraries reaches -the committed image. That keeps the blob entirely ours to ship under the project's MIT licence. +the committed image. That keeps the blob entirely ours to ship under the project's MIT license. ### Verifying the driver @@ -104,7 +104,7 @@ watches the APU's address range, so each routine answers with the register write suite holds the whole run against `RegisterTrace.from_song`. Reading those writes back into instructions and rendering them through the project's own generators closes the loop on the sound as well: what the console plays stands against the very waveform the reconstruction carries. py65 -is a developer dependency, outside both the wheel and the bundles, and its BSD licence leaves the +is a developer dependency, outside both the wheel and the bundles, and its BSD license leaves the project's own terms untouched. Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) carrying the `libgme` demuxer, which diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index b61e0ed30..4d58745b5 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -34,8 +34,8 @@ These rules govern the Python in this repository. They complement 1. If a module contains many class and function definitions, split into a subpackage divided by a single concern. 1. If a private function (or public that does not have any external consumers) serves only a class in the module it lives, move it to the class as a static/class method or isolate helper functions into a separate utility module. 1. Prefer subpackages over a flat directory structure. -1. Isolate platform-, desktop-, or external-tool-specific behaviour behind a `Protocol` with one implementation per target, selected by a runtime factory that probes availability and environment. Callers depend only on the `Protocol` and stay platform-agnostic. -1. Wrap a third-party library or OS tool whose behaviour differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behaviour is warranted there. +1. Isolate platform-, desktop-, or external-tool-specific behavior behind a `Protocol` with one implementation per target, selected by a runtime factory that probes availability and environment. Callers depend only on the `Protocol` and stay platform-agnostic. +1. Wrap a third-party library or OS tool whose behavior differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behavior is warranted there. ## Type Hints @@ -64,7 +64,7 @@ These rules govern the Python in this repository. They complement ## Docstrings and Comments 1. A docstring explains the intention of a class or function and the context of its use. -1. State functionality in positive terms. Describe what a class or function *does* — not what it avoids, omits, skips, differs from, or no longer does. Reframe every negation ("does not", "rather than", "instead of", "without", "never", "cannot", "no longer") into the behaviour that actually happens. Do not contrast with rejected alternatives as justification; the positive statement carries the meaning. +1. State functionality in positive terms. Describe what a class or function *does* — not what it avoids, omits, skips, differs from, or no longer does. Reframe every negation ("does not", "rather than", "instead of", "without", "never", "cannot", "no longer") into the behavior that actually happens. Do not contrast with rejected alternatives as justification; the positive statement carries the meaning. 1. Negative phrasing is allowed only where the condition itself is the contract: exception triggers in `Raises:` clauses, precondition/postcondition bounds (prefer "must be at least X" over "cannot be less than X" where natural), and documented edge-case returns. Outside these concrete cases, negative descriptions are information noise and must be removed. 1. Justify an arbitrary choice in the docstring rather than a code comment, and frame the justification by what the choice achieves. 1. Let clear names carry the meaning, and skip comments or docstrings that restate the code. @@ -93,9 +93,9 @@ These rules govern the Python in this repository. They complement 1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. 1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions. 1. Prefer fixtures over factories, and define shared fixtures in an appropriate place. -1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes, layouts, and the settings a build opens on are tuned freely, so a test that restates one turns every adjustment into a test edit. Assert behaviour instead: validation bounds, serialization round-trips, fallback and recovery paths, and the invariants a value satisfies — a default lies within the range offered, every palette declares the same tokens, every action the application names is answered. -1. **Read a configured value; do not repeat it.** Where a case needs the keys an action answers, a palette's colour, a layout's dimension, or a default a model falls back to, it reads that value from the configuration under test and derives the rest of the case from it. A case that presses a key states which action it is pressing, resolves the combination from the scheme, and keeps passing once that action is rebound. +1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes, layouts, and the settings a build opens on are tuned freely, so a test that restates one turns every adjustment into a test edit. Assert behavior instead: validation bounds, serialization round-trips, fallback and recovery paths, and the invariants a value satisfies — a default lies within the range offered, every palette declares the same tokens, every action the application names is answered. +1. **Read a configured value; do not repeat it.** Where a case needs the keys an action answers, a palette's color, a layout's dimension, or a default a model falls back to, it reads that value from the configuration under test and derives the rest of the case from it. A case that presses a key states which action it is pressing, resolves the combination from the scheme, and keeps passing once that action is rebound. 1. **A literal shipped value needs a stated reason.** Write one only where the value itself is the contract — a file format's constant, a value another system reads back, an interoperability requirement — and say so in the case. Asserting against the named constant that defines the value (`DEFAULT_MAX_FPS`, `DEFAULT_SCHEME_NAME`) states where the value comes from and is welcome; a bare literal standing for the same thing is the pin this rule forbids. 1. Values that must match by contract are asserted to match, never hardcoded — e.g. project metadata at creation or after a save/load round-trip is held against its source, never against a version string. 1. Unit tests may mock system boundaries (file I/O, external services, IPC channels), but must not mock the domain logic that is the subject of the test. Integration tests must exercise real computation pipelines against real (synthetically built) data. -1. When a test expectation diverges from the production code's actual behaviour, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side. +1. When a test expectation diverges from the production code's actual behavior, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side. diff --git a/docs/development/playback.md b/docs/development/playback.md index beca2c541..ac1c9883c 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -22,7 +22,7 @@ a control over what is heard. The contracts here bind every tab and every player given screen every time. 4. **Surfaces describe the target; the transport decides.** The menu, the toolbar, and the keyboard reach identical verbs and report identical state, so a new surface adds another way in to the - same behaviour. + same behavior. 5. **Listening choices stay out of the document.** What the user chooses to hear is session state; what the project holds is the whole song. Saving, export, rendering, and history read the document, so each of them works on the full song whatever the user is listening to. A render @@ -32,10 +32,10 @@ a control over what is heard. The contracts here bind every tab and every player sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a listening control take effect inside the sound already playing. 7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from - the project's tempo and metre together with the row's place in the pattern, so it is a function + the project's tempo and meter together with the row's place in the pattern, so it is a function of position: the same row lasts the same time however playback reached it, and a module exported from the song can state the same figures. The integer tick counts the groove places *are* the - tempo, so a render realises them exactly at every rate it offers. + tempo, so a render realizes them exactly at every rate it offers. ## Two kinds of sound @@ -79,7 +79,7 @@ sounding preview whenever the active tab has a source of its own, so a preview a The transport's verbs are reached identically from the Playback menu, the toolbar, and the keyboard: -| Key | Command | Behaviour | +| Key | Command | Behavior | |-----|---------|-----------| | `Space` | Play / Pause | Acts on the target: pauses or resumes it while it is engaged, and starts it from the beginning otherwise. With no target, it does nothing. | | `Shift+Space` | Play from start | Starts the active tab's source from the beginning. | @@ -141,11 +141,11 @@ keeps the gestures consistent: any way of reaching "silence the rest" leaves the other. Every surface shows that one set and switches it. In the tracker a channel recedes down its column; -in the order table it recedes along its row; both take their shades from one pair of colours, so a +in the order table it recedes along its row; both take their shades from one pair of colors, so a silenced channel looks the same wherever it appears. A channel's name is the switch in both tables — click to silence, modified click to solo, the master name for the whole mix — and both tables hand the gesture and its right-click menu to one object, so both offer the same wording and the same -behaviour. The Playback menu's **Channels** submenu carries the same set as a check per channel, plus +behavior. The Playback menu's **Channels** submenu carries the same set as a check per channel, plus one item that returns the whole mix. Each of those items is registered as an action whether or not a key is bound to it, so the keybinding scheme can give it one and the menu prints what the scheme says (architecture principle 12). @@ -207,10 +207,10 @@ the moment its dialog opens until that dialog closes, and it joins the same busy conversion and library generation, so each of the three holds the others off and every surface offering one reads a single answer. -The write itself takes one pass, or two where the user asks for a normalised peak: the first pass +The write itself takes one pass, or two where the user asks for a normalized peak: the first pass spills raw samples and discovers the peak, the second reads them back and encodes at the scale that peak sets. Each pass names itself, so the bar crosses one axis — samples — twice, holding a single -unit across both. A cancel is honoured between rows and between encoded blocks, and a render that +unit across both. A cancel is honored between rows and between encoded blocks, and a render that is stopped or fails clears the destination and the spill, so a result names a path where a finished file stands. @@ -256,7 +256,7 @@ terminating would reclaim. | The document a kernel reads, live or captured | `ProjectSource` / `ProjectSnapshot` (`logic/shared/project_source.py`) | | The ticks the order lasts and the samples they span | `SongLength` (`logic/sequencer/playback/synthesizer/length.py`) | | Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) | -| Where a rendered file's samples go, normalised or direct | `RenderSink` (`services/render/sink.py`) | +| Where a rendered file's samples go, normalized or direct | `RenderSink` (`services/render/sink.py`) | | The choices a render is made under, and the phase it is in | `SongRenderLogic` (`logic/render/`) | | The formats a file may be written in, and what each accepts | `sampletones_core/audio/writers/` | diff --git a/docs/development/player.md b/docs/development/player.md index 4a2efe520..a035c071e 100644 --- a/docs/development/player.md +++ b/docs/development/player.md @@ -85,7 +85,7 @@ however long it is held and whatever pitch it is played at. **The cheapest reading, not a greedy one.** A plane is parsed as a shortest path: every way of covering a tick is an edge priced in the bytes its token takes, and the cheapest path across the plane is its encoding. Costs are in the currency the program area is measured in, -so the parse optimises the thing that actually has to fit. +so the parse optimizes the thing that actually has to fit. **A phrase earns its entry.** Naming a phrase is not enough — an entry costs its own bytes. Each is weighed by what it spares the streams against a reading of the song that names no @@ -135,7 +135,7 @@ The chain runs from the register values upward, and each link is held on its own | The codec is lossless | every encoding decodes to the planes it was written from, over a corpus | | The codec is safe | a plane the codec finds nothing in stays within its literal bound | | The ratio | `make compression-report` — bytes per tick and ticks that fit, per layer | -| The byte layout | a hand-built song serialises to expected bytes | +| The byte layout | a hand-built song serializes to expected bytes | | The assembly agrees with the specification | the include's equates are read and compared field by field | | The driver behaves | the assembled image on a 6502 emulator against `RegisterTrace.from_song`, over several rates and over songs that repeat | | The audio | a captured trace re-rendered against the reconstruction's own approximation | diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 50a241c59..48abe7fee 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -122,7 +122,7 @@ SampleToNES/1 order rows=1 positions=0..1 The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and strings alone; the desktop's clipboard is reached through -`utils/gui/clipboard.py::TextClipboard`, one more piece of external behaviour standing behind +`utils/gui/clipboard.py::TextClipboard`, one more piece of external behavior standing behind a protocol ([Architecture](architecture.md), principle 11). The sequencer coordinator wires the two. @@ -180,7 +180,7 @@ moment — the same predicate its key scope answers with, so the menu offers wha press would reach — and the router asks the one that does to build its items into the menu the bar has opened. It holds no state, resolving the surface on each call, so the menu states the actions of whoever holds the cursor at the moment it is opened. The bar names the -clipboard four greyed out when no grid answers, which is how a reader working from the menus +clipboard four grayed out when no grid answers, which is how a reader working from the menus learns the commands exist. **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share @@ -201,7 +201,7 @@ volume count separately, each carrying its own action. ## A shape selects to the grid's own edges -`Ctrl+A` and its neighbours select a whole shape at once. Each shape is stated on the input +`Ctrl+A` and its neighbors select a whole shape at once. Each shape is stated on the input state as a run of bounds along one axis — slots in the tracker, rows in the order — handed to a single builder that spans the other axis to the grid's full extent and lands the cursor on the far corner. The whole frame, a column and a subcolumn are therefore three namings of one @@ -222,7 +222,7 @@ Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), whic stands painted and the drag gesture that draws it. The grid states which of its cells the selection covers, in its own coordinates; the repaint that follows reaches the cells whose membership changed, marking each through the selectable's own selected state, which the -table's theme colours. A rebuilt table asks for a reset, since the cells a selection stood on +table's theme colors. A rebuilt table asks for a reset, since the cells a selection stood on belong to the body that was replaced. Both panels read the cell under a held pointer off their own geometry, because DearPyGui diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 8bdef7469..362ea8cce 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -233,7 +233,7 @@ you would see in the tracker either way. ## F. Bitphase capacity limits -| Quantity | Bitphase limit | Exporter behaviour | +| Quantity | Bitphase limit | Exporter behavior | | --- | --- | --- | | Items per instrument row list | unbounded | writes the envelope whole | | Rows per table | unbounded | writes the contour, or the groove, whole | diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index d1af203c0..9740dceef 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -1,9 +1,10 @@ # FamiTracker export format -This document is the reference for how _SampleToNES_ writes FamiTracker files. It -describes the two binary formats the `sampletones_core.formats.famitracker` package -produces — the `.fti` instrument file and the `.ftm` module file — and lists the -FamiTracker capacity limits that the project domain model will grow to respect. +This document is the reference for how _SampleToNES_ writes and reads FamiTracker +files. It describes the two binary formats the `sampletones_core.formats.famitracker` +package produces — the `.fti` instrument file and the `.ftm` module file — states what +a voice takes from an `.fti` it reads back, and lists the FamiTracker capacity limits +that the project domain model will grow to respect. The target is **vanilla FamiTracker 0.4.6** (`FILE_VER = 0x0440`). Files written to this specification load in stock FamiTracker as well as the 0CC, Dn-FamiTracker and @@ -157,7 +158,7 @@ releases the note is dropped from the cycle. Every length stays within the 252 items a FamiTracker sequence holds, so a reconstruction longer than 252 frames — 8.4 s at the default 30 fps — exports its opening 252 frames and -logs the shortening. The instruments panel colours a sequence input warning orange once it +logs the shortening. The instruments panel colors a sequence input warning orange once it passes that length, so the limit is visible before an export. An empty dimension is written as a disabled sequence, which is a different instrument from @@ -200,7 +201,45 @@ later export reports that stored pitch as `initial_pitch` and writes each frame straddle zero and stay compact around one note, and the pattern cell holds the contour's midpoint — a rising contour prints its middle note and opens below it. -## C. FamiTracker capacity limits +## C. Reading an instrument file + +An `.fti` is read as well as written: **Import instrument...** in the sequencer brings +one into the voice pool as a hand-written [instrument](../glossary.md#instrument). +`instrument.py::read_fti` parses the layout in section A.1, and +`voice.py::instrument_to_voice` makes a voice of the 2A03 instrument it holds. + +A voice carries three of the five dimensions — volume, arpeggio and duty — and one loop +point every dimension follows, so those come across as they stand. The voice takes the +name the file states, and a file naming nothing leaves the voice named after the file +itself. The arpeggio is read as offsets from the roots a hand-written voice rests on, +since a tracker instrument sounds at whatever note a row names it with. + +**Which loop point the voice adopts.** One sequence governs and the rest follow it: the +volume sequence wherever it is written, since that is the one shaping a held note, and +otherwise the first sequence the instrument carries. A governing sequence looping from +one of its items gives the voice that point; one halting at its end leaves the voice +playing its envelopes once. + +**What the voice leaves to the file.** A tracker instrument states more than a voice +holds, and each of those is reported once the import lands, so a reader learns what the +file carried (`InstrumentOmission` in `voice.py`): + +| Stated in the file | What the voice holds | +| --- | --- | +| a pitch envelope | a note moved in whole semitones, which the arpeggio carries | +| a hi-pitch envelope | the same | +| a release point | a note the pattern cuts with a note-off | +| an arpeggio in fixed, relative or scheme mode | absolute offsets | +| a loop point per envelope | one point every dimension follows | + +Each of these is a dimension the project model will grow to hold; `bugs-and-todos.md` +under **Tracker** owns that list. + +A sequence carrying an item outside the range its dimension holds raises +`InvalidInstrumentValuesError`. The file is read before the pool is touched, so a file +the reader cannot take leaves the project as it stood and the history without an entry. + +## D. FamiTracker capacity limits FamiTracker bounds several quantities that the _SampleToNES_ `Project` currently leaves looser. The exporter guards these limits, so every file it writes loads: it @@ -209,7 +248,7 @@ that outruns a sequence. Enforcing them on the domain model — so the editor pr reaching an unexportable state — is planned as a follow-up phase; this table is that checklist. -| Quantity | FamiTracker limit | Project bound today | Exporter behaviour | +| Quantity | FamiTracker limit | Project bound today | Exporter behavior | | --- | --- | --- | --- | | Instruments | 64 total | unbounded (1–4 per sample, one per hand-written instrument) | raises when the instruments exceed 64 | | Sequences per kind | 128 | unbounded | raises when a kind's pool exceeds 128 | @@ -229,7 +268,7 @@ for order slots the song leaves unset; a channel that already fills indices up t order. When the domain model grows to enforce these limits, the editor can prevent reaching a state the exporter would reject. -## D. Driver memory footprint +## E. Driver memory footprint Compiling a module into an NSF lays each instrument out across two regions of the driver's data, and an instrument's sequences size both of them. `footprint.py` measures the two, and diff --git a/docs/formats/instruction-libraries.md b/docs/formats/instruction-libraries.md index f695bcfc5..bfa6fdd01 100644 --- a/docs/formats/instruction-libraries.md +++ b/docs/formats/instruction-libraries.md @@ -2,7 +2,7 @@ An instruction library is stored as a single `.ins` file holding, for every possible instruction, the waveform its channel produces and that waveform's -[spectrum](../glossary.md#spectrum-feature-histogram). It is the catalogue the +[spectrum](../glossary.md#spectrum-feature-histogram). It is the catalog the reconstruction search draws its candidates from. For what a library is and how it is built, see [Instruction library](../concepts/instruction-library.md); this page documents the file. diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md index f7d8484e4..6d16077a3 100644 --- a/docs/formats/nsf.md +++ b/docs/formats/nsf.md @@ -38,7 +38,7 @@ directly, which is the one address a build decides — `driver/addresses.py` rea out of the linker's own labels. The console calls `init` once and then `play` once a video frame. The header asks for the -NTSC frame period, so a player honouring the field and one driving from the frame itself +NTSC frame period, so a player honoring the field and one driving from the frame itself run a song at the speed it was built at. **The program area is 32 KB**, from `$8000` upward, and the song block has whatever the diff --git a/docs/glossary.md b/docs/glossary.md index 60719f206..74d63a0bf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -86,7 +86,7 @@ frequency. ### Instruction library -A precomputed catalogue holding, for every possible instruction, the waveform +A precomputed catalog holding, for every possible instruction, the waveform its channel produces and that waveform's spectrum. The search draws its candidates from the library. Saved as an `.ins` file. See [Instruction libraries](formats/instruction-libraries.md). @@ -195,7 +195,7 @@ The tracker tints the row that opens each, and the beat is what a tempo counts: The engine ticks each row of a pattern lasts. An engine holds a row for a whole number of ticks, so a tempo landing between two counts is played by varying the -count from row to row, and the metre places the longer rows on the bar, then the +count from row to row, and the meter places the longer rows on the bar, then the beat, then inside the beat. Playback reads the groove by the row's position in the pattern, so the pattern's first row starts it afresh. @@ -228,6 +228,14 @@ voices in one list, and a row states which one to start and the step it plays at A reconstruction added to the sequencer as a playable voice, carrying the instruction stream its conversion found for each channel. +### Sample column + +The tracker's leftmost data column. It places a sample across every channel that +sample's reconstruction covers and clears the rest of the row, which is why it takes +samples alone: an instrument sounds on the one channel that names it. It summarizes +what those channels hold, reading `?` where they disagree. See +[The sequencer](guide/sequencer.md#writing-a-pattern). + ### Instrument One set of envelopes a channel reads while a note sounds, saved as an `.fti` file. A @@ -254,7 +262,7 @@ be followed by a sustained tail. A voice without one plays its envelopes once. | Extension | Contents | | --- | --- | -| `.ins` | [Instruction library](formats/instruction-libraries.md) — the candidate catalogue. | +| `.ins` | [Instruction library](formats/instruction-libraries.md) — the candidate catalog. | | `.stn` | [Reconstruction](formats/reconstructions.md) — a converted sample. | | `.stp` | [Project](formats/projects.md) — a bundle of reconstructions with a song and settings. | | `.fti` | FamiTracker instrument ([export](formats/famitracker.md)). | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 0e2c320db..95ece9029 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2,7 +2,7 @@ _SampleToNES_ reconstructs according to a **generation configuration** — the sample rate, the NES frequency, which channels are used, how the audio is -analysed, and how candidates are scored. The settings you reach for most often are +analyzed, and how candidates are scored. The settings you reach for most often are on the **Main** tab; the rest live in the configuration file, for when you want to go deeper. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 047a9ecc3..e5aacb689 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -2,7 +2,7 @@ _SampleToNES_ is one window: a menu bar at the top, four tabs, and a status bar at the bottom. Each tab works left to right — pick something on the left, set it -up in the centre, refine it on the right. +up in the center, refine it on the right. This page covers the **Main**, **Instructions**, and **Reconstructions** tabs and the menus around them. The **Sequencer** has [its own page](sequencer.md). @@ -13,7 +13,7 @@ The **Main** tab turns an audio file into a [reconstruction](../concepts/reconstruction.md). Most sessions start here. Pick an audio file — or a whole folder — in the **Filesystem** browser on the -left, set up the conversion in the centre, and click **Convert sample** (or +left, set up the conversion in the center, and click **Convert sample** (or **Convert directory** for a folder). The browser reopens the folders you were last working in, and **Collapse all** folds them away again. The [instruction library](../concepts/instruction-library.md) your settings need is built the @@ -41,7 +41,7 @@ inside it, as **Add folder as stems** does; if the folder holds more recordings than the list has room for, you pick which ones. Each row shows one recording and a checkbox per channel it may use. Untick them -all and the row greys out: that recording sits out of the conversion, and stays +all and the row grays out: that recording sits out of the conversion, and stays in the list so you can bring it back. Rows sit in **level** bands. A level is a turn to choose: every recording on @@ -113,7 +113,7 @@ a box at the front that moves all of them at once. Untick one and those frames go silent everywhere: in the waveform, in playback, in the original audio, and in a WAV export. That is how you hear what each recording contributed, channel by channel. A channel you have switched off under the waveform shows its column -greyed, and your ticks stay where you put them. +grayed, and your ticks stay where you put them. Click a row to show its recording in your file browser, and tick **Collapse levels** to read the whole list as one table. These ticks last for the session: @@ -122,7 +122,7 @@ saving records which recording owns which frame, not what you were listening to. **x** at the end of a row removes the recording from the reconstruction for good, so the app asks first. Its frames go silent and its row disappears, and the rest play as they did. One recording always stays, so the last row's **x** -is greyed out. +is grayed out. ### Exporting @@ -171,7 +171,7 @@ can hear a single NES tone on its own. **Generate library** builds the library for the current settings; if one already exists, _SampleToNES_ asks **Regenerate library?** first. **Cancel generation** -stops it, **Refresh instructions data** re-reads the catalogue, and selecting an +stops it, **Refresh instructions data** re-reads the catalog, and selecting an entry in the **Libraries** tree loads it. ## Around the app @@ -180,12 +180,18 @@ The menu bar and status bar sit outside the tabs. Each menu covers one kind of work: **File** for projects, **Edit** for undo, redo, and whatever your cursor is on, **Reconstruction** for the current -reconstruction and its exports, **Playback** for playing and for muting the -sequencer's channels, **View** for settings and the window, and **Help** for -**About**. What **Edit** offers below undo and redo follows your cursor: the -block actions of the sequencer grid you are in, or the actions of the sample you +reconstruction and its exports, **Voice** for the ways a voice comes into the +sequencer and what the one you picked offers, **Playback** for playing and for +muting the sequencer's channels, **View** for settings and the window, and **Help** +for **About**. What **Edit** offers below undo and redo follows your cursor: the +block actions of the sequencer grid you are in, or the actions of the voice you have picked in the **Voices** list. +**Voice** answers "how do I get a voice in" on its own: **New instrument**, **Add +sample from file...**, **Import instrument...**, and **Add to Sequencer** stand +together at the top, and the actions of the voice you picked follow — the same set the +list's own right-click menu prints. See [voices](sequencer.md#voices-samples-and-instruments). + Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** for the reconstruction you have open, and **File ▸ Render song...** (`Ctrl+Shift+E`) for the sequencer's whole song, as a WAV or an MP3 — [rendering diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index e2e876d9d..f9f053adc 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -4,7 +4,7 @@ The **Sequencer** tab is a tracker: it arranges voices into a song across the fo NES channels and exports it as a FamiTracker [module](../formats/famitracker.md) (`.ftm`). It works on a [project](../formats/projects.md), so start one with **File ▸ New project** (or -open an existing `.stp`). The pattern grid and order sit in the centre, a browser +open an existing `.stp`). The pattern grid and order sit in the center, a browser for pulling in reconstructions on the left, and the module settings, voice list, and undo history on the right. @@ -14,26 +14,48 @@ A song is built from **voices**, and there are two kinds. A **sample** is a reconstruction brought in as something a row can play. An **instrument** is written by hand — envelopes with no recording behind them — for the melodies and basses you write yourself. Both sit in the **Voices** list on the right, numbered together, and a -mark at the front of each row says which kind it is. - -Add a sample from the **Reconstructions** browser on the left (right-click ▸ **Add -to Sequencer**), or with **Add to Sequencer** on the **Reconstructions** tab. If a -reconstruction was made at a different NES frequency than the project and the -project already has voices, _SampleToNES_ warns with **Different NES frequency**; -**Add anyway** adds it regardless. - -Add an instrument with **New instrument** at the top of the list. It starts out holding a -note at full volume, so you can place it and hear it straight away; give it the sound you -want on the **Reconstructions** tab (right-click ▸ **Edit**). See [editing -instruments](interface.md#editing-instruments). - -Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or -reorder it, and toggle its **Loop** flag. The **Edit** menu carries the same actions -for the voice you have picked. The right-click menu also names how much room the -voice takes on the NES — a sample's total and then each channel it plays, and an -instrument's single figure — measured as its **Loop** flag has it. The figures are in bytes, and -they count what a FamiTracker export saves. Removing a voice that patterns still use -asks first, because it clears every row that references it. +mark at the front of each row says which kind it is. The mark carries a color as well +as a shape — amber for a sample, magenta for an instrument — and the same two colors +name a voice in the pattern grid and in the history, so the two kinds read apart +wherever one is named. + +Four ways bring a voice in, and the **Voice** menu holds all four: + +| Way in | What arrives | +|--------|--------------| +| **New instrument** | An instrument holding a note at full volume, ready to place and hear | +| **Add sample from file...** | A reconstruction saved anywhere on disk, as a sample | +| **Import instrument...** | A FamiTracker instrument file (`.fti`), as an instrument | +| **Add to Sequencer** | The reconstruction the **Reconstructions** tab holds, as a sample | + +The first three also sit at the top of the **Voices** list, and on the list's own +menu — right-click below the rows to reach it. **Add to Sequencer** is on the +**Reconstructions** browser to the left (right-click a reconstruction) and on the +**Reconstructions** tab. If a reconstruction was made at a different NES frequency +than the project and the project already has voices, _SampleToNES_ warns with +**Different NES frequency**; **Add anyway** adds it regardless. + +A new instrument starts out holding a note at full volume, so you can place it and +hear it straight away; give it the sound you want on the **Reconstructions** tab +(right-click ▸ **Edit**). See [editing instruments](interface.md#editing-instruments). +An imported `.fti` arrives with the volume, arpeggio, and duty-cycle envelopes the +file states, and **Instrument imported** names anything the file held on a tracker's +own terms that the voice leaves behind — see [reading an instrument +file](../formats/famitracker.md#c-reading-an-instrument-file). + +**New instrument from ▸ _channel_** on a sample's menu writes what one of its channels +plays into an instrument of its own, so a recorded part becomes envelopes you edit by +hand. + +Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder +it, and toggle its **Loop** flag. **Export instrument...** writes the voice out as a +`.fti` another tracker reads — a sample holds one instrument per channel it plays, so +it asks which. The **Edit** menu carries the same actions for the voice you have +picked. The right-click menu also names how much room the voice takes on the NES — a +sample's total and then each channel it plays, and an instrument's single figure — +measured as its **Loop** flag has it. The figures are in bytes, and they count what a +FamiTracker export saves. Removing a voice that patterns still use asks first, because +it clears every row that references it. ## Writing a pattern @@ -46,8 +68,15 @@ adjustments, **Play from here** to audition from the cursor row, and **Play from this frame** to start at the top of the shown frame. The **Sample** column places a sample across every channel its reconstruction -covers. An instrument sounds on one channel at a time, so name it in the -channel column you want it on. +covers, and clears the rest of the row. It takes samples alone: an instrument sounds +on the one channel that names it, so **Set voice** lists instruments there grayed out, +and a number typed over one leaves the cell reading what it held. Name an instrument +in the channel column you want it on. + +A cell holding a voice wears that voice's color — amber for a sample, magenta for an +instrument — while an empty cell, a cut, and a `?` where the **Sample** column's +channels disagree read in a plain gray. Silencing a channel dims its cells and keeps +those colors, so a muted column stays as readable as the rest. ## Reading and typing a pitch @@ -182,7 +211,7 @@ the song runs. ## Listening to one channel at a time Channel names are switches. Click **Triangle** at the top of the tracker to silence -that channel: its name greys, its column and its row in the **Order** grid go +that channel: its name grays, its column and its row in the **Order** grid go neutral, and its notes dim — still readable, still editable, just not sounding. Click the name again to bring it back. The same click works on the channel's name in the **Order** grid, and both grids show every change, so a channel looks the same @@ -215,17 +244,17 @@ frequency** first (with a **Don't ask again** option). The project's title, author, and comment — which carry into the exported module — are set in **Project properties**, from the button or **File ▸ Project -properties...**, along with the metre the song is counted in. +properties...**, along with the meter the song is counted in. -**First highlight** and **Second highlight** are that metre: how many rows make a +**First highlight** and **Second highlight** are that meter: how many rows make a beat, and how many make a bar. The tracker tints the row that opens each one. The bar divided by the beat is how many beats you hear in a bar, so the default 4 and 16 give four beats of four rows — common time. Waltz time keeps the four-row beat and shortens the bar to 12, for three beats. The beat is what the tempo counts, so the two together say how fast the song is felt as well as how it looks. -The metre also places the song's timing. Most tempos ask for a row length the engine -can only reach on average, so the rows of a bar differ a little: the metre gives the +The meter also places the song's timing. Most tempos ask for a row length the engine +can only reach on average, so the rows of a bar differ a little: the meter gives the extra time to the row that opens the bar, then to the row that opens each beat, which keeps the beat audible where you expect it. diff --git a/docs/index.md b/docs/index.md index be4c7046a..7eb47421d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,7 @@ reconstruction. It is written to be read without the source code. - [Reconstruction algorithms](concepts/reconstruction.md) — how a sample becomes a stream of NES instructions. - [Stems reconstruction](concepts/stems.md) — how one reconstruction is assigned across several stems. -- [Instruction library](concepts/instruction-library.md) — the catalogue of NES sounds the search draws from. +- [Instruction library](concepts/instruction-library.md) — the catalog of NES sounds the search draws from. - [Song compression](concepts/compression.md) — how a whole song is fitted into the space an NES program has for it. - [Project](concepts/project.md) — a whole composition: a song and the reconstructions it is built from. - [Calibration](concepts/calibration.md) — how the reconstruction's settings are tuned by experiment. @@ -40,7 +40,7 @@ reconstruction. It is written to be read without the source code. The [**formats**](formats/) section documents the files _SampleToNES_ reads and writes. -- [Instruction libraries](formats/instruction-libraries.md) — the `.ins` candidate catalogue. +- [Instruction libraries](formats/instruction-libraries.md) — the `.ins` candidate catalog. - [Reconstructions](formats/reconstructions.md) — the `.stn` reconstruction data. - [Projects](formats/projects.md) — the `.stp` project bundle. - [FamiTracker export](formats/famitracker.md) — the `.fti` instrument and `.ftm` module formats. diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index 467fb425b..97412fd99 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -40,7 +40,7 @@ class ApplicationConfig(BaseModel): ) playback: PlaybackConfig = Field( default_factory=PlaybackConfig, - description="Playback behaviour preferences.", + description="Playback behavior preferences.", ) shortcuts: ShortcutsConfig = Field( default_factory=ShortcutsConfig, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index e027862b3..01f178d01 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1176,7 +1176,7 @@ def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: """Settles the marks the transport owns, and how far the grid chases the playhead. The player emits a view on every position update and on every change to the setting, so - reading the follow behaviour here keeps the grid in step both while a song sounds and the + reading the follow behavior here keeps the grid in step both while a song sounds and the moment the reader picks another mode. """ self._sequencer_tracker_panel.set_row_following(view_model.follow_mode.follows_row) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py index 25ddb56a0..44ffafb69 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py @@ -15,7 +15,7 @@ def collapse_single_child_containers(node: TreeNode) -> None: into groups as soon as a second configuration arrives. The row that survives keeps its node type, its path, its configuration and its children, so its - click behaviour, theme, context menu and favorite star carry over from before the fold. The two + click behavior, theme, context menu and favorite star carry over from before the fold. The two branch roots stay in place, since each names a way of reading the whole tree, and a folder the disk holds stays a folder of its own, since the configuration branch mirrors the disk. """ diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index eb562a68a..a8c15b0be 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -424,7 +424,7 @@ def _frame_range( def _covered_channels( covered: Set[Optional[ChannelName]], ) -> List[ChannelName]: - """The channels a run of columns names, an aggregate one standing for all it summarises. + """The channels a run of columns names, an aggregate one standing for all it summarizes. Both grids carry a column that answers for every channel — the tracker's sample column and the order's master row — so a gesture reaching one of them reads as the whole set. diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py index 8e325044c..5da26b58e 100644 --- a/src/sampletones_application/logic/sequencer/order/reader.py +++ b/src/sampletones_application/logic/sequencer/order/reader.py @@ -42,7 +42,7 @@ def _agree( """What a row holds at a position: a channel's own index, or the one its channels share. A channel row answers for itself, so it is a group of one and always agrees. The master row - answers for every channel, which is the group its display summarises too, so a block states + answers for every channel, which is the group its display summarizes too, so a block states about a cell exactly what the table it came from shows there. """ if channel is not None: diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index a30e3b573..6681c4525 100644 --- a/src/sampletones_application/logic/sequencer/playback/song_player.py +++ b/src/sampletones_application/logic/sequencer/playback/song_player.py @@ -114,7 +114,7 @@ def pause_or_resume(self) -> None: def seek(self, order_position: int) -> None: """Moves the live playhead to another order, preserving sounding voices. - Drives the follow-playback behaviour: selecting a different order while the song plays + Drives the follow-playback behavior: selecting a different order while the song plays relocates the playhead in place. The service stays idle when nothing is playing. The worker emits each row's position only after its blocking write, so one in-flight update diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index c5e6b8b63..1576c85ac 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -59,7 +59,12 @@ def _read_subcolumn( agreement = self._agree(row_index, slot.channel, select) if agreement.is_unanimous: - values[(row_offset, region.first_slot + position - base)] = agreement.value + values[ + ( + row_offset, + region.first_slot + position - base, + ) + ] = agreement.value return values @@ -72,14 +77,20 @@ def _agree( """What a column holds at a cell: a channel's own value, or the one its channels share. A channel column answers for itself, so it is a group of one and always agrees. The sample - column answers for the channels it governs, which is the group its display summarises too, + column answers for the channels it governs, which is the group its display summarizes too, so a block states about a cell exactly what the grid it came from shows there. """ if channel is not None: return Agreement.collapse([select(self._tracker.row(channel, row_index))]) return Agreement.collapse( - select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_channels(row_index) + select( + self._tracker.row( + channel, + row_index, + ) + ) + for channel in self._tracker.relevant_channels(row_index) ) @staticmethod diff --git a/src/sampletones_application/view_model/sequencer/order.py b/src/sampletones_application/view_model/sequencer/order.py index e9ce815b9..0fdb89eee 100644 --- a/src/sampletones_application/view_model/sequencer/order.py +++ b/src/sampletones_application/view_model/sequencer/order.py @@ -26,7 +26,7 @@ class SequencerOrderViewModel(BaseModel, frozen=True): class SequencerOrderTrackerViewModel(BaseModel, frozen=True): """The whole arrangement: order positions (columns) across channels (rows). - The master row summarises each position across channels — the horizontal analog + The master row summarizes each position across channels — the horizontal analog of the tracker's sample column — showing the shared pattern index or ``?`` when the channels disagree. """ diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py index fc5ff1511..998bef4dc 100644 --- a/src/sampletones_core/exporters/lengths.py +++ b/src/sampletones_core/exporters/lengths.py @@ -16,7 +16,7 @@ def _limited_length(length: int, limit: Optional[int]) -> int: if limit is None or length <= limit: return length - logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") + logger.debug(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") return limit diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py b/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py index f16c73d94..7a0843461 100644 --- a/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py +++ b/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py @@ -8,7 +8,7 @@ class GreedyDecoder(Decoder): Plays each frame's best candidate, so every frame stands on its own. Reading one candidate per frame makes the frame's own cost the whole decision, which is - the classic behaviour: what the matching ranked first is what the channel plays. + the classic behavior: what the matching ranked first is what the channel plays. """ @property diff --git a/tests/suite/application.py b/tests/suite/application.py index e8df8a13a..6e8a51579 100644 --- a/tests/suite/application.py +++ b/tests/suite/application.py @@ -5,8 +5,12 @@ from sampletones_application.layout.behavior.scheduling.delays import SchedulingDelays from sampletones_application.layout.behavior.scheduling.emit import SchedulingEmit -from sampletones_application.layout.behavior.scheduling.priorities import SchedulingPriorities -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.layout.behavior.scheduling.priorities import ( + SchedulingPriorities, +) +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_shared.types.callback import VoidCallback @@ -46,7 +50,7 @@ def execute_sync(self: SingleThreadExecutor, target: VoidCallback, wait: bool = @pytest.fixture def scheduling() -> SchedulingBehavior: - """Scheduling behaviour with every delay collapsed to zero for deterministic tests.""" + """Scheduling behavior with every delay collapsed to zero for deterministic tests.""" return SchedulingBehavior( delays=SchedulingDelays( schedule=0, diff --git a/tests/suite/language.py b/tests/suite/language.py index 5e298af7e..0cbac9c6b 100644 --- a/tests/suite/language.py +++ b/tests/suite/language.py @@ -8,7 +8,7 @@ class FakeLanguageManager: A test asserting on the text a widget or a callback receives then names the entry the code read, which holds the wiring in place while leaving the wording to the language file. Where - the behaviour under test formats the text — a template with placeholders — the test states + the behavior under test formats the text — a template with placeholders — the test states that text explicitly. """ diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index dda0e945d..14c36bc4a 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -79,7 +79,7 @@ def render_frame(tracker_logic: SequencerTrackerLogic) -> Tuple[str, ...]: """Every row of the frame shown, each read as the four channel cells the grid draws. A row is written the way it appears on screen, so an expectation and a screenshot read alike. - The sample column is left out because it holds nothing of its own: it summarises these four, + The sample column is left out because it holds nothing of its own: it summarizes these four, and stating it again would pin the summary rather than what a gesture wrote. """ grid = tracker_logic.build_grid() @@ -105,7 +105,7 @@ def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]: """Every channel's row of the order, each read as the pattern indices the table draws. A row is written the way it appears on screen, so an expectation and a screenshot read alike. - The master row is left out because it holds nothing of its own: it summarises these four, and + The master row is left out because it holds nothing of its own: it summarizes these four, and stating it again would pin the summary rather than what a gesture wrote. """ view_model = order_logic.build_order() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index e6288ef0b..39b67c9b8 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -29,7 +29,9 @@ SequencerClipboard, TrackerBlockText, ) -from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail +from sampletones_application.logic.sequencer.history_detail import ( + SequencerHistoryDetail, +) from sampletones_application.logic.sequencer.order import ( OrderBlockReader, OrderBlockWriter, @@ -56,7 +58,10 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_application.view_model.sequencer.voices import VoiceKind, VoiceSelection +from sampletones_application.view_model.sequencer.voices import ( + VoiceKind, + VoiceSelection, +) from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, @@ -522,7 +527,7 @@ def _playhead(frame_index: int, row_index: int) -> SongPosition: def _player_view(*, follow_mode: FollowMode) -> SongPlayerViewModel: - """A stopped transport view, which is what the coordinator reads the follow behaviour from.""" + """A stopped transport view, which is what the coordinator reads the follow behavior from.""" return SongPlayerViewModel( is_loaded=True, is_playing=False, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index eb355ad1a..0b9f3e675 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -630,7 +630,7 @@ def test_the_column_stands_by_for_a_voice_the_project_lost(self) -> None: class TestWhatTheSampleColumnReads: - """The column summarises what its own kind of voice put on the row.""" + """The column summarizes what its own kind of voice put on the row.""" def test_an_instrument_alone_leaves_the_column_empty(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index c0a8ac868..38ea45308 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -446,7 +446,7 @@ def test_the_reconstruction_the_edit_was_made_from_keeps_playing( class TestRegenerationServiceCancellationConstraints: - """Tests that document the non-preemptive cancellation behaviour. + """Tests that document the non-preemptive cancellation behavior. cancel() only prevents new tasks from starting. It does NOT interrupt synthesis that is already in progress. diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index fd7ab04c5..3dbcbcbb3 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -211,7 +211,7 @@ def test_another_scheme_hands_its_keys_to_the_dispatcher(self, application: Appl class TestStartupRestoreDelegation: """Application only forwards the startup restore to the domain coordinators, which are the recovery boundary (docs/development/architecture.md § Error Handling Policy). The - recovery behaviour itself is covered by the coordinator tests. + recovery behavior itself is covered by the coordinator tests. """ def test_project_restore_delegates_to_coordinator(self, app: Application) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 4e9a9d77b..961410dca 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -5,7 +5,10 @@ from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget -from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import ( + TrackerCursor, + TrackerInputState, +) from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.voices import ( @@ -33,7 +36,7 @@ def _panel() -> tracker_module.GUISequencerTrackerPanel: The menu-dispatch methods touch only their hook attributes, the context labels, the keys each item prints, and ``CallbackMixin.call``, so a fully - wired GUI context is unnecessary here. Labels carry no behaviour, so any + wired GUI context is unnecessary here. Labels carry no behavior, so any placeholder text serves. """ panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 4b07b3955..959f90205 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -210,7 +210,7 @@ def test_re_export_reads_the_edited_arpeggio_back(self, test_case: TestCase) -> def test_cleared_arpeggio_returns_every_frame_to_the_reference(self, test_case: TestCase) -> None: """Clearing an arpeggio envelope restores the pitch the channel started at. - This is the reported behaviour: typing ``12 0`` and then clearing it back to ``0`` + This is the reported behavior: typing ``12 0`` and then clearing it back to ``0`` sounds the sample at the note it was reconstructed at. """ features = self._export(test_case, self._edited(test_case)) From a21c516ce85048bd6ee2958dd46f017840a04f25 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 10:49:00 +0200 Subject: [PATCH 106/142] Fixes --- conftest.py | 17 ++++++---- docs/concepts/instruction-library.md | 2 +- src/sampletones/self_check.py | 4 +-- src/sampletones_application/application.py | 10 +++--- .../categories/context.py | 4 +-- .../categories/instrument.py | 2 +- .../coordinators/tabs/instructions.py | 10 +++--- .../coordinators/tabs/reconstruction.py | 2 +- .../coordinators/tabs/sequencer.py | 6 ++-- .../layout/general/colors/feature.py | 2 +- .../layout/general/columns.py | 2 +- .../layout/tabs/sequencer/colors/channel.py | 6 ++-- .../layout/tabs/sequencer/colors/header.py | 2 +- .../layout/tabs/sequencer/colors/history.py | 2 +- .../tabs/sequencer/colors/history_role.py | 2 +- .../layout/tabs/sequencer/colors/muted.py | 2 +- .../layout/tabs/sequencer/colors/order.py | 2 +- .../layout/tabs/sequencer/colors/row.py | 2 +- .../layout/tabs/sequencer/colors/sample.py | 2 +- .../layout/tabs/sequencer/colors/tracker.py | 6 ++-- .../logic/history/errors.py | 2 +- .../logic/history/manager.py | 2 +- .../logic/main/converter.py | 2 +- .../logic/main/stems.py | 4 +-- .../browser/tree/samples/branch.py | 2 +- .../logic/sequencer/history_detail.py | 8 ++--- .../logic/sequencer/tracker/tracker.py | 2 +- .../parameters/geometry.py | 2 +- .../services/conversion.py | 2 +- .../services/render/service.py | 2 +- .../services/song_player/player.py | 2 +- src/sampletones_application/shell.py | 2 +- .../ui/elements/field.py | 6 ++-- .../ui/elements/graphs/waveform.py | 16 ++++----- .../ui/elements/layout/columns.py | 6 ++-- .../ui/elements/layout/responsive.py | 6 ++-- .../ui/elements/path.py | 4 +-- .../ui/elements/pitch_stepper.py | 2 +- .../ui/elements/plus_minus_buttons.py | 2 +- .../ui/elements/stems/list.py | 8 ++--- .../ui/elements/table/caret.py | 2 +- .../ui/elements/table/selection.py | 2 +- .../ui/elements/tree/browser.py | 4 +-- .../ui/elements/tree/tree.py | 10 +++--- .../ui/elements/window.py | 4 +-- src/sampletones_application/ui/menu.py | 4 +-- .../ui/panels/dialogs/audio_settings.py | 2 +- .../ui/panels/instruction/library.py | 8 ++--- .../ui/panels/main/converter.py | 2 +- .../reconstruction/instruments/instruments.py | 8 ++--- .../ui/panels/sequencer/channels.py | 2 +- .../ui/panels/sequencer/history.py | 6 ++-- .../ui/panels/sequencer/order.py | 19 +++++++---- .../ui/panels/sequencer/rows.py | 2 +- .../ui/panels/sequencer/tracker.py | 33 +++++++++++-------- .../ui/panels/sequencer/voices/panel.py | 4 +-- .../ui/panels/shared/browser.py | 6 ++-- .../ui/themes/inline.py | 10 +++--- .../ui/themes/items.py | 6 ++-- .../ui/themes/loader.py | 8 ++--- .../ui/themes/theme.py | 10 +++--- .../file_dialogs/backends/portal/client.py | 2 +- .../utils/gui/align.py | 4 +-- .../utils/gui/dialogs/renderer.py | 4 +-- .../utils/gui/dialogs/windows/error.py | 2 +- .../utils/gui/keyboard/modifiers.py | 2 +- .../utils/gui/palette/binding.py | 4 +-- .../utils/gui/palette/dpg.py | 16 ++++----- .../utils/gui/palette/palette.py | 14 ++++---- .../utils/gui/staging.py | 2 +- .../utils/palette/colors/base.py | 14 ++++---- .../utils/palette/colors/blended.py | 2 +- .../utils/palette/colors/faded.py | 4 +-- .../utils/palette/colors/grayscale.py | 4 +-- .../utils/palette/colors/layered.py | 2 +- .../utils/palette/colors/literal.py | 4 +-- .../utils/palette/colors/named.py | 2 +- .../utils/palette/colors/written.py | 8 ++--- .../utils/palette/palette.py | 8 ++--- .../utils/palette/reference.py | 4 +-- .../utils/palette/source.py | 10 +++--- .../view_model/sequencer/move.py | 2 +- .../view_model/sequencer/tracker.py | 6 ++-- .../view_model/shared/history.py | 6 ++-- .../view_model/shared/stems.py | 2 +- src/sampletones_application/viewport.py | 4 +-- src/sampletones_assets/mark/raster.py | 2 +- .../mark/specification/__init__.py | 2 +- .../mark/specification/colors.py | 12 +++---- src/sampletones_config/lang/en.yaml | 2 +- src/sampletones_core/fft/cqt/frequencies.py | 2 +- src/sampletones_core/fft/spectrum/cqt.py | 2 +- .../formats/bitphase/model/project.py | 2 +- .../formats/bitphase/model/song.py | 2 +- src/sampletones_core/generators/utils.py | 2 +- src/sampletones_player/clock/schedule.py | 4 +-- src/sampletones_player/registers/streams.py | 2 +- src/sampletones_player/trace/trace.py | 16 ++++----- src/sampletones_shared/utils/color.py | 12 +++---- src/sampletones_shared/utils/serialization.py | 4 +-- .../integration/bitphase/test_btp_pipeline.py | 2 +- tests/integration/nsf/console/machine.py | 12 +++---- tests/integration/nsf/console/session.py | 8 ++--- tests/integration/nsf/test_driver_trace.py | 4 +-- tests/suite/browser.py | 2 +- tests/suite/scenario.py | 2 +- .../config/managers/test_application.py | 2 +- .../constants/test_keybindings.py | 2 +- .../coordinators/tabs/test_sequencer.py | 2 +- .../coordinators/test_keybindings.py | 4 +-- .../logic/main/test_stems.py | 2 +- .../logic/reconstruction/test_data.py | 2 +- .../logic/sequencer/order/test_writer.py | 2 +- .../logic/sequencer/test_history_detail.py | 4 +-- .../logic/sequencer/tracker/test_tracker.py | 4 +-- .../logic/sequencer/tracker/test_writer.py | 2 +- .../services/render/conftest.py | 2 +- .../services/render/test_service.py | 2 +- .../sampletones_application/test_startup.py | 6 ++-- .../ui/elements/graphs/test_waveform.py | 2 +- .../ui/elements/layout/test_responsive.py | 6 ++-- .../ui/elements/stems/test_list.py | 8 ++--- .../ui/elements/test_window.py | 2 +- .../ui/elements/tree/test_favorites_filter.py | 4 +-- .../ui/panels/dialogs/test_export.py | 2 +- .../sequencer/test_history_role_color.py | 14 ++++---- .../panels/sequencer/test_order_channels.py | 4 +-- .../sequencer/test_tracker_cell_themes.py | 18 +++++----- .../panels/sequencer/test_tracker_channels.py | 6 ++-- .../sequencer/test_tracker_typed_voice.py | 6 ++-- .../sequencer/voices/test_kind_color.py | 10 +++--- .../ui/panels/sequencer/voices/test_menu.py | 4 +-- .../sampletones_application/ui/test_menu.py | 2 +- .../ui/themes/test_inline.py | 6 ++-- .../ui/themes/test_loader.py | 6 ++-- .../ui/themes/test_theme.py | 14 ++++---- .../utils/gui/keyboard/test_keys.py | 2 +- .../utils/gui/keyboard/test_modifiers.py | 2 +- .../utils/gui/test_palette.py | 12 +++---- .../utils/palette/test_catalog.py | 6 ++-- .../utils/palette/test_colors.py | 10 +++--- .../utils/palette/test_palette.py | 2 +- .../utils/palette/test_written.py | 4 +-- .../view_model/main/test_converter.py | 2 +- .../view_model/sequencer/test_tracker.py | 2 +- .../view_model/shared/test_export.py | 2 +- .../view_model/shared/test_render.py | 2 +- .../sampletones_assets/mark/test_raster.py | 10 +++--- .../sampletones_assets/mark/test_vector.py | 4 +-- .../exporters/test_lengths.py | 10 +++--- .../sampletones_core/timing/test_groove.py | 2 +- .../sampletones_player/clock/test_schedule.py | 2 +- .../unit/sampletones_player/nsf/test_file.py | 2 +- .../sampletones_player/nsf/test_header.py | 4 +-- .../unit/sampletones_player/nsf/test_song.py | 4 +-- .../sampletones_player/trace/test_trace.py | 26 +++++++-------- .../sampletones_shared/utils/test_color.py | 2 +- .../scripts/checks/test_palette_colors.py | 8 ++--- .../scripts/checks/test_shortcut_actions.py | 2 +- 159 files changed, 417 insertions(+), 404 deletions(-) diff --git a/conftest.py b/conftest.py index 48d3aa476..273aab9ed 100644 --- a/conftest.py +++ b/conftest.py @@ -4,22 +4,25 @@ JEEPNEY_MODULE: Final[str] = "jeepney" -PORTAL_PATHS: Final[Tuple[str, ...]] = ( +JEEPNEY_PATHS: Final[Tuple[str, ...]] = ( "src/sampletones_application/utils/file_dialogs/backends/portal", + "src/sampletones_shared/utils/system/reveal/file_manager1.py", "tests/unit/sampletones_application/utils/file_dialogs/backends/portal", "tests/unit/sampletones_application/utils/file_dialogs/test_selection.py", + "tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py", ) -PORTAL_LIBRARY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None +JEEPNEY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None def pytest_ignore_collect(collection_path: Path) -> Optional[bool]: """ Keeps collection to the modules the running platform imports. - ``jeepney`` is declared for Linux alone, so what speaks to the desktop portal is collected - where that library is installed. The behavior those modules describe belongs to the Linux - desktop, and the Linux runs of the suite cover it. + ``jeepney`` is declared for Linux alone, so what speaks D-Bus — the desktop portal's file + dialogs and the ``FileManager1`` reveal backend — is collected where that library is + installed. The behavior those modules describe belongs to the Linux desktop, and the Linux + runs of the suite cover it. Args: collection_path: The file or directory pytest is about to look into. @@ -28,11 +31,11 @@ def pytest_ignore_collect(collection_path: Path) -> Optional[bool]: Optional[bool]: ``True`` for a path that stays out of collection, ``None`` to leave the choice with pytest. """ - if PORTAL_LIBRARY_INSTALLED: + if JEEPNEY_INSTALLED: return None root = Path(__file__).parent - if any(collection_path.is_relative_to(root / path) for path in PORTAL_PATHS): + if any(collection_path.is_relative_to(root / path) for path in JEEPNEY_PATHS): return True return None diff --git a/docs/concepts/instruction-library.md b/docs/concepts/instruction-library.md index dc6a0e381..c0890acd3 100644 --- a/docs/concepts/instruction-library.md +++ b/docs/concepts/instruction-library.md @@ -13,7 +13,7 @@ describes that search; this page describes the catalog it searches. The number of distinct instructions is large but fixed — a few thousand per channel — and the same candidates are compared against every frame of every -sample. Rendering each candidate's waveform and analysing its spectrum once, up +sample. Rendering each candidate's waveform and analyzing its spectrum once, up front, turns the per-frame work into a lookup instead of a re-synthesis. A library is therefore built once for a given configuration and reused across every reconstruction that shares it. diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py index 212a8150b..30fe3b407 100644 --- a/src/sampletones/self_check.py +++ b/src/sampletones/self_check.py @@ -86,7 +86,7 @@ def _check_keybindings() -> str: def _check_layout_config() -> str: - """Resolves the layout against every shipped palette, since each answers the colour tokens itself.""" + """Resolves the layout against every shipped palette, since each answers the color tokens itself.""" from sampletones_application.layout import LayoutConfig, load_layout_config from sampletones_application.paths import BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY @@ -97,7 +97,7 @@ def _check_layout_config() -> str: def _check_themes() -> str: - """Resolves the theme set against every shipped palette, since each answers the colour tokens itself.""" + """Resolves the theme set against every shipped palette, since each answers the color tokens itself.""" from sampletones_application.paths import THEME_DIRECTORY from sampletones_application.ui.themes.loader import ThemeLoader diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index a89c3d95a..257b783e1 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -746,11 +746,11 @@ def _on_bindings_changed(self, _scheme: ShortcutScheme) -> None: self.shortcut_manager.rebind() def _on_palette_changed(self, _palette: Palette) -> None: - """Repaints what holds a colour DearPyGui has copied, once another palette is in place. + """Repaints what holds a color DearPyGui has copied, once another palette is in place. - Every layout and theme colour already answers with the new palette, so the work left is - handing those values to the copies DearPyGui keeps: the registered theme colours and item - arguments, the viewport clear colour, and the sequencer tables, whose tints belong to the + Every layout and theme color already answers with the new palette, so the work left is + handing those values to the copies DearPyGui keeps: the registered theme colors and item + arguments, the viewport clear color, and the sequencer tables, whose tints belong to the table rather than to an item. """ PaletteBindings.apply() @@ -963,7 +963,7 @@ def _refresh_busy_state(self) -> None: generation or render starts or finishes, keeping the long operations mutually exclusive. Each panel reads the live ``_is_operation_active`` state for itself; this only nudges them to re-apply, so the busy truth lives in one place. The menu follows the same edge, since what - greys an entry offering another such operation is one already running.""" + grays an entry offering another such operation is one already running.""" self._instructions_tab.refresh_generate_button() self._update_menu() diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index f8b04bc34..7ed551702 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -25,12 +25,12 @@ def context_text( element comes from the same place. Args: - language_manager: The catalogue the words are read from. + language_manager: The catalog the words are read from. text_type: The voice the element is read in. element: The context element being read. Returns: - str: The words the catalogue holds for that element in that voice. + str: The words the catalog holds for that element in that voice. """ return language_manager[ Page.GLOBAL, diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py index 17ae7deab..9de28e4d8 100644 --- a/src/sampletones_application/categories/instrument.py +++ b/src/sampletones_application/categories/instrument.py @@ -55,7 +55,7 @@ def build(cls, language_manager: LanguageManager) -> Self: """Resolves every word the import report prints. Args: - language_manager: The catalogue the words are read from. + language_manager: The catalog the words are read from. Returns: Self: The bundle the import handler reads. diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 9864e16e2..ac684e0a4 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -80,7 +80,7 @@ class _StackedGraphPanel(Protocol): - """A centre-column card whose graph display follows a viewport-driven height.""" + """A center-column card whose graph display follows a viewport-driven height.""" def set_display_height(self, height: int) -> None: ... @@ -351,11 +351,11 @@ def _close_instruction(self) -> None: self._instruction_player_logic.clear_audio() def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: - """Persists a centre-column card's collapsed state so it restores on the next launch.""" + """Persists a center-column card's collapsed state so it restores on the next launch.""" self._session_manager.set_card_collapsed(card_tag, collapsed) def _repaint_library_favorites(self, node: FileSystemNode) -> None: - """Repaints the row whose star was toggled: the catalogue lists a library once, so it is one row.""" + """Repaints the row whose star was toggled: the catalog lists a library once, so it is one row.""" self._library_panel.update_favorite_indicators((node,)) def _on_library_collapse_changed(self, card_tag: str, collapsed: bool) -> None: @@ -450,7 +450,7 @@ def create_tab(self) -> None: self._sync_graph_heights() def _build_display_column(self, parent: str) -> None: - """Stacks the waveform and spectrum cards down the centre column.""" + """Stacks the waveform and spectrum cards down the center column.""" self._waveform_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._spectrum_panel.create_panel(parent) @@ -487,7 +487,7 @@ def load_library_safely(self, filepath: Path) -> None: logger.warning(f"Could not load library from {logger.format_path(filepath)}: {exception}") def save_browser_shape(self) -> None: - """Writes down the rows the catalogue stands open, so a later run brings them back.""" + """Writes down the rows the catalog stands open, so a later run brings them back.""" self._session_manager.set_expanded_rows( self._library_panel.tag, self._library_panel.expanded_rows, diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 1279102e4..a4fc59e95 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -512,7 +512,7 @@ def create_tab(self) -> None: self._sync_instruments_width() def _build_reconstruction_column(self, parent: str) -> None: - """Stacks the audio, plot, and stems cards down the centre column.""" + """Stacks the audio, plot, and stems cards down the center column.""" self._reconstruction_audio_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._reconstruction_plot_panel.create_panel(parent) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 01f178d01..beb031410 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1144,7 +1144,7 @@ def repaint(self) -> None: """Draws every table again so its tints take the palette now in place. DearPyGui keeps a table's row, column and cell tints as state of the table rather than - as a property of an item, so they take a new colour by being issued again. Each panel + as a property of an item, so they take a new color by being issued again. Each panel answers for the tints it owns, and this is where the palette asks all three. """ self._sequencer_tracker_panel.repaint() @@ -1401,7 +1401,7 @@ def _commit_replace_reconstruction( The detail is composed while the sample still holds the outgoing reconstruction, so it reads the name being replaced alongside the incoming one. The replacement is announced in the same - window, ahead of the substitution, because an editor holding the sample open recognises it by + window, ahead of the substitution, because an editor holding the sample open recognizes it by the identity of the reconstruction it is about to give up. The frequency adoption, the rename, and the substitution share a single history entry, so one undo restores the previous rate, name, and audio together. @@ -1684,7 +1684,7 @@ def create_tab(self) -> None: self._sync_browser_width() def _build_center_column(self, parent: str) -> None: - """Stacks the order table and tracker tracker down the centre column.""" + """Stacks the order table and tracker tracker down the center column.""" self._sequencer_order_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._sequencer_tracker_panel.create_panel(parent) diff --git a/src/sampletones_application/layout/general/colors/feature.py b/src/sampletones_application/layout/general/colors/feature.py index e8780ee55..b9d9ee0f6 100644 --- a/src/sampletones_application/layout/general/colors/feature.py +++ b/src/sampletones_application/layout/general/colors/feature.py @@ -7,7 +7,7 @@ class FeatureColors(BaseModel, extra="forbid", frozen=True): """The per-feature palette shared by every view that names a feature. The details tab's bar plots and the history panel's detail segments both - paint from this block, so a feature keeps one colour across the + paint from this block, so a feature keeps one color across the application. """ diff --git a/src/sampletones_application/layout/general/columns.py b/src/sampletones_application/layout/general/columns.py index 6912944f4..3f85fb18a 100644 --- a/src/sampletones_application/layout/general/columns.py +++ b/src/sampletones_application/layout/general/columns.py @@ -10,7 +10,7 @@ class ColumnsLayout(BaseModel, extra="forbid", frozen=True): that every tab carries, so the side panel stays the same size across tabs; each tab's own right column lives in that tab's section (``.right_column``). A column ``height`` of -1 fills the tab vertically. ``center_weight`` is the - share of the surplus width the stretching centre column claims against each side + share of the surplus width the stretching center column claims against each side column's single share as the viewport grows past the responsive baseline (see ``ResponsiveLayout`` and ``expanded_side_width``). """ diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py b/src/sampletones_application/layout/tabs/sequencer/colors/channel.py index c43e3655e..1a5df62d9 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/channel.py @@ -4,10 +4,10 @@ class ChannelColors(BaseModel, extra="forbid", frozen=True): - """Per-channel identity colours shared by the order table and the tracker grid. + """Per-channel identity colors shared by the order table and the tracker grid. - The order table paints each channel's row label in its colour; the tracker grid - tints each channel's column background with the same colour at a low alpha, so a + The order table paints each channel's row label in its color; the tracker grid + tints each channel's column background with the same color at a low alpha, so a channel keeps one identity across both views. """ diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/header.py b/src/sampletones_application/layout/tabs/sequencer/colors/header.py index 21dce042d..bd1325612 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/header.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/header.py @@ -4,7 +4,7 @@ class HeaderColors(BaseModel, extra="forbid", frozen=True): - """Colours the tracker's clickable column header takes. + """Colors the tracker's clickable column header takes. ``background`` is the band the header row sits in, the shade a table header carries; ``hovered`` and ``active`` are the washes a header label takes under the pointer and while diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history.py b/src/sampletones_application/layout/tabs/sequencer/colors/history.py index 837c60ac5..ac07736e3 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/history.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/history.py @@ -5,7 +5,7 @@ class HistoryColors(BaseModel, extra="forbid", frozen=True): - """Colours for the history detail: the dimmed tint of future (redoable) entries + """Colors for the history detail: the dimmed tint of future (redoable) entries and the per-role token palette. """ diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py index 2a7f2590f..f4a8eb7c8 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py @@ -4,7 +4,7 @@ class HistoryRoleColors(BaseModel, extra="forbid", frozen=True): - """Colours for the history-detail token roles unique to the detail line. + """Colors for the history-detail token roles unique to the detail line. The voice/transpose/volume, frame, row, and sample tokens draw from the shared :class:`TrackerColors` palette; only the roles unique to the detail line diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/muted.py b/src/sampletones_application/layout/tabs/sequencer/colors/muted.py index 7e430b1a5..ea47917ea 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/muted.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/muted.py @@ -4,7 +4,7 @@ class MutedColors(BaseModel, extra="forbid", frozen=True): - """Colours marking a channel the song player silences. + """Colors marking a channel the song player silences. ``background`` is the neutral shade the channel takes in place of its identity tint — down its column in the tracker, along its row in the order table — so the channel diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/order.py b/src/sampletones_application/layout/tabs/sequencer/colors/order.py index ffd0eed4b..b6b691d2d 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/order.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/order.py @@ -4,7 +4,7 @@ class OrderColors(BaseModel, extra="forbid", frozen=True): - """Colours specific to the order table: the row-label column, the master row and + """Colors specific to the order table: the row-label column, the master row and the divider below it, and the per-column highlights for the current and playing positions. """ diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/row.py b/src/sampletones_application/layout/tabs/sequencer/colors/row.py index 559bc8001..2660cf950 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/row.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/row.py @@ -4,7 +4,7 @@ class RowColors(BaseModel, extra="forbid", frozen=True): - """Colours marking where a tracker row falls in the pulse of the pattern. + """Colors marking where a tracker row falls in the pulse of the pattern. ``beat`` lifts the row that opens each beat off the zebra stripe and ``bar`` marks the row that opens each bar more strongly, so a long pattern reads as a rhythm at a glance. diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/sample.py b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py index 02ccae710..e10518bf2 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/sample.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py @@ -4,7 +4,7 @@ class SampleColors(BaseModel, extra="forbid", frozen=True): - """Colours marking the tracker's sample column and the divider beside it.""" + """Colors marking the tracker's sample column and the divider beside it.""" column: WrittenColor divider: WrittenColor diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py index 5f5595b34..c297d3682 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py @@ -4,12 +4,12 @@ class TrackerColors(BaseModel, extra="forbid", frozen=True): - """The semantic text colours shared across every tracker view. + """The semantic text colors shared across every tracker view. One palette feeds the pattern grid, the order table, and the history detail so a - concept keeps its colour everywhere. Three of the tokens read a voice slot: ``voice`` + concept keeps its color everywhere. Three of the tokens read a voice slot: ``voice`` is what the slot wears while it names nothing, and ``sample`` and ``instrument`` are - the two kinds a named voice can be, so the slot's colour reports what it holds. + the two kinds a named voice can be, so the slot's color reports what it holds. ``transpose`` and ``volume`` carry the other two slots, and the ``frame`` and ``row`` indices and the ``order`` entries carry the grids around them. Defining them once keeps every panel in step. diff --git a/src/sampletones_application/logic/history/errors.py b/src/sampletones_application/logic/history/errors.py index ae756fadc..09c1d7c5a 100644 --- a/src/sampletones_application/logic/history/errors.py +++ b/src/sampletones_application/logic/history/errors.py @@ -2,7 +2,7 @@ class UntrackedMutationError(RuntimeError): """Raised when a project mutation fires outside any history transaction. Under strict deployment the history refuses to guess a grouping for an - unlabelled mutation and surfaces the completeness gap immediately, so the + unlabeled mutation and surfaces the completeness gap immediately, so the call site can be wrapped in a transaction. """ diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index 62bb8677c..ef58c7183 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -76,7 +76,7 @@ def is_restoring(self) -> bool: Every project transition reaches its handlers through the controller's single ``on_project_replaced`` signal, so a handler that keeps transient session state — a - listening mute set, an acknowledged prompt — reads this to recognise history + listening mute set, an acknowledged prompt — reads this to recognize history navigation and carry that state across it, while a new, opened, or closed document starts it fresh. """ diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 0d91ded2c..19b219b54 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -220,7 +220,7 @@ def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> N ) def move_source_within_level(self, path: Path, offset: int) -> None: - """Moves a recording past the neighbour it shares a level with.""" + """Moves a recording past the neighbor it shares a level with.""" self._apply(self._levels.move_within_level(path, offset)) def join_source_level(self, path: Path, offset: int) -> None: diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py index 3839987f0..236a77ff4 100644 --- a/src/sampletones_application/logic/main/stems.py +++ b/src/sampletones_application/logic/main/stems.py @@ -111,7 +111,7 @@ def keep_first(self) -> Self: return self.of([[sources[0]]]) if sources else self.of([]) def move_within_level(self, path: Path, offset: int) -> Self: - """Moves a recording past the neighbour it shares a level with, changing which of them ties first.""" + """Moves a recording past the neighbor it shares a level with, changing which of them ties first.""" source = self._source(path) if source is None: return self @@ -129,7 +129,7 @@ def move_within_level(self, path: Path, offset: int) -> Self: return self.of(levels) def join_level(self, path: Path, offset: int) -> Self: - """Sends a recording to the neighbouring level, where it picks with that level's recordings.""" + """Sends a recording to the neighboring level, where it picks with that level's recordings.""" source = self._source(path) if source is None: return self diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py index 7ae3c7741..8db01c69f 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py @@ -21,7 +21,7 @@ def build_sample_branch( """Builds the branch listing each source audio with the configurations that reconstructed it. Every top-level configuration directory contributes its reconstructions under the source folders - they mirror, so one audio gathers its variants and each variant is labelled by its configuration. + they mirror, so one audio gathers its variants and each variant is labeled by its configuration. """ branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) variants_by_source = collect_variants(scan) diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index a8c15b0be..4398ca6a7 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -69,7 +69,7 @@ def _span(first: int, last: int) -> str: def _kind_role(kind: Optional[VoiceKind]) -> HistoryDetailRole: - """The role a voice reads under, so its line wears the colour of the kind it is about. + """The role a voice reads under, so its line wears the color of the kind it is about. A voice the pool has stopped holding keeps the plain voice role, the same one the tracker's voice slot wears while it names nothing. @@ -92,7 +92,7 @@ class SequencerHistoryDetail: Language-managed words — the loop on/off states — are emitted as :class:`HistoryDetailWordSegment` keys and translated when the history view is built, keeping committed entries language-independent. A gesture on the voice - pool names its voice in the colour of the kind that voice is, so a recording + pool names its voice in the color of the kind that voice is, so a recording and a hand-written one read apart down the list of entries. """ @@ -326,7 +326,7 @@ def edit_reconstruction( """Describes a regenerated sample: its position, channel, and edited feature. The channel and the feature both render abbreviated — the ``P``/``p``/``T``/``N`` - channel letter and the feature's one-letter code in the same colour the details + channel letter and the feature's one-letter code in the same color the details tab plots it with — mirroring the tracker rows. """ return ( @@ -464,7 +464,7 @@ def _name( return HistoryDetailSegment(text=text, role=_kind_role(kind)) def _voice_name(self, voice_id: str) -> HistoryDetailSegment: - """The name a voice in the pool carries, read in the colour of the kind it is.""" + """The name a voice in the pool carries, read in the color of the kind it is.""" return self._name( self._samples_logic.voice_name(voice_id), self._samples_logic.voice_kind(voice_id), diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 1c32004f8..abdaf1a18 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -456,7 +456,7 @@ def set_sample_subcolumn( transpose: Optional[int] = None, volume: Optional[int] = None, ) -> None: - """Synchronises a subcolumn across the row's relevant channels. + """Synchronizes a subcolumn across the row's relevant channels. Transpose and volume exist independently of the voice slot: they follow the sample's channels when one is present, and otherwise reach every channel, so diff --git a/src/sampletones_application/parameters/geometry.py b/src/sampletones_application/parameters/geometry.py index c6826bd35..140abab02 100644 --- a/src/sampletones_application/parameters/geometry.py +++ b/src/sampletones_application/parameters/geometry.py @@ -10,7 +10,7 @@ class TabGeometry: """The geometry every tab coordinator lays its columns out on. These six values are identical across all four tabs: the uniform side column's - size, the responsive baseline and centre share that drive its width as the + size, the responsive baseline and center share that drive its width as the viewport grows, the rail it docks to when collapsed, and the gap between panels. They are flattened to scalars because each feeds a pure-int sink (``expanded_side_width``, ``ColumnSpec``, raw ``dpg.configure_item``) that blends diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion.py index a72021b0e..4af0d8b74 100644 --- a/src/sampletones_application/services/conversion.py +++ b/src/sampletones_application/services/conversion.py @@ -22,7 +22,7 @@ class ConversionService(ServiceBase[ConversionResult]): """ Translates raw ``ReconstructionConverter`` callbacks into a uniform result stream. - This normalises the impedance mismatch between the core converter's ad-hoc + This normalizes the impedance mismatch between the core converter's ad-hoc callback interface and the subscriber model used throughout the application. Library-generation progress is forwarded through the same stream so the converter panel has a single unified view. diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py index 840fc4eed..5fcf5789b 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -31,7 +31,7 @@ class SongRenderService(ServiceBase[RenderResult]): written back at the level the whole render turned out to reach — so the service reports one pass or two without knowing which format waits on the other side. - A render is one at a time. Cancelling is honoured between rows and between encoded blocks, + A render is one at a time. Cancelling is honored between rows and between encoded blocks, and the file a cancelled or failed run was writing is removed, so a result names a path only where a finished file stands. """ diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py index 2cdc565c5..e4e5351b4 100644 --- a/src/sampletones_application/services/song_player/player.py +++ b/src/sampletones_application/services/song_player/player.py @@ -256,7 +256,7 @@ def _play_row(self, stream: pyaudio.Stream, row: _RenderedRow) -> None: def _write_chunk(self, stream: pyaudio.Stream, chunk: np.ndarray) -> bool: """Writes one row to the device in buffer-sized blocks; reports whether it completed. - Each block is a separate blocking write, so a stop reached mid-row is honoured within + Each block is a separate blocking write, so a stop reached mid-row is honored within roughly one buffer period rather than at the next row boundary. That bounds how long the writer holds its stream open after a stop, which is what keeps the audio backend safe to tear down on demand. diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 652ae2852..55f22f2b0 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -122,7 +122,7 @@ class ApplicationShell: It serves two roles: - - *Lifecycle* — encodes the DPG initialisation sequence in ``setup()`` and + - *Lifecycle* — encodes the DPG initialization sequence in ``setup()`` and hides it behind a clean boundary. - *Runtime* — tab router, shortcut dispatcher, and per-frame UI driver. diff --git a/src/sampletones_application/ui/elements/field.py b/src/sampletones_application/ui/elements/field.py index 2e282f0c2..493df0ad8 100644 --- a/src/sampletones_application/ui/elements/field.py +++ b/src/sampletones_application/ui/elements/field.py @@ -18,7 +18,7 @@ def labeled_field( """Lay out a widget as ``label [ widget ]`` with the label in a fixed-width column. Opens a horizontal group holding the label text, pads it to ``label_width`` so the - widget aligns with its neighbours, and yields inside the group for the caller to + widget aligns with its neighbors, and yields inside the group for the caller to create the widget. The caller's widget omits its own ``label``. A ``font`` binds the label to that weight; leaving it unset keeps the default weight. @@ -39,8 +39,8 @@ def labeled_field( def subheader(label: str, *, parent: Union[int, str] = 0) -> None: """Render a bold subheader that groups the fields beneath it within a card. - Uses the default body colour at bold weight, sitting a level below the card's section - header so related fields read as one labelled group under a clear caption. + Uses the default body color at bold weight, sitting a level below the card's section + header so related fields read as one labeled group under a clear caption. """ label_id = dpg.add_text(label, parent=parent) FontRegistry.bind_to_item(label_id, Font.BOLD) diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 58ba202e8..bf7d047b0 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -38,7 +38,7 @@ class SeriesShade(StrEnum): - """How strongly a waveform series is drawn, which decides the colour its theme carries.""" + """How strongly a waveform series is drawn, which decides the color its theme carries.""" FULL = "full" DIMMED = "dimmed" @@ -260,12 +260,12 @@ def load_waveform_data( self.add_layer(layer) def set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Greys the reconstruction line while its audio is being regenerated, restoring it when done. + """Grays the reconstruction line while its audio is being regenerated, restoring it when done. - Only the reconstruction series is greyed; the original-audio series and the axes keep full + Only the reconstruction series is grayed; the original-audio series and the axes keep full strength, so the fade reads as "this waveform is being recomputed", and the status bar shows a regenerating hint for the same span. The state is remembered so an async data update arriving - mid-regeneration redraws the reconstruction still greyed. + mid-regeneration redraws the reconstruction still grayed. """ if self._reconstruction_dimmed == dimmed: return @@ -370,7 +370,7 @@ def _series_color( layer: Union[ArrayLayer, InstructionLayer], shade: SeriesShade, ) -> BaseColor: - """A layer's line colour in one of its two shades. + """A layer's line color in one of its two shades. The dimmed reconstruction is desaturated to gray and faded, so the drawn waveform — not just the legend swatch — clearly reads as inactive while its audio is recomputed. @@ -423,11 +423,11 @@ def _bind_series_theme( series_tag: str, layer: Union[ArrayLayer, InstructionLayer], ) -> None: - """Binds a line-colour theme to a series, holding one theme per shade the series takes. + """Binds a line-color theme to a series, holding one theme per shade the series takes. - A series switches between its full and dimmed shades — the reconstruction line greys while + A series switches between its full and dimmed shades — the reconstruction line grays while its audio is recomputed — by binding the theme built for that shade, and each theme carries - the colour token behind its shade, so both follow a palette swap. + the color token behind its shade, so both follow a palette swap. """ shade = self._series_shade(layer) theme_tag = compose_tag(series_tag, SUF_GRAPH_THEME, shade) diff --git a/src/sampletones_application/ui/elements/layout/columns.py b/src/sampletones_application/ui/elements/layout/columns.py index 010a57192..85ba26481 100644 --- a/src/sampletones_application/ui/elements/layout/columns.py +++ b/src/sampletones_application/ui/elements/layout/columns.py @@ -55,7 +55,7 @@ def build( """Builds the ground wrapper and the column row from ``columns``, then binds their themes. Returns the number of fixed-width side columns — the ones that hold their width while the - centre stretches — which the responsive width sizing shares the viewport's surplus among. + center stretches — which the responsive width sizing shares the viewport's surplus among. """ with dpg.child_window( width=-1, @@ -94,7 +94,7 @@ def row( Where :meth:`build` frames a whole tab, ``row`` composes a side-by-side group inside a column a coordinator already owns: it drops the ground wrapper and the outer gaps, so the - columns sit flush to the container edges with a single gap between each neighbour. Each + columns sit flush to the container edges with a single gap between each neighbor. Each column's builder fills its cell directly, letting the hosted cards own their own surface. A ``height`` of ``0`` sizes the row to its content. A ``tag`` names the row table so a coordinator can resize it when its hosted cards collapse. @@ -149,7 +149,7 @@ def _declare_row_columns( panel_gap: int, columns: Sequence[ColumnSpec], ) -> None: - """Declares each content column with a fixed gap column between neighbours only.""" + """Declares each content column with a fixed gap column between neighbors only.""" for index, column in enumerate(columns): if index > 0: dpg.add_table_column( diff --git a/src/sampletones_application/ui/elements/layout/responsive.py b/src/sampletones_application/ui/elements/layout/responsive.py index 8caffb6f4..7956f995a 100644 --- a/src/sampletones_application/ui/elements/layout/responsive.py +++ b/src/sampletones_application/ui/elements/layout/responsive.py @@ -7,10 +7,10 @@ def expanded_side_width( ) -> int: """Widens a fixed side column as the viewport grows past the design baseline. - A tab's centre column stretches while its side columns hold fixed widths, so the extra room a - viewport wider than ``baseline_viewport_width`` offers is shared out with the centre taking + A tab's center column stretches while its side columns hold fixed widths, so the extra room a + viewport wider than ``baseline_viewport_width`` offers is shared out with the center taking ``center_weight`` shares against each side's single share. Splitting the surplus - ``center_weight + side_panel_count`` ways and granting one share to each side keeps the centre the + ``center_weight + side_panel_count`` ways and granting one share to each side keeps the center the widest column while the sides breathe on large displays. At the baseline the column sits at its configured ``base_width`` and grows only as surplus appears above it. """ diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index e751b6280..b76ca1410 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -146,9 +146,9 @@ def set_path(self, path: Pathlike, shorten: bool = True) -> None: dpg.set_value(self.tooltip, self.path_text) def set_status(self, text: str, color: BaseColor) -> None: - """Displays a non-path status (missing or not applicable) in a muted colour. + """Displays a non-path status (missing or not applicable) in a muted color. - The path is cleared so the row is inert: hovering holds the muted colour and a + The path is cleared so the row is inert: hovering holds the muted color and a click has nothing to open. """ self.path = Path() diff --git a/src/sampletones_application/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py index a275fe303..17cbd8633 100644 --- a/src/sampletones_application/ui/elements/pitch_stepper.py +++ b/src/sampletones_application/ui/elements/pitch_stepper.py @@ -36,7 +36,7 @@ class PitchStepperStyle: """The styling a pitch stepper draws itself with, narrowed from the general layout. A stepper needs only its own dimensions, the plus/minus button dimensions it embeds, and - the colour of its read-only value readout. Assembling this at the composition root lets a + the color of its read-only value readout. Assembling this at the composition root lets a panel that builds steppers receive just these three fields, mirroring the :meth:`TreeColors.create` narrowing. """ diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py index 73dc9edc5..b355f4363 100644 --- a/src/sampletones_application/ui/elements/plus_minus_buttons.py +++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py @@ -41,7 +41,7 @@ class GUIPlusMinusButtons(CallbackMixin): sign occupies. With ``hold_repeat`` a held button repeats its press after an initial delay, matching the stepping feel of a numeric field; otherwise each button fires once per click. Either button can be enabled or disabled independently, so a control can - grey out a step that would have no effect. + gray out a step that would have no effect. """ def __init__( diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 53cd5f2c8..16acae045 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -59,7 +59,7 @@ class GUIStemsList(CallbackMixin): Both the converter's gathered recordings and a reconstruction's recorded assignment are the same list, so one definition draws them and each owner turns on the affordances it can - honour: ``draggable`` makes a row itself the thing you drag and opens a drop strip between + honor: ``draggable`` makes a row itself the thing you drag and opens a drop strip between the bands, ``master_checkbox`` gives the row a leading box moving every channel at once, ``removable`` gives it the danger-toned button that takes it out, and ``retain_last_row`` holds that button back once one row is all that stands. Rows are keyed by the @@ -387,7 +387,7 @@ def _create_remove(self, row: StemRowViewModel) -> None: def _render_row(self, row: StemRowViewModel) -> None: """Draw what the row currently holds onto the widgets it already stands as. - A row contributing nothing greys through its theme rather than through ``enabled``, so + A row contributing nothing grays through its theme rather than through ``enabled``, so it answers a drag and a right-click as readily as one in play. A box on a channel switched off elsewhere takes the muted tone and stays as clickable as any other. """ @@ -421,14 +421,14 @@ def _row_boxes(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: return tuple(channel_name for channel_name in self._channels_in_play if channel_name in row.offered_channels) def _channel_theme(self, channel_name: ChannelName) -> str: - """The tone a channel's boxes take: its own colour, muted where the channel is off.""" + """The tone a channel's boxes take: its own color, muted where the channel is off.""" if channel_name in self._muted_channels: return TAG_GLOBAL_THEME_CHANNEL_MUTED return CHANNEL_THEME_TAGS[channel_name] def _row_explanation(self, row: StemRowViewModel) -> str: - """What the row's hover states: where the recording is, why it is greyed out where it + """What the row's hover states: where the recording is, why it is grayed out where it contributes nothing, and how it moves where the list lets it.""" lines = [str(row.path)] if not row.available: diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py index 40f0a0166..a7a4d1bd7 100644 --- a/src/sampletones_application/ui/elements/table/caret.py +++ b/src/sampletones_application/ui/elements/table/caret.py @@ -19,7 +19,7 @@ def _as_item_id(item: Sender) -> Sender: """Resolves an alias string to its numeric item id, passing ids through unchanged. ``get_active_window`` and ``get_item_parent`` report items by alias while stored tags - are also aliases, so both sides are normalised to ids before comparison. + are also aliases, so both sides are normalized to ids before comparison. """ if isinstance(item, str): return int(dpg.get_alias_id(item)) diff --git a/src/sampletones_application/ui/elements/table/selection.py b/src/sampletones_application/ui/elements/table/selection.py index 14729cabf..24d2cd9fc 100644 --- a/src/sampletones_application/ui/elements/table/selection.py +++ b/src/sampletones_application/ui/elements/table/selection.py @@ -15,7 +15,7 @@ class TableSelection(Generic[KeyT]): A grid states which of its cells the selection covers, in whatever coordinates it selects in; which of them stand painted, and how far a held pointer has carried, are held here. A selected - cell is drawn by the selectable's own selected state, which the table's theme colours, so a + cell is drawn by the selectable's own selected state, which the table's theme colors, so a repaint reaches only the cells whose membership changed. """ diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 2428bd6eb..e64075b77 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -104,7 +104,7 @@ def refresh_status_message(self) -> str: ... def create_panel(self, parent: str) -> None: """Builds the card, and fills the tree where the panel is the one reading its model. - A browser reading the filesystem shows its rows as it appears, while a catalogue filled by + A browser reading the filesystem shows its rows as it appears, while a catalog filled by the owner that gathers it waits for that reading to arrive. """ self._setup_handlers() @@ -204,7 +204,7 @@ def _create_tree_root(self) -> None: pass def _create_tree_root_heading(self, label: str) -> None: - """Opens the root container as a labelled row the whole tree folds under.""" + """Opens the root container as a labeled row the whole tree folds under.""" with dpg.tree_node( label=label, tag=self.tree_tag, diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 563e55683..80d9072a1 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -295,8 +295,8 @@ def create_favorites_filter(self, parent: str) -> None: """Builds the control showing the favorites alone, as a row of its own under the search box. The checkbox carries the label, so the words are part of what the reader clicks, and the star - beside it reads in the colour the mode it stands for is drawn in. The label reads in the pair - every checkbox reads — the text colour while the control is live, the muted one while a + beside it reads in the color the mode it stands for is drawn in. The label reads in the pair + every checkbox reads — the text color while the control is live, the muted one while a rebuild holds it — so the shade states whether the control can be acted on. """ self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES) @@ -341,14 +341,14 @@ def _on_favorites_only_changed( self.redraw_tree() def _apply_favorites_glyph_color(self) -> None: - """Colours the star by the mode the control reads, wherever the browser offers one.""" + """Colors the star by the mode the control reads, wherever the browser offers one.""" if self._favorites_glyph_tag is None: return dpg_set_palette_color(self._favorites_glyph_tag, self._favorites_glyph_color()) def _favorites_glyph_color(self) -> BaseColor: - """The colour the star takes: the favorite colour while the mode is on, muted while it is off.""" + """The color the star takes: the favorite color while the mode is on, muted while it is off.""" if self._filter.favorites_only: return self._colors.favorite @@ -466,7 +466,7 @@ def _finish_emit( The emitter runs this once its last batch has attached. A filtered rebuild that drew no row fills the cleared tree with the message naming that outcome, so the filter's answer is - legible where the rows would be. Applying the filter here lets late-emitted nodes honour + legible where the rows would be. Applying the filter here lets late-emitted nodes honor an active search, and releasing the lock hands control back to interactive rebuilds. """ if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows: diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index 4c7a1d50f..bf7535e31 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -92,9 +92,9 @@ def dialog_window( yield def show(self, *args: Any, **kwargs: Any) -> None: - """Builds this appearance's tree and centres it once the layout has measured it. + """Builds this appearance's tree and centers it once the layout has measured it. - A window's size is known to DearPyGui only after a frame has drawn it, so the centre + A window's size is known to DearPyGui only after a frame has drawn it, so the center waits for that frame to arrive on its own. Waiting for it in place would hold the render thread, and a window is raised from wherever a result reaches the screen — including the callback drain that runs between frames, where the frame being waited for is the one this diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 93a55f317..1ae4f4b1f 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -299,12 +299,12 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None: self._edit_section.watch() def _add_edit_action_items(self) -> None: - """States the focused surface's actions, or the clipboard four greyed out while none is.""" + """States the focused surface's actions, or the clipboard four grayed out while none is.""" if not self._build_edit_actions(): self._add_unfocused_clipboard_items() def _add_unfocused_clipboard_items(self) -> None: - """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor.""" + """Names the clipboard actions grayed out, the Edit menu with no grid holding a cursor.""" for element in UNFOCUSED_CLIPBOARD_ELEMENTS: dpg.add_menu_item( label=self._context_label(element), diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index c45b76d67..ae5c758c8 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -206,7 +206,7 @@ def _master_gain_readout(self, gain: float) -> MasterGainReadout: ) def _clip_warning_color(self, clip_fraction: float) -> ColorRGBA: - """Reddens the readout colour along the layout gradient by the projected boost fraction.""" + """Reddens the readout color along the layout gradient by the projected boost fraction.""" colors = self._layout.audio.master_gain return blend(colors.label_color.rgba, colors.clip_color.rgba, clip_fraction) diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 75215a95d..3807946b2 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -55,7 +55,7 @@ class LibraryLogicProtocol(Protocol): - """The library-catalogue contract ``GUIInstructionsLibraryPanel`` drives. + """The library-catalog contract ``GUIInstructionsLibraryPanel`` drives. Typing the collaborator structurally keeps the panel bound to the queries its rendering needs — the current-library check runs per node, and the @@ -78,7 +78,7 @@ def get_path(self, key: InstructionLibraryKey) -> Path: ... class GUIInstructionsLibraryPanel(GUIFileBrowserPanel): - """The Instructions tab's catalogue of instruction libraries and the generators inside them.""" + """The Instructions tab's catalog of instruction libraries and the generators inside them.""" _NAME_FONT: Font = Font.REGULAR_SMALL _MONOSPACE_CONFIG_NODES: bool = True @@ -166,9 +166,9 @@ def _setup_handlers(self) -> None: super()._setup_handlers() def _create_controls(self) -> None: - """Reads out what the catalogue holds, and offers what can be done to it. + """Reads out what the catalog holds, and offers what can be done to it. - The controls come in two sets: the ones a reader picks from while the catalogue sits still, + The controls come in two sets: the ones a reader picks from while the catalog sits still, and the progress bar and cancel button a generation replaces them with. """ self._create_library_status() diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 8b5cdeb1c..79b3f1661 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -344,7 +344,7 @@ def _on_dropped_on_level(self, key: str, position: int) -> None: self.call(self.on_source_dropped_on_level, Path(key), position) def _show_row_menu(self, key: str) -> None: - """Names the moves the row can make, greying out the ones that would change nothing, + """Names the moves the row can make, graying out the ones that would change nothing, and offers the recording's own filesystem actions below them.""" row = self._stems_list.row(key) if row is None: diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 36df086ab..0bfe86222 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -230,7 +230,7 @@ def _create_size_field( The figure names how much of the NES data area an export spends, so it reads as information beside the fields that change: the label column aligns with the stepper - below it, and the value carries the stepper's own read-only colour and font. A tooltip + below it, and the value carries the stepper's own read-only color and font. A tooltip names the export the figure measures, since the formats spend differently. """ with labeled_field( @@ -561,7 +561,7 @@ def _apply_playing_state( ) -> None: """Marks one channel's tab as playing or standing by. - The muted theme reaches the tab label alone; the tab's body carries its own text colour, + The muted theme reaches the tab label alone; the tab's body carries its own text color, so a channel standing by stays as readable to edit as one that plays. """ theme_tag = TAG_GLOBAL_THEME_INSTRUMENT_TABS if is_playing else TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED @@ -910,10 +910,10 @@ def _apply_input_theme( feature_key: FeatureKey, item_count: int, ) -> None: - """Colours the sequence input by how a FamiTracker export treats its length. + """Colors the sequence input by how a FamiTracker export treats its length. A sequence longer than ``MAX_SEQUENCE_ITEMS`` exports its opening items, so the - input carries the warning colour to show which part of the envelope reaches a + input carries the warning color to show which part of the envelope reaches a FamiTracker file. """ self._sequence_lengths[(channel_name, feature_key)] = item_count diff --git a/src/sampletones_application/ui/panels/sequencer/channels.py b/src/sampletones_application/ui/panels/sequencer/channels.py index c6f9efc4f..41c6c52a2 100644 --- a/src/sampletones_application/ui/panels/sequencer/channels.py +++ b/src/sampletones_application/ui/panels/sequencer/channels.py @@ -77,7 +77,7 @@ def click(self, sender: Sender, channel: Optional[ChannelName]) -> None: switches every channel at once. The selectable is released as the click is handled, so a name behaves as a button that - reports the mix through its colour, and the edit cursor stays where it is. + reports the mix through its color, and the edit cursor stays where it is. """ dpg.set_value(sender, False) if channel is None: diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 173b8eac6..e39dc5033 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -61,7 +61,7 @@ def matches(self, entry: HistoryEntryViewModel) -> bool: class GUISequencerHistoryPanel(GUIPanel): - """Shows the undo/redo stack: labelled entries with the current state marked. + """Shows the undo/redo stack: labeled entries with the current state marked. Selecting an entry jumps the project to that state; the Undo and Redo buttons step one entry at a time. Entries past the current one are the redo branch and @@ -275,9 +275,9 @@ def _create_entry( A ``span_columns`` selectable backs the whole row, so clicking anywhere jumps to that entry and the current entry keeps the native selected highlight. The label and each detail segment render as separate text items - in the second column, letting every segment carry its role's colour while + in the second column, letting every segment carry its role's color while the non-interactive text passes clicks through to the selectable beneath. - A future (redo) entry greys every token. + A future (redo) entry grays every token. """ with dpg.table_row(parent=table, before=before) as row: selectable = dpg.add_selectable( diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 0fea714b5..6c2deee57 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -310,10 +310,10 @@ def create_panel(self, parent: str) -> None: self._register_handlers() def _create_entry_themes(self) -> None: - """Colours every pattern entry, in the shade its channel sounds and the shade it is silenced. + """Colors every pattern entry, in the shade its channel sounds and the shade it is silenced. - The entry themes target only the selectable text, so they leave every other colour to the - global theme; the dimmed variant keeps the entry colour at reduced alpha, so a silenced + The entry themes target only the selectable text, so they leave every other color to the + global theme; the dimmed variant keeps the entry color at reduced alpha, so a silenced channel's frames stay readable and editable while the others are worked on. A row label carries the header's hover and press washes instead, so it reads as the switch it is. """ @@ -570,7 +570,7 @@ def repaint(self) -> None: """Issues every tint the table holds as its own state. DearPyGui keeps a row, column or cell highlight on the table rather than on an item, - so a colour reaches it only by being pushed again. Gathering the pushes here gives + so a color reaches it only by being pushed again. Gathering the pushes here gives the palette one call to make and keeps a rebuilt table and a recolored one identical. """ if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE): @@ -628,7 +628,7 @@ def _highlight_master_cell_at(self, column: int) -> None: ) def _tint_channel_rows(self) -> None: - """Washes each channel row with a light tint of its identity colour. + """Washes each channel row with a light tint of its identity color. Uses a row highlight so it sits on a layer beneath the position and cursor highlights, which keep working; a cleared cursor cell falls back to the row @@ -900,6 +900,7 @@ def _on_cell_clicked( self._apply_state(OrderInputState(cursor=cursor)) + # TODO: to abstract def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: """Carries the selection to the cell under a held pointer, which is what drags a range out. @@ -1086,15 +1087,18 @@ def _show_context_menu( dpg.add_separator() self.add_action_items(target) + # TODO: to abstract @property def edit_surface(self) -> OrderEditSurface: """This table as the menu bar's Edit menu reaches it.""" return self._surface + # TODO: to abstract def input_state(self) -> OrderInputState: """Where the cursor stands and what it has selected, which a target is resolved from.""" return self._input_state + # TODO: to abstract def owns_keys(self) -> bool: """Whether the table owns the next key, which is also what the Edit menu asks.""" return self._keys_active() @@ -1195,7 +1199,7 @@ def _add_move_item( shortcut_id: ShortcutId, position: int, ) -> None: - """Adds a move item, greyed out (disabled) when the move would have no effect. + """Adds a move item, grayed out (disabled) when the move would have no effect. The action names both the direction it moves and the accelerator it prints, so the item a reader sees is the one the key press performs. @@ -1226,6 +1230,7 @@ def _keys_active(self) -> bool: """ return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused + # TODO: to extract common parts [_on_key_pressed] def _on_key_pressed(self, event: KeyEvent) -> bool: """Applies an order key to the active cell, reporting whether the table consumed it. @@ -1536,7 +1541,7 @@ def _get_removable_position(self) -> Optional[int]: return None def _refresh_remove_enabled(self) -> None: - """Enables ``[-]`` while a press would remove a frame, so a greyed-out button tells the + """Enables ``[-]`` while a press would remove a frame, so a grayed-out button tells the user that removal awaits a selected frame. """ if self._buttons is not None: diff --git a/src/sampletones_application/ui/panels/sequencer/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py index c34b19d4f..03e2fe1a8 100644 --- a/src/sampletones_application/ui/panels/sequencer/rows.py +++ b/src/sampletones_application/ui/panels/sequencer/rows.py @@ -62,7 +62,7 @@ def row_background( colors: SequencerColors, cues: RowCues, ) -> Optional[BaseColor]: - """The colour a pattern row's background carries, group and cue taken together. + """The color a pattern row's background carries, group and cue taken together. DearPyGui offers one row background above the zebra stripe, so the row's standing emphasis and whatever mark is passing over it arrive as a single shade: the cue is diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index e858e2275..7a035a9ee 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -461,10 +461,10 @@ def _create_themes(self) -> None: self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row) def _create_subcolumn_themes(self) -> None: - """Builds every text theme a cell can wear, in its full and its dimmed colour. + """Builds every text theme a cell can wear, in its full and its dimmed color. The voice slot carries one theme per kind of voice it can name, beside the shade it takes - while it names none, so the colour of a cell reports what stands in it. Transpose and volume + while it names none, so the color of a cell reports what stands in it. Transpose and volume speak for themselves and take one each. The dimmed variant keeps the same hue at reduced alpha, so a silenced channel's values stay readable and editable while the others are worked on. @@ -491,7 +491,7 @@ def _create_header_themes(self) -> None: """Builds the two shades a channel's header label takes: audible and silenced. Both carry the header's own hover and press washes, so a label reads as the switch it is - while its text colour reports whether the channel sounds. + while its text color reports whether the channel sounds. """ header = self._layout.colors.header self._header_theme = create_header_selectable_theme( @@ -657,7 +657,7 @@ def repaint(self) -> None: """Issues every tint the table holds as its own state. DearPyGui keeps a row, column or cell highlight on the table rather than on an item, - so a colour reaches it only by being pushed again. Gathering the pushes here gives + so a color reaches it only by being pushed again. Gathering the pushes here gives the palette one call to make and keeps a rebuilt table and a recolored one identical. """ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): @@ -675,7 +675,7 @@ def update_settings(self, view_model: SequencerSettingsViewModel) -> None: self._apply_row_backgrounds() def _row_background(self, row_index: int) -> Optional[BaseColor]: - """The colour a pattern row's background carries under the marks standing on it now.""" + """The color a pattern row's background carries under the marks standing on it now.""" cursor = self._input_state.cursor return row_background( row_index, @@ -692,7 +692,7 @@ def _draw_row( row_index: int, color: Optional[BaseColor], ) -> None: - """Gives one pattern row the background colour it resolved to. + """Gives one pattern row the background color it resolved to. Position updates arrive on the callback-queue worker thread, so the table may be shorter than the row asked for if the main thread shrank it (a rows-per-pattern change) in between; @@ -715,7 +715,7 @@ def _draw_row( ) def _paint_row(self, row_index: int) -> None: - """Draws a row in the colour its group and the marks on it resolve to.""" + """Draws a row in the color its group and the marks on it resolve to.""" self._draw_row(row_index, self._row_background(row_index)) def _paint_hovered_row(self, row_index: int) -> None: @@ -777,7 +777,7 @@ def _highlight_header_row(self) -> None: ) def _tint_channel_columns(self) -> None: - """Washes each channel's column with a faint tint of its identity colour. + """Washes each channel's column with a faint tint of its identity color. Reapplied after each rebuild alongside the sample column so the tint survives row replacement, giving the tracker the same per-channel identity the order @@ -830,7 +830,7 @@ def _compute_cell_kinds( self, view_model: SequencerTrackerViewModel, ) -> CellKinds: - """Which kind of voice each voice slot names, which is the colour that slot wears. + """Which kind of voice each voice slot names, which is the color that slot wears. Only the voice slot reports a kind, so the map covers those cells alone and a refresh re-themes as many of them as the edit touched. @@ -1093,11 +1093,11 @@ def _bind_cell_theme(self, key: CellKey) -> None: dpg.bind_item_theme(cell_id, self._cell_theme(key)) def _cell_theme(self, key: CellKey) -> int: - """The theme a cell wears: its slot's colour, dimmed while its channel is silenced. + """The theme a cell wears: its slot's color, dimmed while its channel is silenced. - A voice slot takes the colour of the kind of voice standing in it, so a reader tells a + A voice slot takes the color of the kind of voice standing in it, so a reader tells a recording from a hand-written one across the whole grid; a slot naming none takes the - neutral shade the other slots' colours are read against. + neutral shade the other slots' colors are read against. """ _, channel, subcolumn = key kind = self._cell_kinds.get(key) @@ -1221,7 +1221,7 @@ def _show_voice( label: str, kind: Optional[VoiceKind], ) -> None: - """Shows a voice slot's new reading before the project answers, colour and number together. + """Shows a voice slot's new reading before the project answers, color and number together. The panel holds both caches, so an edit that the logic goes on to refuse leaves the cell reading exactly what it held. @@ -1232,7 +1232,7 @@ def _show_voice( self._bind_cell_theme(key) def _forget_voice(self, row: int, channel: Optional[ChannelName]) -> None: - """Empties a voice slot's caches, so a cleared cell drops its number and its colour as one.""" + """Empties a voice slot's caches, so a cleared cell drops its number and its color as one.""" key = (row, channel, SubColumn.VOICE) self._editable_cells.values.pop(key, None) self._cell_kinds[key] = None @@ -1337,6 +1337,7 @@ def _on_cell_clicked( self._apply_state(TrackerInputState(cursor=cursor, pending="")) + # TODO: to abstract [_on_cell_held] def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: """Carries the selection to the cell under a held pointer, which is what drags a range out. @@ -1513,15 +1514,18 @@ def _show_context_menu( dpg.add_separator() self.add_action_items(target) + # TODO: to abstract @property def edit_surface(self) -> TrackerEditSurface: """This grid as the menu bar's Edit menu reaches it.""" return self._surface + # TODO: to abstract def input_state(self) -> TrackerInputState: """Where the cursor stands and what it has selected, which a target is resolved from.""" return self._input_state + # TODO: to abstract def owns_keys(self) -> bool: """Whether the grid owns the next key, which is also what the Edit menu asks.""" return self._keys_active() @@ -1703,6 +1707,7 @@ def _keys_active(self) -> bool: """ return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused + # TODO: to extract common parts [_on_key_pressed] def _on_key_pressed(self, event: KeyEvent) -> bool: """Applies a tracker key to the active cell, reporting whether the grid consumed it. diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index c6892f76c..c60d4827a 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -261,7 +261,7 @@ def _highlight_selected_row(self, position: int) -> None: def repaint(self) -> None: """Issues the selected row's tint again so it takes the palette now in place. - DearPyGui keeps a row highlight on the table rather than on an item, so the colour + DearPyGui keeps a row highlight on the table rather than on an item, so the color reaches it only by being pushed again. """ if self._selected_row is None or not dpg.does_item_exist(TAG_SEQUENCER_VOICES_TABLE): @@ -276,7 +276,7 @@ def _build_kind_cell( ) -> None: """Marks which kind the row carries, so a converted voice reads apart from a written one. - The glyph names the kind and its colour repeats it, which is the same pair the tracker's + The glyph names the kind and its color repeats it, which is the same pair the tracker's voice slot wears — so a row and the cells naming it read as one thing across the two panels. """ kind_cell = dpg.add_table_cell(parent=row_id) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 35652a6be..6d7acceeb 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -33,7 +33,7 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs. - Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node + Reads the tree both tabs share into rows, colors the ones the browser invents, and routes node clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and its refresh control, and adds the items its context menus offer. @@ -163,10 +163,10 @@ def _build_tree_node( state.parent = node_tag def _resolve_other_theme_tag(self, node: TreeNode) -> str: - """Selects the colour of a row the browser invents: a plain group, or a sample in wave colour. + """Selects the color of a row the browser invents: a plain group, or a sample in wave color. A sample row names the audio a set of reconstructions was made from, so it reads in the - colour audio files carry elsewhere in the application. + color audio files carry elsewhere in the application. """ match node.node_type: case NodeType.GROUP: diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py index de518002c..14aa7b9c9 100644 --- a/src/sampletones_application/ui/themes/inline.py +++ b/src/sampletones_application/ui/themes/inline.py @@ -8,7 +8,7 @@ def create_selectable_text_theme(color: BaseColor) -> int: - """Builds a theme colouring selectable text, leaving its other colours to the global theme.""" + """Builds a theme coloring selectable text, leaving its other colors to the global theme.""" return _create_selectable_theme({dpg.mvThemeCol_Text: color}) @@ -19,8 +19,8 @@ def create_header_selectable_theme( ) -> int: """Builds a theme for a selectable that carries a table column's label. - A selectable takes the ``Header`` colours under the pointer, so naming those alongside the - text colour gives a header label the pointer feedback a table header has, in place of the + A selectable takes the ``Header`` colors under the pointer, so naming those alongside the + text color gives a header label the pointer feedback a table header has, in place of the selection shade a cell takes. """ return _create_selectable_theme( @@ -35,7 +35,7 @@ def create_header_selectable_theme( def create_label_selectable_theme(color: BaseColor) -> int: """Builds a theme for a selectable that carries a label rather than a gesture. - Every header wash takes the label's own colour at zero alpha, so the cell reads as plain + Every header wash takes the label's own color at zero alpha, so the cell reads as plain text while it keeps the layout a selectable lays out with, which is what lets it line up with the clickable labels beside it. """ @@ -54,7 +54,7 @@ def _create_selectable_theme(colors: Dict[int, BaseColor]) -> int: """Builds a theme carrying ``colors`` for a selectable in both enabled states. DearPyGui resolves an item against the theme component that matches the - item's enabled state. Carrying the colour in both components keeps the theme + item's enabled state. Carrying the color in both components keeps the theme authoritative for the selectable in every state — including frames where its container is disabled — matching the loader's policy that a theme fully describes both item states. diff --git a/src/sampletones_application/ui/themes/items.py b/src/sampletones_application/ui/themes/items.py index fee56fa1f..f1bfff8b6 100644 --- a/src/sampletones_application/ui/themes/items.py +++ b/src/sampletones_application/ui/themes/items.py @@ -10,13 +10,13 @@ class ThemeEntryKey(NamedTuple): - """Merge identity of one theme colour or style entry. + """Merge identity of one theme color or style entry. Entries sharing a key target the same slot, so inheritance resolution lets a child theme's entry override its parent's, and the disabled mirror can address the disabled counterpart of an enabled slot directly. ``is_style`` separates the - colour and style enum families, whose integer keys overlap, so a style and a - colour sharing an item type and integer occupy distinct slots. + color and style enum families, whose integer keys overlap, so a style and a + color sharing an item type and integer occupy distinct slots. """ item_type: int diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index 855e27834..5949fa7ab 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -85,7 +85,7 @@ def _effective_parent(spec: ThemeSpec) -> Optional[str]: the base theme, so every theme carries the shared base's complete component set and states only its own overrides. The base theme itself stands alone. This makes inheritance the default: a bound item theme always resolves to - the global base plus its overrides, so every colour it omits keeps the + the global base plus its overrides, so every color it omits keeps the base's value. """ if spec.extends is not None: @@ -188,7 +188,7 @@ def _mirror_disabled_entries(entries: ThemeEntries) -> ThemeEntries: item's enabled state, and it classifies many presentation items (menus, text labels, tree headers) as disabled; when the matching component is missing it re-applies its built-in palette, and the palette of the last - themed item drawn bleeds into the global style, recolouring the entire + themed item drawn bleeds into the global style, recoloring the entire application. Mirroring keeps every theme complete for both states, so items render with the theme's own values whichever state DearPyGui assigns them and the global style stays intact. Disabled-state entries stated explicitly @@ -225,8 +225,8 @@ def _mirror_disabled_entries(entries: ThemeEntries) -> ThemeEntries: def _entries_to_items(entries: ThemeEntries) -> ThemeItems: """Gathers the entries into the components a theme is built from, broadest first. - DearPyGui fills an item's colours by walking a theme's components in the order they were - created, so the last one covering a colour is the one the item wears. A component naming a + DearPyGui fills an item's colors by walking a theme's components in the order they were + created, so the last one covering a color is the one the item wears. A component naming a single item type states what that type is meant to look like, and one naming every type states the ground it stands on, so the ground is laid first and the item type paints over it. Ordering them here keeps that true whichever order a theme and the theme it extends diff --git a/src/sampletones_application/ui/themes/theme.py b/src/sampletones_application/ui/themes/theme.py index 8742b7318..b80cc0ff1 100644 --- a/src/sampletones_application/ui/themes/theme.py +++ b/src/sampletones_application/ui/themes/theme.py @@ -44,15 +44,15 @@ def _index(items: ThemeItems) -> ThemeDictionary: def components(self) -> Tuple[ThemeParameter, ...]: """The components the theme is built from, in the order DearPyGui fills an item from them. - A later component covering a colour is the one the item wears, so the order states which - of two components naming the same colour has the final say. + A later component covering a color is the one the item wears, so the order states which + of two components naming the same color has the final say. """ return tuple(self._items.items) def create(self) -> None: - """Builds the DearPyGui theme once, registering each colour item it fills. + """Builds the DearPyGui theme once, registering each color item it fills. - DearPyGui copies a colour into the item at the call that fills it, so each one is + DearPyGui copies a color into the item at the call that fills it, so each one is handed over through the palette bindings, which repaint the theme in place when another palette is activated. """ @@ -111,7 +111,7 @@ def get_color( enabled_state: bool = True, category: int = dpg.mvThemeCat_Core, ) -> Optional[ColorRGBA]: - """The value a theme colour carries under the active palette.""" + """The value a theme color carries under the active palette.""" theme_item = self.get( item_type, key, diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py index ba7ea4b9a..60f3847b7 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py +++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py @@ -130,7 +130,7 @@ def call( @staticmethod def _response_rule() -> MatchRule: - """Subscribes to the outcome of every portal request, each call recognising its own.""" + """Subscribes to the outcome of every portal request, each call recognizing its own.""" return MatchRule( type="signal", interface=REQUEST_INTERFACE, diff --git a/src/sampletones_application/utils/gui/align.py b/src/sampletones_application/utils/gui/align.py index 4117f4949..5079b7376 100644 --- a/src/sampletones_application/utils/gui/align.py +++ b/src/sampletones_application/utils/gui/align.py @@ -23,11 +23,11 @@ def center_item(tag: str) -> None: def center_when_settled(tag: str) -> None: - """Centres an autosizing window once its content has reached its final size. + """Centers an autosizing window once its content has reached its final size. An autosize window measures its content across the first couple of frames, so a stretch table or wrapped text reaches its final width and height only on the second layout pass. - Deferring the centre until then reads the settled size, so the window rests centred on its + Deferring the center until then reads the settled size, so the window rests centered on its first appearance the same way a reopened one does from its remembered size. """ FrameCallbackManager.set_frame_callback( diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py index 7a73d487e..c818215a7 100644 --- a/src/sampletones_application/utils/gui/dialogs/renderer.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -227,8 +227,8 @@ def show_config_recovery( """ Reports that a stored configuration was migrated, listing the discarded settings. - The version numbers are emphasised in bold, each discarded setting is drawn in the - highlight colour to stand apart from the surrounding prose, and the trailing path + The version numbers are emphasized in bold, each discarded setting is drawn in the + highlight color to stand apart from the surrounding prose, and the trailing path opens the configuration file's directory so the user can edit it directly. """ source = ( diff --git a/src/sampletones_application/utils/gui/dialogs/windows/error.py b/src/sampletones_application/utils/gui/dialogs/windows/error.py index 151e2c628..3e7d63fc0 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/error.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/error.py @@ -26,7 +26,7 @@ class GUIErrorDialogWindow(GUIDialogWindow): """A modal reporting an exception with its message and an optional traceback. - The exception's name and text are drawn in the error colour, the traceback starts + The exception's name and text are drawn in the error color, the traceback starts hidden behind its toggle, and OK — the initially focused button — dismisses the prompt. The title-bar close reads the same way. """ diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py index 0cab9693b..7110d56a8 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -101,7 +101,7 @@ def modifier_display(modifier: Modifier) -> str: """The name a modifier reads under on the platform in use. One key wears three names across the platforms — Command on macOS, Windows on Windows, Super on - Linux — so a combination reads the way the keyboard in front of the reader is labelled. Every + Linux — so a combination reads the way the keyboard in front of the reader is labeled. Every spelling stays readable everywhere through :data:`MODIFIER_NAMES`, which lets a scheme written for one platform be read on another. """ diff --git a/src/sampletones_application/utils/gui/palette/binding.py b/src/sampletones_application/utils/gui/palette/binding.py index 30c99e1ae..5ef5f4b16 100644 --- a/src/sampletones_application/utils/gui/palette/binding.py +++ b/src/sampletones_application/utils/gui/palette/binding.py @@ -13,7 +13,7 @@ @dataclass(frozen=True) class ArgumentBinding: - """A colour DearPyGui copied into one of an item's arguments.""" + """A color DearPyGui copied into one of an item's arguments.""" item: Sender color: BaseColor @@ -25,7 +25,7 @@ def push(self) -> None: @dataclass(frozen=True) class ThemeColorBinding: - """A colour DearPyGui copied into a theme colour item.""" + """A color DearPyGui copied into a theme color item.""" item: Sender color: BaseColor diff --git a/src/sampletones_application/utils/gui/palette/dpg.py b/src/sampletones_application/utils/gui/palette/dpg.py index 6adbcf422..c1f22aa35 100644 --- a/src/sampletones_application/utils/gui/palette/dpg.py +++ b/src/sampletones_application/utils/gui/palette/dpg.py @@ -12,12 +12,12 @@ def dpg_set_palette_color( *, argument: str = COLOR_ARGUMENT, ) -> None: - """Colours an item so it follows the palette, in place of passing ``color=`` to DearPyGui. + """Colors an item so it follows the palette, in place of passing ``color=`` to DearPyGui. Args: - item: Item to colour. - color: Token the colour is read from, kept for the next palette in place. - argument: Name of the item's colour argument, for an item that carries more than one. + item: Item to color. + color: Token the color is read from, kept for the next palette in place. + argument: Name of the item's color argument, for an item that carries more than one. """ PaletteBindings.bind( item, @@ -32,15 +32,15 @@ def dpg_add_palette_theme_color( *, category: int = dpg.mvThemeCat_Core, ) -> Sender: - """Adds a theme colour that follows the palette, inside an open theme component. + """Adds a theme color that follows the palette, inside an open theme component. Args: - key: Theme colour constant the value fills, such as ``dpg.mvThemeCol_Text``. - color: Token the colour is read from, kept for the next palette in place. + key: Theme color constant the value fills, such as ``dpg.mvThemeCol_Text``. + color: Token the color is read from, kept for the next palette in place. category: Theme category the constant belongs to. Returns: - Sender: The theme colour item, which repaints every widget bound to the theme. + Sender: The theme color item, which repaints every widget bound to the theme. """ item: Sender = dpg.add_theme_color( key, diff --git a/src/sampletones_application/utils/gui/palette/palette.py b/src/sampletones_application/utils/gui/palette/palette.py index 664e70a65..68a76d95d 100644 --- a/src/sampletones_application/utils/gui/palette/palette.py +++ b/src/sampletones_application/utils/gui/palette/palette.py @@ -15,14 +15,14 @@ class PaletteBindings: - """Every colour DearPyGui holds a copy of, and the token each copy came from. + """Every color DearPyGui holds a copy of, and the token each copy came from. - DearPyGui reads an item's colour argument and a theme colour item once, at the call that + DearPyGui reads an item's color argument and a theme color item once, at the call that fills it, so those copies keep the shade of the palette that was active then. Handing a - colour over through this registry keeps the :class:`BaseColor` alongside the copy, and + color over through this registry keeps the :class:`BaseColor` alongside the copy, and :meth:`apply` hands DearPyGui the value each token carries now. - One argument of one item holds one colour, so binding it again replaces what is recorded + One argument of one item holds one color, so binding it again replaces what is recorded for it: an item recolored on every hover stays a single entry. """ @@ -37,7 +37,7 @@ def bind( *, argument: str = COLOR_ARGUMENT, ) -> None: - """Colours one of an item's arguments now, and keeps the token behind it.""" + """Colors one of an item's arguments now, and keeps the token behind it.""" binding = ArgumentBinding( item=item, color=color, @@ -48,7 +48,7 @@ def bind( @classmethod def bind_theme_color(cls, item: Sender, color: BaseColor) -> None: - """Keeps the token behind a theme colour item the caller has just filled.""" + """Keeps the token behind a theme color item the caller has just filled.""" cls._theme_colors[item] = ThemeColorBinding( item=item, color=color, @@ -69,7 +69,7 @@ def apply(cls) -> None: @classmethod def bindings(cls) -> Iterator[PaletteBinding]: - """Every colour copy the registry currently tracks.""" + """Every color copy the registry currently tracks.""" return chain(cls._arguments.values(), cls._theme_colors.values()) @classmethod diff --git a/src/sampletones_application/utils/gui/staging.py b/src/sampletones_application/utils/gui/staging.py index f4f31af1f..7ebcba132 100644 --- a/src/sampletones_application/utils/gui/staging.py +++ b/src/sampletones_application/utils/gui/staging.py @@ -23,7 +23,7 @@ def create_stage() -> Sender: def staged_container(stage: Sender) -> Iterator[None]: """Push ``stage`` as the active container so parentless items land in it. - Items created with an explicit parent still honour that parent; the stage + Items created with an explicit parent still honor that parent; the stage captures the parentless ones. """ with dpg_container(stage): diff --git a/src/sampletones_application/utils/palette/colors/base.py b/src/sampletones_application/utils/palette/colors/base.py index c8db56333..e4bf9f4a0 100644 --- a/src/sampletones_application/utils/palette/colors/base.py +++ b/src/sampletones_application/utils/palette/colors/base.py @@ -6,20 +6,20 @@ @dataclass(frozen=True) class BaseColor(ABC): - """A colour read at the moment it is drawn with. + """A color read at the moment it is drawn with. - A colour keeps the form it was given rather than a value of its own, and :attr:`rgba` + A color keeps the form it was given rather than a value of its own, and :attr:`rgba` answers with what that form reads under the palette active right now, so the same object - gives a new colour once another palette is activated. Consumers hold the colour and read + gives a new color once another palette is activated. Consumers hold the color and read :attr:`rgba` where they hand it to DearPyGui. Each form is a frozen dataclass carrying what it was written or composed from, which makes - a colour hashable by that form and lets a theme cache key on the shade it holds. A form - composed from other colours reads them through this same property, so a shade taken from a - token follows a palette swap along with the colour it came from. + a color hashable by that form and lets a theme cache key on the shade it holds. A form + composed from other colors reads them through this same property, so a shade taken from a + token follows a palette swap along with the color it came from. """ @property @abstractmethod def rgba(self) -> ColorRGBA: - """The colour's value under the active palette.""" + """The color's value under the active palette.""" diff --git a/src/sampletones_application/utils/palette/colors/blended.py b/src/sampletones_application/utils/palette/colors/blended.py index cc77ad782..d46d8ee5d 100644 --- a/src/sampletones_application/utils/palette/colors/blended.py +++ b/src/sampletones_application/utils/palette/colors/blended.py @@ -7,7 +7,7 @@ @dataclass(frozen=True) class BlendedColor(BaseColor): - """A colour carried as a point on the gradient between two others, channel by channel.""" + """A color carried as a point on the gradient between two others, channel by channel.""" start: BaseColor end: BaseColor diff --git a/src/sampletones_application/utils/palette/colors/faded.py b/src/sampletones_application/utils/palette/colors/faded.py index a25f77976..5462570cd 100644 --- a/src/sampletones_application/utils/palette/colors/faded.py +++ b/src/sampletones_application/utils/palette/colors/faded.py @@ -7,12 +7,12 @@ @dataclass(frozen=True) class FadedColor(BaseColor): - """A colour carried at a fraction of full opacity, keeping its red, green and blue.""" + """A color carried at a fraction of full opacity, keeping its red, green and blue.""" color: BaseColor fraction: float @property def rgba(self) -> ColorRGBA: - """The carried colour's value under the active palette, at the carried opacity.""" + """The carried color's value under the active palette, at the carried opacity.""" return with_alpha_fraction(self.color.rgba, self.fraction) diff --git a/src/sampletones_application/utils/palette/colors/grayscale.py b/src/sampletones_application/utils/palette/colors/grayscale.py index b803eef08..f787f1a3c 100644 --- a/src/sampletones_application/utils/palette/colors/grayscale.py +++ b/src/sampletones_application/utils/palette/colors/grayscale.py @@ -7,11 +7,11 @@ @dataclass(frozen=True) class GrayscaleColor(BaseColor): - """A colour carried as the gray of the same luminance, keeping its alpha.""" + """A color carried as the gray of the same luminance, keeping its alpha.""" color: BaseColor @property def rgba(self) -> ColorRGBA: - """The carried colour's value under the active palette, desaturated.""" + """The carried color's value under the active palette, desaturated.""" return to_grayscale(self.color.rgba) diff --git a/src/sampletones_application/utils/palette/colors/layered.py b/src/sampletones_application/utils/palette/colors/layered.py index 84af55932..eadd7297e 100644 --- a/src/sampletones_application/utils/palette/colors/layered.py +++ b/src/sampletones_application/utils/palette/colors/layered.py @@ -7,7 +7,7 @@ @dataclass(frozen=True) class LayeredColor(BaseColor): - """A colour carried as one wash drawn over another, kept as the two it was composed from. + """A color carried as one wash drawn over another, kept as the two it was composed from. A surface that offers a single tint takes both washes through this form: the pair keeps following the palette, and the value handed over is the shade the two make together. diff --git a/src/sampletones_application/utils/palette/colors/literal.py b/src/sampletones_application/utils/palette/colors/literal.py index 76ae0636c..14734b5db 100644 --- a/src/sampletones_application/utils/palette/colors/literal.py +++ b/src/sampletones_application/utils/palette/colors/literal.py @@ -6,11 +6,11 @@ @dataclass(frozen=True) class LiteralColor(BaseColor): - """A colour written as a ``#rrggbb`` value, standing on its own.""" + """A color written as a ``#rrggbb`` value, standing on its own.""" value: ColorRGBA @property def rgba(self) -> ColorRGBA: - """The value the colour was written with.""" + """The value the color was written with.""" return self.value diff --git a/src/sampletones_application/utils/palette/colors/named.py b/src/sampletones_application/utils/palette/colors/named.py index 36b1c1fbf..9a159bf78 100644 --- a/src/sampletones_application/utils/palette/colors/named.py +++ b/src/sampletones_application/utils/palette/colors/named.py @@ -8,7 +8,7 @@ @dataclass(frozen=True) class NamedColor(BaseColor): - """A colour written as a palette reference, together with the source that answers it.""" + """A color written as a palette reference, together with the source that answers it.""" reference: PaletteReference source: PaletteSource diff --git a/src/sampletones_application/utils/palette/colors/written.py b/src/sampletones_application/utils/palette/colors/written.py index be2851530..0684e5753 100644 --- a/src/sampletones_application/utils/palette/colors/written.py +++ b/src/sampletones_application/utils/palette/colors/written.py @@ -13,7 +13,7 @@ def palette_source_from_context(info: ValidationInfo) -> PaletteSource: - """The palette source a colour reference binds to, taken from the validation context. + """The palette source a color reference binds to, taken from the validation context. Raises: ValueError: when the context omits the palette source entry. @@ -33,10 +33,10 @@ def palette_source_from_context(info: ValidationInfo) -> PaletteSource: def _written_color(value: object, info: ValidationInfo) -> BaseColor: - """The colour a configuration entry spells out, read once so its token answers at load. + """The color a configuration entry spells out, read once so its token answers at load. An entry is written as a palette reference (``.token``, optionally ``.token/alpha``) or - as a ``#rrggbb`` literal, and is kept in the form it was written. A colour built in code + as a ``#rrggbb`` literal, and is kept in the form it was written. A color built in code passes through as it stands, which is how a derived shade reaches a field. Raises: @@ -47,7 +47,7 @@ def _written_color(value: object, info: ValidationInfo) -> BaseColor: return value if not isinstance(value, str): - raise ValueError(f"A colour is written as a palette reference or a hex literal, got {type(value)}") + raise ValueError(f"A color is written as a palette reference or a hex literal, got {type(value)}") text = value.strip() color: BaseColor diff --git a/src/sampletones_application/utils/palette/palette.py b/src/sampletones_application/utils/palette/palette.py index b523df969..6b48bcf6d 100644 --- a/src/sampletones_application/utils/palette/palette.py +++ b/src/sampletones_application/utils/palette/palette.py @@ -12,9 +12,9 @@ class Palette(BaseModel, frozen=True): - """A named set of semantic colour tokens shared across a theme set and the layout. + """A named set of semantic color tokens shared across a theme set and the layout. - Colour fields reference these tokens by name so a colour is defined once and + Color fields reference these tokens by name so a color is defined once and reused everywhere, and swapping the palette restyles every theme and layout entry that resolves against it. """ @@ -33,7 +33,7 @@ def resolve(self, reference: PaletteReference) -> ColorRGBA: """ if reference.token not in self.colors: raise KeyError( - f"Palette {self.name!r} has no colour token {REFERENCE_PREFIX}{reference.token!r}. " + f"Palette {self.name!r} has no color token {REFERENCE_PREFIX}{reference.token!r}. " f"Known tokens: {sorted(self.colors)}" ) @@ -45,7 +45,7 @@ def resolve(self, reference: PaletteReference) -> ColorRGBA: @classmethod def load(cls, path: Path) -> Palette: - """Load the palette that colour references resolve against. + """Load the palette that color references resolve against. Raises: TypeError: when the palette file holds a value other than a mapping. diff --git a/src/sampletones_application/utils/palette/reference.py b/src/sampletones_application/utils/palette/reference.py index 1e9438bdc..786bf8023 100644 --- a/src/sampletones_application/utils/palette/reference.py +++ b/src/sampletones_application/utils/palette/reference.py @@ -7,12 +7,12 @@ class PaletteReference(BaseModel, frozen=True): - """A colour entry's reference to a named palette colour. + """A color entry's reference to a named palette color. Written in YAML as ``.token`` or ``.token/alpha`` where ``alpha`` is a fraction in ``[0, 1]`` that overrides the token's own alpha. The leading ``.`` marks the value as a reference and keeps it distinct from a ``#rrggbb`` literal, so a - colour field accepts either form in the same slot. + color field accepts either form in the same slot. """ token: str diff --git a/src/sampletones_application/utils/palette/source.py b/src/sampletones_application/utils/palette/source.py index 19a1fe7ec..412adaeb7 100644 --- a/src/sampletones_application/utils/palette/source.py +++ b/src/sampletones_application/utils/palette/source.py @@ -6,10 +6,10 @@ class PaletteSource(CallbackMixin): - """The palette every colour token resolves against, and the one place it changes. + """The palette every color token resolves against, and the one place it changes. A :class:`BaseColor` keeps the token it was written as and reads its value from - here, so activating another palette gives every colour in the application a new + here, so activating another palette gives every color in the application a new value with no reload and no re-injection. Whatever DearPyGui has already copied is repainted by the listener on ``on_palette_changed``. """ @@ -23,11 +23,11 @@ def palette(self) -> Palette: return self._palette def activate(self, palette: Palette) -> None: - """Make ``palette`` the one every colour token resolves against. + """Make ``palette`` the one every color token resolves against. Announces the change once the swap is in place, so the listener reads the new - colours as it repaints. Activating the palette already in place leaves both the - colours and the listener untouched. + colors as it repaints. Activating the palette already in place leaves both the + colors and the listener untouched. """ if palette == self._palette: return diff --git a/src/sampletones_application/view_model/sequencer/move.py b/src/sampletones_application/view_model/sequencer/move.py index 92e13a521..eb662da0c 100644 --- a/src/sampletones_application/view_model/sequencer/move.py +++ b/src/sampletones_application/view_model/sequencer/move.py @@ -18,7 +18,7 @@ class MoveDirection(Enum): def target(self, position: int, count: int) -> Optional[int]: """Resolve the destination index, or ``None`` when the move would keep the position where it is. - ``None`` is the grey-out signal: a move toward the start from the first + ``None`` is the gray-out signal: a move toward the start from the first position, or toward the end from the last, already sits at the boundary, so the menu disables that item. """ diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index b848f4235..bad11b612 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -28,7 +28,7 @@ class SequencerCellViewModel(BaseModel, frozen=True): """The kind of the voice this cell names, absent where it names none. The cell reads its voice by list position, which says nothing about what that voice is. The - kind travels beside it so a reader of the cell — the sample column's summary, the colour the + kind travels beside it so a reader of the cell — the sample column's summary, the color the slot takes — knows which of the two it is looking at. """ @@ -67,7 +67,7 @@ def subcolumn_channels(self) -> FrozenSet[ChannelName]: """Channels every sample column summary spans. A sample governs the channels its reconstruction covers, so its subcolumns - summarise exactly those. Transpose and volume stand on their own, so a row + summarize exactly those. Transpose and volume stand on their own, so a row naming no sample spans every channel. """ return self.sample_channels or frozenset(self.cells) @@ -100,7 +100,7 @@ def _aggregate( select: Callable[[SequencerCellViewModel], str], default: str, ) -> str: - """Summarise one subcolumn across the channels the sample column spans. + """Summarize one subcolumn across the channels the sample column spans. The summary holds a value only where every channel agrees on it, so :data:`MIXED` marks each way they can differ: a sample missing from one of diff --git a/src/sampletones_application/view_model/shared/history.py b/src/sampletones_application/view_model/shared/history.py index 62df52b73..762b49c61 100644 --- a/src/sampletones_application/view_model/shared/history.py +++ b/src/sampletones_application/view_model/shared/history.py @@ -5,12 +5,12 @@ class HistoryDetailRole(StrEnum): - """The kind of data a detail segment carries, driving its colour. + """The kind of data a detail segment carries, driving its color. A role is a semantic tag chosen by the logic layer; the panel maps it to a - concrete colour, keeping the detail-producing code free of any visual + concrete color, keeping the detail-producing code free of any visual concern. Three of them read a voice: ``SAMPLE`` and ``INSTRUMENT`` name the - kind a line is about, so its position and its name wear that kind's colour, + kind a line is about, so its position and its name wear that kind's color, and ``VOICE`` carries a voice reference the kind says nothing about — the tracker's voice slot, and a voice the pool has stopped holding. """ diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index f8f0d3047..eba1f7b37 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -11,7 +11,7 @@ class StemRowViewModel(BaseModel, frozen=True): A row states where it stands — the level it picks on, the place it takes among the recordings sharing that level, and how many of each the list holds — so the moves a list - offers grey themselves out from the row alone. ``key`` is the identity the list reports a + offers gray themselves out from the row alone. ``key`` is the identity the list reports a gesture under: the recording's path where the list gathers files, the stem id where it describes a recorded assignment. ``offered_channels`` names the boxes the row draws and ``channels`` the ones ticked among them. diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py index 6c740dd59..80c6d51ba 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -85,9 +85,9 @@ def monitor_area(self) -> MonitorArea: return self._monitor_area(int(viewport_x), int(viewport_y), width, height) def refresh_clear_color(self) -> None: - """Paints the area around the windows in the main theme's background colour. + """Paints the area around the windows in the main theme's background color. - DearPyGui holds the clear colour outside the theme system, so it is issued again + DearPyGui holds the clear color outside the theme system, so it is issued again whenever the theme's background answers with a new value. """ color = self._theme.get_color(dpg.mvAll, dpg.mvThemeCol_WindowBg) diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py index 7c437fe08..e7d388bd9 100644 --- a/src/sampletones_assets/mark/raster.py +++ b/src/sampletones_assets/mark/raster.py @@ -51,7 +51,7 @@ def render(self) -> Image.Image: return image def _background(self) -> Image.Image: - """The frame: a vertical gradient between the two background colours, rounded at its corners.""" + """The frame: a vertical gradient between the two background colors, rounded at its corners.""" size = (self.canvas, self.canvas) top = Image.new("RGB", size, self.mark.colors.background.top) bottom = Image.new("RGB", size, self.mark.colors.background.bottom) diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py index fa600fe11..6e78e8710 100644 --- a/src/sampletones_assets/mark/specification/__init__.py +++ b/src/sampletones_assets/mark/specification/__init__.py @@ -21,7 +21,7 @@ class Mark(BaseModel, extra="forbid", frozen=True): """ frame: MarkFrame = Field(description="The rounded square the mark sits on.") - colors: MarkColors = Field(description="The colours the mark is drawn in.") + colors: MarkColors = Field(description="The colors the mark is drawn in.") waves: MarkWaves = Field(description="The wave crossing the frame.") render: MarkRender = Field(description="How the mark is rasterized.") diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py index 1063ea2b7..8f9820420 100644 --- a/src/sampletones_assets/mark/specification/colors.py +++ b/src/sampletones_assets/mark/specification/colors.py @@ -16,14 +16,14 @@ def _validate_hex_color(value: str) -> str: class MarkBackground(BaseModel, extra="forbid", frozen=True): """The vertical gradient filling the frame.""" - top: HexColor = Field(description="Colour at the top edge of the frame.") - bottom: HexColor = Field(description="Colour at the bottom edge of the frame.") + top: HexColor = Field(description="Color at the top edge of the frame.") + bottom: HexColor = Field(description="Color at the bottom edge of the frame.") class MarkColors(BaseModel, extra="forbid", frozen=True): - """The mark's colours, written as the hex strings the vector carries.""" + """The mark's colors, written as the hex strings the vector carries.""" background: MarkBackground = Field(description="Gradient behind the wave.") - sine: HexColor = Field(description="Colour of the smooth half of the wave.") - square: HexColor = Field(description="Colour of the stepped half of the wave.") - rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.") + sine: HexColor = Field(description="Color of the smooth half of the wave.") + square: HexColor = Field(description="Color of the stepped half of the wave.") + rim: HexColor = Field(description="Color of the hairline inside the frame's edge.") diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index d5437fc73..bd52ec466 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -416,7 +416,7 @@ main.advanced.label.select_library_directory: "Select instruction library direct main.advanced.label.combo_spectrum_method: "Method" main.advanced.label.slider_transformation_gamma: "Feature scaling" main.advanced.label.input_max_workers: "Workers" -main.advanced.tooltip.tooltip_spectrum_method: "Chooses how the sample is analysed into a spectrum:\n • FFT uses evenly spaced frequency bins\n • Log-spaced FFT regroups those bins onto a logarithmic frequency axis\n • CQT spaces bins per musical octave" +main.advanced.tooltip.tooltip_spectrum_method: "Chooses how the sample is analyzed into a spectrum:\n • FFT uses evenly spaced frequency bins\n • Log-spaced FFT regroups those bins onto a logarithmic frequency axis\n • CQT spaces bins per musical octave" main.advanced.tooltip.tooltip_transformation_gamma: "Scales spectral magnitudes before matching:\n • at 0 the raw linear spectrum is used\n • at 100 a logarithmic scaling is applied\n • and values in between blend the two" main.advanced.tooltip.tooltip_max_workers: "Set the number parallel workers for audio processing tasks." main.advanced.title.select_library_directory: "Select library directory" diff --git a/src/sampletones_core/fft/cqt/frequencies.py b/src/sampletones_core/fft/cqt/frequencies.py index a66853c96..132cc15c6 100644 --- a/src/sampletones_core/fft/cqt/frequencies.py +++ b/src/sampletones_core/fft/cqt/frequencies.py @@ -15,7 +15,7 @@ def calculate_cqt_frequencies( Calculate center frequencies for CQT bins. Bins are spaced geometrically from ``cutoff``, so bin ``k`` sits at - ``cutoff * 2 ** (k / bins_per_octave)`` — a constant ratio between neighbours that keeps the + ``cutoff * 2 ** (k / bins_per_octave)`` — a constant ratio between neighbors that keeps the quality factor constant across the spectrum. Args: diff --git a/src/sampletones_core/fft/spectrum/cqt.py b/src/sampletones_core/fft/spectrum/cqt.py index 798fc038e..e1f129439 100644 --- a/src/sampletones_core/fft/spectrum/cqt.py +++ b/src/sampletones_core/fft/spectrum/cqt.py @@ -83,7 +83,7 @@ def calculate_cqt_spectrum_columns( The signal is advanced by half a hop before transforming, so column ``i`` represents the frame centered on ``(i + 0.5) * hop_length``. This aligns the - per-frame timing with the FFT path, which analyses a window centered on each frame. + per-frame timing with the FFT path, which analyzes a window centered on each frame. Args: audio: Input audio as a numpy array. diff --git a/src/sampletones_core/formats/bitphase/model/project.py b/src/sampletones_core/formats/bitphase/model/project.py index 27f1b72a1..babd977e0 100644 --- a/src/sampletones_core/formats/bitphase/model/project.py +++ b/src/sampletones_core/formats/bitphase/model/project.py @@ -46,7 +46,7 @@ class BitphaseProject(BaseModel): ) pattern_order_colors: Dict[int, str] = Field( default_factory=dict, - description="Highlight colour per order position.", + description="Highlight color per order position.", ) instruments: Tuple[BitphaseInstrument, ...] = Field( ..., diff --git a/src/sampletones_core/formats/bitphase/model/song.py b/src/sampletones_core/formats/bitphase/model/song.py index 3c852e3f8..bd85a11b9 100644 --- a/src/sampletones_core/formats/bitphase/model/song.py +++ b/src/sampletones_core/formats/bitphase/model/song.py @@ -56,7 +56,7 @@ class BitphaseSong(BaseModel): ) a4_tuning_hz: float = Field( default=DEFAULT_A4_TUNING, - description="Concert pitch the tuning table centres on.", + description="Concert pitch the tuning table centers on.", ) virtual_channel_map: Dict[int, int] = Field( default_factory=dict, diff --git a/src/sampletones_core/generators/utils.py b/src/sampletones_core/generators/utils.py index d68c2a10c..28f827389 100644 --- a/src/sampletones_core/generators/utils.py +++ b/src/sampletones_core/generators/utils.py @@ -39,7 +39,7 @@ def get_remaining_generator_classes( """ Maps each remaining generator class to its representative channel generator. - Channels of one kind share a candidate catalogue, so one channel stands for the + Channels of one kind share a candidate catalog, so one channel stands for the kind while its candidates are scored. The lowest remaining channel of a kind is its representative, which resolves successive picks over same-kind channels to the lowest free channel deterministically. diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py index 2f5b1f7e5..82ee27363 100644 --- a/src/sampletones_player/clock/schedule.py +++ b/src/sampletones_player/clock/schedule.py @@ -33,7 +33,7 @@ class PlaySchedule(BaseModel): spread the fractional part across consecutive units so the running total tracks the exact clock. There it is audio samples per engine tick; here it is engine ticks per play call. - Initialisation leaves the stream on tick 0, and the play call at index ``play_call`` leaves it + Initialization leaves the stream on tick 0, and the play call at index ``play_call`` leaves it on tick ``ticks_at(play_call + 1)``. Attributes: @@ -50,7 +50,7 @@ def from_parameters(cls, nes_frequency: int) -> PlaySchedule: The console calls the play routine once a video frame, so the rate a stream is read at is its own rate measured against `NTSC_FRAME_RATE`. The header states that same rate as - the period it asks for, so a player honouring the field and one driving from the frame + the period it asks for, so a player honoring the field and one driving from the frame itself run the stream at the speed it was built at. Args: diff --git a/src/sampletones_player/registers/streams.py b/src/sampletones_player/registers/streams.py index 0bc94989f..89ca2ea55 100644 --- a/src/sampletones_player/registers/streams.py +++ b/src/sampletones_player/registers/streams.py @@ -55,7 +55,7 @@ def ticks(self) -> int: @property def padded(self) -> Tuple[Tuple[ChannelRegisters, ...], ...]: - """The four streams each carried to the song's full length, ready to serialise. + """The four streams each carried to the song's full length, ready to serialize. Every channel reaching the same tick count is what lets the driver read a record by multiplying the tick by the channel's record size. diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py index 5cd9970b4..be9779270 100644 --- a/src/sampletones_player/trace/trace.py +++ b/src/sampletones_player/trace/trace.py @@ -30,26 +30,26 @@ class RegisterTrace: """Every APU register write a run of the driver makes, grouped by the call that makes it. - This is the contract the assembly is written against: initialisation clears the channels, + This is the contract the assembly is written against: initialization clears the channels, enables them and sounds the song's first tick, and each play call afterwards either advances the streams and writes the tick it lands on, or leaves the console alone. The three registers that reset a running channel are written only where their value changes, which is what keeps a pulse waveform's phase running across a rest the way a rendered channel does. A channel sounds only while its length counter stands above zero, and the counter loads from a - write to the register carrying the length index once the channel is enabled. Initialisation + write to the register carrying the length index once the channel is enabled. Initialization therefore reaches those registers after :data:`APU_STATUS`: the noise channel's directly, and the three that carry a timer through the first tick's high byte. Halting every counter is what holds them there for the rest of the song. Attributes: - initialisation: The writes the init routine makes, leaving the console on the song's + initialization: The writes the init routine makes, leaving the console on the song's first tick. play_calls: The writes each play call makes, one entry per call, and an empty one for a call the streams hold their tick through. """ - initialisation: Tuple[RegisterWrite, ...] + initialization: Tuple[RegisterWrite, ...] play_calls: Tuple[Tuple[RegisterWrite, ...], ...] @staticmethod @@ -72,7 +72,7 @@ def _tick_writes( return tuple(writes) @classmethod - def _initialisation_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[RegisterWrite, ...]: + def _initialization_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[RegisterWrite, ...]: writes = [ RegisterWrite(address, SILENCED_REGISTER) for address in range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1) @@ -94,7 +94,7 @@ def from_song(cls, song: Song, play_calls: int) -> RegisterTrace: play_calls: How many play calls the run covers, at least 0. Returns: - RegisterTrace: The initialisation writes and the writes of every call in the run. + RegisterTrace: The initialization writes and the writes of every call in the run. Raises: ValueError: If ``play_calls`` is negative. @@ -103,7 +103,7 @@ def from_song(cls, song: Song, play_calls: int) -> RegisterTrace: raise ValueError(f"play_calls must be at least 0, got {play_calls}") shadows: Dict[int, int] = {} - initialisation = cls._initialisation_writes(song, shadows) + initialization = cls._initialization_writes(song, shadows) calls: List[Tuple[RegisterWrite, ...]] = [] for play_call in range(play_calls): @@ -115,6 +115,6 @@ def from_song(cls, song: Song, play_calls: int) -> RegisterTrace: calls.append(cls._tick_writes(song, tick, shadows)) return cls( - initialisation=initialisation, + initialization=initialization, play_calls=tuple(calls), ) diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index daa8d01c9..69e9ba481 100644 --- a/src/sampletones_shared/utils/color.py +++ b/src/sampletones_shared/utils/color.py @@ -13,19 +13,19 @@ def with_alpha_fraction(color: ColorRGBA, fraction: float) -> ColorRGBA: """Return ``color`` with its alpha set to ``fraction`` of full opacity. - ``fraction`` is a value in ``[0, 1]``; ``1`` keeps the colour fully opaque and + ``fraction`` is a value in ``[0, 1]``; ``1`` keeps the color fully opaque and ``0`` makes it fully transparent, letting callers express a tint strength as a - fraction while colours stay 8-bit RGBA tuples. + fraction while colors stay 8-bit RGBA tuples. """ red, green, blue, _ = color return (red, green, blue, round(fraction * MAX_CHANNEL_VALUE)) def blend(start: ColorRGBA, end: ColorRGBA, fraction: float) -> ColorRGBA: - """Linearly interpolate between two colours, channel by channel. + """Linearly interpolate between two colors, channel by channel. ``fraction`` is clamped to ``[0, 1]``: ``0`` returns ``start`` and ``1`` returns ``end``, with - every RGBA channel mixed in proportion so a scalar can drive a colour along a gradient. + every RGBA channel mixed in proportion so a scalar can drive a color along a gradient. """ ratio = clamp(fraction, 0.0, 1.0) start_channels = np.array(start, dtype=np.float64) @@ -35,9 +35,9 @@ def blend(start: ColorRGBA, end: ColorRGBA, fraction: float) -> ColorRGBA: def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA: - """Return the colour ``overlay`` makes when it is drawn over ``base``. + """Return the color ``overlay`` makes when it is drawn over ``base``. - Each colour carries its own alpha, and the result carries the coverage the two reach + Each color carries its own alpha, and the result carries the coverage the two reach together, so a pair of translucent washes bound for a single layer reads as it would if the layer held both. A fully transparent pair returns ``base``. """ diff --git a/src/sampletones_shared/utils/serialization.py b/src/sampletones_shared/utils/serialization.py index 0cfca3e5b..e127ebc0f 100644 --- a/src/sampletones_shared/utils/serialization.py +++ b/src/sampletones_shared/utils/serialization.py @@ -132,7 +132,7 @@ def load_yaml_model( model_type (Type[ModelTypeT]): Model class validating the mapping. context (Optional[Mapping[str, Any]]): Validation context forwarded to ``model_validate``, letting field validators resolve against shared state - (e.g. a palette for colour references). + (e.g. a palette for color references). Returns: ModelTypeT: The validated model instance. @@ -166,7 +166,7 @@ def load_yaml_model_dir( model_type (Type[ModelTypeT]): Model class validating the merged mapping. context (Optional[Mapping[str, Any]]): Validation context forwarded to ``model_validate``, letting field validators resolve against shared state - (e.g. a palette for colour references). + (e.g. a palette for color references). Returns: ModelTypeT: The validated model instance. diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index 12d11cbec..f1b8f7d41 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -182,7 +182,7 @@ def test_every_channel_fills_its_pattern(self, document: LoadedProject) -> None: for channel in pattern.channels ) - def test_each_channel_is_labelled_as_its_position_names_it(self, document: LoadedProject) -> None: + def test_each_channel_is_labeled_as_its_position_names_it(self, document: LoadedProject) -> None: pattern = document.songs[0].patterns[0] assert [channel.label for channel in pattern.channels] == list(CHANNEL_LABELS) diff --git a/tests/integration/nsf/console/machine.py b/tests/integration/nsf/console/machine.py index 7a5d7b69e..afa2a1c56 100644 --- a/tests/integration/nsf/console/machine.py +++ b/tests/integration/nsf/console/machine.py @@ -70,7 +70,7 @@ def _call(self, address: int, accumulator: int) -> Tuple[RegisterWrite, ...]: raise RuntimeError(f"the routine at {address:#06x} ran for {STEP_BUDGET} instructions without returning") - def initialise(self) -> Tuple[RegisterWrite, ...]: + def initialize(self) -> Tuple[RegisterWrite, ...]: """Runs the init routine, which readies the APU and sounds the song's first tick. Returns: @@ -88,7 +88,7 @@ def play(self) -> Tuple[RegisterWrite, ...]: return self._call(self._addresses.play, FIRST_SONG_INDEX) def trace(self, play_calls: int) -> RegisterTrace: - """Runs a whole session: initialisation followed by ``play_calls`` play calls. + """Runs a whole session: initialization followed by ``play_calls`` play calls. Args: play_calls: How many play calls the run covers. @@ -96,15 +96,15 @@ def trace(self, play_calls: int) -> RegisterTrace: Returns: RegisterTrace: The writes the driver made, grouped the way the model states them. """ - initialisation = self.initialise() + initialization = self.initialize() return RegisterTrace( - initialisation=initialisation, + initialization=initialization, play_calls=tuple(self.play() for _ in range(play_calls)), ) def register_file(trace: RegisterTrace) -> List[Dict[int, int]]: - """The APU as the driver leaves it after initialisation and after every call that sounds. + """The APU as the driver leaves it after initialization and after every call that sounds. A tick reaches the hardware as the values standing in the registers once its writes land, and the three registers written only on change keep the value an earlier tick left there. Reading @@ -120,7 +120,7 @@ def register_file(trace: RegisterTrace) -> List[Dict[int, int]]: registers: Dict[int, int] = {} ticks: List[Dict[int, int]] = [] - for writes in (trace.initialisation, *trace.play_calls): + for writes in (trace.initialization, *trace.play_calls): if not writes: continue diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py index ea98a6975..356116ee6 100644 --- a/tests/integration/nsf/console/session.py +++ b/tests/integration/nsf/console/session.py @@ -65,7 +65,7 @@ def captured_file_trace(data: bytes, song: Song) -> RegisterTrace: song: The song the file plays, which states how far the run reaches. Returns: - RegisterTrace: The writes of the initialisation and of every play call in the run. + RegisterTrace: The writes of the initialization and of every play call in the run. """ return captured_run(data, play_calls_covering(song)) @@ -78,7 +78,7 @@ def captured_run(data: bytes, play_calls: int) -> RegisterTrace: play_calls: How many play calls the run covers. Returns: - RegisterTrace: The writes of the initialisation and of every play call in the run. + RegisterTrace: The writes of the initialization and of every play call in the run. """ image = DriverImage.load() console = Console(data, image.addresses) @@ -93,7 +93,7 @@ def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace: information: The text the exported header carries. Returns: - RegisterTrace: The writes of the initialisation and of every play call in the run. + RegisterTrace: The writes of the initialization and of every play call in the run. """ return captured_file_trace(nsf_to_bytes(song, information, DriverImage.load()), song) @@ -111,6 +111,6 @@ def captured_trace_over( play_calls: How many play calls the run covers. Returns: - RegisterTrace: The writes of the initialisation and of every play call in the run. + RegisterTrace: The writes of the initialization and of every play call in the run. """ return captured_run(nsf_to_bytes(song, information, DriverImage.load()), play_calls) diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py index 992fee796..2f9fb7cad 100644 --- a/tests/integration/nsf/test_driver_trace.py +++ b/tests/integration/nsf/test_driver_trace.py @@ -45,12 +45,12 @@ def expected(song: Song) -> RegisterTrace: class TestTheDriverWritesWhatTheModelStates: """The assembled 6502 driver run on py65, held against `RegisterTrace.from_song`.""" - def test_initialisation_readies_the_console_the_way_the_model_states( + def test_initialization_readies_the_console_the_way_the_model_states( self, trace: RegisterTrace, expected: RegisterTrace, ) -> None: - assert trace.initialisation == expected.initialisation + assert trace.initialization == expected.initialization def test_every_play_call_writes_what_the_model_states( self, diff --git a/tests/suite/browser.py b/tests/suite/browser.py index a847ed1bf..8263f4fe2 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -218,7 +218,7 @@ def _rows_nested_under(rows: Sequence[_Row], index: int) -> Set[int]: def _assert_rows_named(rows: Sequence[_Row], labels: AbstractSet[str]) -> None: """Holds a derived view to the rows the view it varies reads, so a label naming none is stated.""" missing = labels - {row.label for row in rows} - assert not missing, f"the view holds no row labelled {sorted(missing)}" + assert not missing, f"the view holds no row labeled {sorted(missing)}" def config_fields( diff --git a/tests/suite/scenario.py b/tests/suite/scenario.py index 29e4375d7..fd99af604 100644 --- a/tests/suite/scenario.py +++ b/tests/suite/scenario.py @@ -26,7 +26,7 @@ class BaseTestScenario(Generic[ContextT]): Unlike a :class:`BaseTestCase`, which describes a single input/output pair, a scenario models a stateful sequence of operations: ``build`` produces the initial context, then :meth:`run` threads it through each step in turn. This - fits behaviours that only emerge over a sequence of mutations -- reordering a + fits behaviors that only emerge over a sequence of mutations -- reordering a collection, editing an item in place, and asserting that references survive. """ diff --git a/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index 865ae32ef..eacebedd5 100644 --- a/tests/unit/sampletones_application/config/managers/test_application.py +++ b/tests/unit/sampletones_application/config/managers/test_application.py @@ -132,7 +132,7 @@ def test_a_stored_scheme_stands_on_a_mac( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A reader who chose the Control keys keeps them on a machine labelled Command.""" + """A reader who chose the Control keys keeps them on a machine labeled Command.""" monkeypatch.setattr(platform, "system", lambda: "Darwin") path = tmp_path / "config.yaml" path.write_text(yaml.safe_dump({"shortcuts": {"scheme": DEFAULT_SCHEME_NAME}})) diff --git a/tests/unit/sampletones_application/constants/test_keybindings.py b/tests/unit/sampletones_application/constants/test_keybindings.py index 563dbb9d9..bbad7e46b 100644 --- a/tests/unit/sampletones_application/constants/test_keybindings.py +++ b/tests/unit/sampletones_application/constants/test_keybindings.py @@ -70,7 +70,7 @@ def test_a_fresh_profile_opens_on_it( test_case: TestCase, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A reader who has chosen nothing yet starts on the keys their machine is labelled with.""" + """A reader who has chosen nothing yet starts on the keys their machine is labeled with.""" monkeypatch.setattr(platform, "system", lambda: test_case.system) assert ShortcutsConfig().scheme == test_case.expected diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 39b67c9b8..3ab90588b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1200,7 +1200,7 @@ def test_closing_the_project_restores_every_channel( def channels_coordinator(monkeypatch: pytest.MonkeyPatch) -> SequencerTabCoordinator: """A coordinator joining the real channels logic to a real grid panel and a real order panel. - Each panel's colour cues reach DearPyGui, which holds no context here, so the tables are + Each panel's color cues reach DearPyGui, which holds no context here, so the tables are reported absent and a panel stops once it has recorded the mute set — which is what the wiring is read for. The menu bar above the tab is a recorder, so a test can read whether it was told. Modifiers are reported as held nowhere; a test that needs Ctrl says so. diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py index 56ee13d14..bf69dd41d 100644 --- a/tests/unit/sampletones_application/coordinators/test_keybindings.py +++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py @@ -187,9 +187,9 @@ def test_every_editable_action_reaches_a_row(self, harness: Harness) -> None: assert listed == editable def test_every_row_carries_a_label_a_reader_sees(self, harness: Harness) -> None: - unlabelled = [row.action for group in harness.window.view_model.groups for row in group.rows if not row.label] + unlabeled = [row.action for group in harness.window.view_model.groups for row in group.rows if not row.label] - assert unlabelled == [] + assert unlabeled == [] def test_every_shipped_scheme_is_offered(self, harness: Harness) -> None: assert harness.window.view_model.schemes == shipped_catalog().names diff --git a/tests/unit/sampletones_application/logic/main/test_stems.py b/tests/unit/sampletones_application/logic/main/test_stems.py index 3f2dee025..3a44902cb 100644 --- a/tests/unit/sampletones_application/logic/main/test_stems.py +++ b/tests/unit/sampletones_application/logic/main/test_stems.py @@ -76,7 +76,7 @@ def test_asking_after_a_recording_that_was_never_gathered_fails(self) -> None: class TestMovesWithinALevel: """Position among peers settles which of two equal-cost choices picks first.""" - def test_a_recording_moves_past_its_neighbour(self) -> None: + def test_a_recording_moves_past_its_neighbor(self) -> None: assert _shape(_levels(["bass", "lead"]).move_within_level(_path("lead"), -1)) == [["lead", "bass"]] def test_a_move_off_the_end_of_a_level_changes_nothing(self) -> None: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index d9c5b5572..146b98806 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -399,7 +399,7 @@ def _three_recordings( ) -> ReconstructionData: """A document over three recordings, each carrying a shape of its own. - The shapes differ rather than the levels, since loading normalises each recording and + The shapes differ rather than the levels, since loading normalizes each recording and would read three levels of one shape as the same waveform. """ sample_rate = Config().library.sample_rate diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py index c9b4da58e..551d4e73b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py @@ -117,7 +117,7 @@ class TestCase(BaseRegularTestCase): ), ), TestCase( - label="a mixed cell leaves its target as it stands while its neighbours take theirs", + label="a mixed cell leaves its target as it stands while its neighbors take theirs", order=( "01 02 03", SILENT, diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index edfe24278..b459a6ef5 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -382,7 +382,7 @@ def test_value_wraps_a_number(self) -> None: class TestWhichKindADetailNames: - """A line about the pool reads in the colour of the kind of voice it is about.""" + """A line about the pool reads in the color of the kind of voice it is about.""" def test_a_written_voice_is_added_under_its_own_kind(self) -> None: formatter = _formatter(_controller()) @@ -464,7 +464,7 @@ def test_edit_reconstruction_names_position_channel_and_feature(self) -> None: (FeatureKey.DUTY_CYCLE, "d", HistoryDetailRole.FEATURE_DUTY_CYCLE), ], ) - def test_every_feature_has_a_letter_and_a_colour_role( + def test_every_feature_has_a_letter_and_a_color_role( self, feature_key: FeatureKey, letter: str, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 0b9f3e675..a876576a2 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -350,7 +350,7 @@ def test_none_sample_clears_the_whole_row(self) -> None: class TestSampleSubcolumn: - def test_synchronises_across_relevant_channels_even_without_instrument( + def test_synchronizes_across_relevant_channels_even_without_instrument( self, ) -> None: controller = _controller() @@ -379,7 +379,7 @@ def test_synchronises_across_relevant_channels_even_without_instrument( assert row.transpose is None assert row.volume is None - def test_synchronises_across_all_channels_when_no_sample_is_referenced( + def test_synchronizes_across_all_channels_when_no_sample_is_referenced( self, ) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py index 2cdaf0a30..917c4666b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -158,7 +158,7 @@ class TestCase(BaseRegularTestCase): ), ), TestCase( - label="a mixed cell leaves its target as it stands while its neighbours clear theirs", + label="a mixed cell leaves its target as it stands while its neighbors clear theirs", frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",), block=(".. ? .",), first_subcolumn=SubColumn.VOICE, diff --git a/tests/unit/sampletones_application/services/render/conftest.py b/tests/unit/sampletones_application/services/render/conftest.py index a67b0d581..bc1edf8f3 100644 --- a/tests/unit/sampletones_application/services/render/conftest.py +++ b/tests/unit/sampletones_application/services/render/conftest.py @@ -21,7 +21,7 @@ def wave_spec(sample_rate: int = SAMPLE_RATE) -> AudioOutputSpec: class FakeSynthesizer: """A kernel that renders a fixed number of identical rows, standing in for a song. - Each row is a constant level, so a normalising pass has a peak to find and a written file + Each row is a constant level, so a normalizing pass has a peak to find and a written file can be checked sample by sample without modelling a generator. """ diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py index 2a1ce82c3..cb509bbd6 100644 --- a/tests/unit/sampletones_application/services/render/test_service.py +++ b/tests/unit/sampletones_application/services/render/test_service.py @@ -116,7 +116,7 @@ def test_the_encoding_pass_climbs_to_the_total(self, tmp_path: Path) -> None: class TestNormalizing(BaseTestSuite): - """Normalising scales the whole render by what its loudest sample turned out to be.""" + """Normalizing scales the whole render by what its loudest sample turned out to be.""" def test_the_peak_reaches_full_scale(self, tmp_path: Path) -> None: destination = tmp_path / "song.wav" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 3dbcbcbb3..deed263c4 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -117,14 +117,14 @@ def dpg_context(self) -> Generator[Any, Application, Any]: SingleThreadExecutor.reset_shutdown() dpg.destroy_context() - def test_initialises_without_error(self, tmp_path: Path) -> None: + def test_initializes_without_error(self, tmp_path: Path) -> None: with ExitStack() as stack: for display_patch in _display_patches(): stack.enter_context(display_patch) Application(profile=_profile(tmp_path)) - def test_initialises_where_nothing_can_play(self, tmp_path: Path) -> None: + def test_initializes_where_nothing_can_play(self, tmp_path: Path) -> None: """Editing a song, exporting a module and rendering to a file need no output device. The rate the audio is rendered at is the consumer's to state, so a machine offering no @@ -537,7 +537,7 @@ def test_the_order_explanation_leaves_with_the_control_it_belongs_to(self, app: converter_logic.set_stems_mode(False) assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is False - def test_a_recording_holding_no_channel_greys_out_but_stays_listed( + def test_a_recording_holding_no_channel_grays_out_but_stays_listed( self, app: Application, tmp_path: Path, diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index ce2de5541..06cbe9ad2 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -149,7 +149,7 @@ def test_series_color_is_untouched_when_not_dimmed(self) -> None: assert graph._series_color(layer, graph._series_shade(layer)) == layer.color - def test_series_color_greys_the_reconstruction_when_dimmed(self) -> None: + def test_series_color_grays_the_reconstruction_when_dimmed(self) -> None: graph = _graph() _with_layout(graph, opacity=0.4) graph._reconstruction_dimmed = True diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py index 9f1f0fb17..9732e0e81 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py @@ -127,7 +127,7 @@ def test_stays_between_the_base_and_the_cap( class TestExpandedSideWidth(BaseTestSuite): """``expanded_side_width`` holds a fixed side column at its configured width up to the design - baseline, then grants it one share of the wider viewport's surplus against the stretching centre + baseline, then grants it one share of the wider viewport's surplus against the stretching center column's ``center_weight`` shares.""" @dataclass(frozen=True, kw_only=True) @@ -168,7 +168,7 @@ class SideWidthCase(BaseRegularTestCase): expected=400, ), SideWidthCase( - label="two_sides_split_after_centre", + label="two_sides_split_after_center", base_width=300, viewport_width=1600, baseline_viewport_width=1280, @@ -177,7 +177,7 @@ class SideWidthCase(BaseRegularTestCase): expected=380, ), SideWidthCase( - label="heavier_centre_narrows_sides", + label="heavier_center_narrows_sides", base_width=300, viewport_width=1600, baseline_viewport_width=1280, diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 944fb2192..5b41f9e28 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -166,7 +166,7 @@ def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_ assert dpg.get_value(channel_tag(bass, ChannelName.PULSE1)) assert not dpg.get_value(channel_tag(bass, ChannelName.TRIANGLE)) - def test_a_row_holding_no_channel_greys_out_and_still_answers( + def test_a_row_holding_no_channel_grays_out_and_still_answers( self, dpg_context: None, layout_config, @@ -388,7 +388,7 @@ def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( dpg_context: None, layout_config, ) -> None: - """Greying a row is drawn onto the widgets it stands as, so the pointer keeps its box.""" + """Graying a row is drawn onto the widgets it stands as, so the pointer keeps its box.""" stems_list = build(layout_config) bass = row("bass") stems_list.update_view(view(bass)) @@ -409,7 +409,7 @@ def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: Non assert dpg.does_item_exist(channel_tag(bass, ChannelName.PULSE1)) assert not dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) - def test_a_recording_missing_from_disk_greys_out(self, dpg_context: None, layout_config) -> None: + def test_a_recording_missing_from_disk_grays_out(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) bass = row("bass", available=False) @@ -508,7 +508,7 @@ def test_a_muted_box_keeps_its_value_and_stays_clickable(self, dpg_context: None assert dpg.get_value(channel_tag(bass, channel_name)) assert dpg.is_item_enabled(channel_tag(bass, channel_name)) - def test_a_channel_switched_back_on_takes_its_own_colour_again(self, dpg_context: None, layout_config) -> None: + def test_a_channel_switched_back_on_takes_its_own_color_again(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) bass = row("bass") stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.TRIANGLE}))) diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py index 8cbd7c507..2d3cf79cd 100644 --- a/tests/unit/sampletones_application/ui/elements/test_window.py +++ b/tests/unit/sampletones_application/ui/elements/test_window.py @@ -134,7 +134,7 @@ def test_opening_waits_on_no_frame(self, dpg_context: None) -> None: split_frame.assert_not_called() - def test_opening_centres_the_window_once_it_has_been_measured(self, dpg_context: None) -> None: + def test_opening_centers_the_window_once_it_has_been_measured(self, dpg_context: None) -> None: window = ProbeWindow(on_close=None) with ( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 8f2fd33f9..29d184b1e 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -633,7 +633,7 @@ def test_a_query_typed_earlier_survives_a_change_of_mode( class TestStarColor: - """The star beside the label reads in the colour of the mode it stands for.""" + """The star beside the label reads in the color of the mode it stands for.""" def test_the_star_reads_favorite_while_the_mode_is_on(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel(corpus, set(), favorites_only=True) @@ -648,7 +648,7 @@ def test_the_star_is_colored_with_the_token_the_mode_names( corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The colour reaches the star as a token, so the star follows a palette swapped in place.""" + """The color reaches the star as a token, so the star follows a palette swapped in place.""" panel = build_browser_panel(corpus, set(), favorites_only=True) panel._favorites_glyph_tag = GLYPH_TAG colored: List[Tuple[str, BaseColor]] = [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py index c6e2b2f30..62630e5b5 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -104,7 +104,7 @@ def test_a_bar_stands_where_the_stage_has_reached(self, window: GUIExportWindow) render(window, travelling=True, progress=HALFWAY) assert dpg.get_value(TAG_SETTINGS_EXPORT_PROGRESS) == pytest.approx(HALFWAY) - def test_a_bar_is_labelled_with_the_share_it_has_covered(self, window: GUIExportWindow) -> None: + def test_a_bar_is_labeled_with_the_share_it_has_covered(self, window: GUIExportWindow) -> None: render(window, travelling=True, progress=HALFWAY) assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_PROGRESS)["overlay"] == "50%" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py index e397871b8..8e1666883 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py @@ -23,7 +23,7 @@ def layout_config() -> LayoutConfig: def panel(layout_config: LayoutConfig) -> GUISequencerHistoryPanel: """Builds a panel without its DearPyGui-dependent constructor. - A role's colour is read from the layout alone, so a running GUI context is unnecessary here. + A role's color is read from the layout alone, so a running GUI context is unnecessary here. """ instance = GUISequencerHistoryPanel.__new__(GUISequencerHistoryPanel) instance._layout = layout_config.tabs.sequencer @@ -31,10 +31,10 @@ def panel(layout_config: LayoutConfig) -> GUISequencerHistoryPanel: return instance -class TestWhatColourAVoiceRoleWears: - """A history line names its voice in the colour of the kind that voice is.""" +class TestWhatColorAVoiceRoleWears: + """A history line names its voice in the color of the kind that voice is.""" - def test_a_sample_wears_the_sample_colour( + def test_a_sample_wears_the_sample_color( self, panel: GUISequencerHistoryPanel, layout_config: LayoutConfig, @@ -43,7 +43,7 @@ def test_a_sample_wears_the_sample_colour( assert panel._role_color(HistoryDetailRole.SAMPLE) is text.sample - def test_an_instrument_wears_the_instrument_colour( + def test_an_instrument_wears_the_instrument_color( self, panel: GUISequencerHistoryPanel, layout_config: LayoutConfig, @@ -52,7 +52,7 @@ def test_an_instrument_wears_the_instrument_colour( assert panel._role_color(HistoryDetailRole.INSTRUMENT) is text.instrument - def test_a_voice_of_no_stated_kind_wears_the_slot_colour( + def test_a_voice_of_no_stated_kind_wears_the_slot_color( self, panel: GUISequencerHistoryPanel, layout_config: LayoutConfig, @@ -68,7 +68,7 @@ def test_the_two_kinds_are_told_apart(self, panel: GUISequencerHistoryPanel) -> assert sample.rgba != instrument.rgba - def test_every_role_answers_with_a_colour(self, panel: GUISequencerHistoryPanel) -> None: + def test_every_role_answers_with_a_color(self, panel: GUISequencerHistoryPanel) -> None: """The panel paints whatever the logic tags, so each role states what it wears.""" for role in HistoryDetailRole: assert panel._role_color(role) is not None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py index 73456851c..b0e52c3ee 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py @@ -134,7 +134,7 @@ def click(self, label: str) -> None: def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerOrderPanel: """Builds a panel around the state the channel cues read, with no DearPyGui context. - The cues touch the layout colours, the theme ids, the row labels, and the entry registry, so + The cues touch the layout colors, the theme ids, the row labels, and the entry registry, so those are wired directly and the rest of the panel is left out. The switch behind the label is built the way the panel builds it, from the real language file, so the item labels under test are the ones a user reads. @@ -307,7 +307,7 @@ def test_the_master_row_carries_no_channel_wash(self, recorder: _DearPyGuiRecord assert set(recorder.row_tints) == set(CHANNEL_TABLE_ROWS.values()) def test_the_wash_matches_the_shade_the_tracker_column_takes(self, recorder: _DearPyGuiRecorder) -> None: - """Both tables read the same colour, so a silenced channel looks the same in each.""" + """Both tables read the same color, so a silenced channel looks the same in each.""" panel = _panel(frozenset({ChannelName.NOISE})) assert panel._channel_row_tint(ChannelName.NOISE) == MUTED_BACKGROUND diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py index 19ccb6ab3..648ef13ba 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py @@ -125,8 +125,8 @@ def _row( return SequencerRowViewModel(index=index, cells=cells, sample_channels=sample_channels) -class TestWhatColourAVoiceSlotWears: - """The slot takes the colour of the kind standing in it, so the grid reports what it holds.""" +class TestWhatColorAVoiceSlotWears: + """The slot takes the color of the kind standing in it, so the grid reports what it holds.""" def test_a_slot_naming_nothing_takes_the_neutral_shade(self) -> None: panel = _panel() @@ -140,7 +140,7 @@ def test_a_slot_naming_nothing_takes_the_neutral_shade(self) -> None: [VoiceKind.SAMPLE, VoiceKind.INSTRUMENT], ids=lambda kind: kind.value, ) - def test_a_slot_naming_a_voice_takes_that_kinds_colour(self, kind: VoiceKind) -> None: + def test_a_slot_naming_a_voice_takes_that_kinds_color(self, kind: VoiceKind) -> None: key = (0, ChannelName.PULSE1, SubColumn.VOICE) panel = _panel(cell_kinds={key: kind}) @@ -157,7 +157,7 @@ def test_the_sample_column_takes_the_kind_its_own_slot_names(self) -> None: [SubColumn.TRANSPOSE, SubColumn.VOLUME], ids=lambda subcolumn: subcolumn.value, ) - def test_the_other_slots_keep_their_own_colour(self, subcolumn: SubColumn) -> None: + def test_the_other_slots_keep_their_own_color(self, subcolumn: SubColumn) -> None: """A pitch and a volume mean the same whatever voice sounds them.""" panel = _panel(cell_kinds={(0, ChannelName.PULSE1, SubColumn.VOICE): VoiceKind.INSTRUMENT}) @@ -260,9 +260,9 @@ def test_a_voice_taken_out_returns_its_cell_to_the_neutral_shade(self, bound: Di class TestWhatAnEditShowsAtOnce: - """The number and the colour are written together, so a typed voice reads whole in one frame.""" + """The number and the color are written together, so a typed voice reads whole in one frame.""" - def test_a_placed_voice_takes_its_colour_with_its_number(self, bound: Dict[Sender, int]) -> None: + def test_a_placed_voice_takes_its_color_with_its_number(self, bound: Dict[Sender, int]) -> None: panel = _panel() key = (0, ChannelName.PULSE1, SubColumn.VOICE) @@ -271,7 +271,7 @@ def test_a_placed_voice_takes_its_colour_with_its_number(self, bound: Dict[Sende assert panel._editable_cells.values[key] == display_id(3) assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] - def test_a_cleared_slot_drops_its_number_and_its_colour(self, bound: Dict[Sender, int]) -> None: + def test_a_cleared_slot_drops_its_number_and_its_color(self, bound: Dict[Sender, int]) -> None: key = (0, ChannelName.PULSE1, SubColumn.VOICE) panel = _panel(cell_kinds={key: VoiceKind.SAMPLE}) panel._editable_cells.values[key] = display_id(3) @@ -298,7 +298,7 @@ def _record(color: BaseColor, *_arguments: Any) -> int: panel._create_subcolumn_themes() return panel, colors - def test_the_voice_slot_is_built_in_a_colour_for_each_kind(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_the_voice_slot_is_built_in_a_color_for_each_kind(self, monkeypatch: pytest.MonkeyPatch) -> None: panel, _ = self._built(monkeypatch) voice_themes = {theme_key for theme_key in panel._subcolumn_themes if theme_key[0] is SubColumn.VOICE} @@ -309,7 +309,7 @@ def test_the_voice_slot_is_built_in_a_colour_for_each_kind(self, monkeypatch: py (SubColumn.VOICE, VoiceKind.INSTRUMENT), } - def test_each_kind_is_built_in_its_own_colour(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_each_kind_is_built_in_its_own_color(self, monkeypatch: pytest.MonkeyPatch) -> None: panel, colors = self._built(monkeypatch) sample = colors[panel._subcolumn_themes[(SubColumn.VOICE, VoiceKind.SAMPLE)] - 1] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index 546d517d6..0f277e5bf 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -90,7 +90,7 @@ def _cell_widget(channel: ChannelName, row_index: int, subcolumn: SubColumn) -> def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: """Builds a panel around the state the channel cues read, with no DearPyGui context. - The cues touch the layout colours, the theme ids, the header widgets, and the cell + The cues touch the layout colors, the theme ids, the header widgets, and the cell registry, so those are wired directly and the rest of the panel is left out. """ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) @@ -376,7 +376,7 @@ def test_no_channel_reads_as_muted_before_a_model_arrives(self) -> None: assert not any(panel._is_muted(channel) for channel in ChannelName.items()) -class TestChannelTintColour: +class TestChannelTintColor: @pytest.mark.parametrize( "channel, expected", [ @@ -387,7 +387,7 @@ class TestChannelTintColour: ], ids=lambda value: value.value if isinstance(value, ChannelName) else "", ) - def test_audible_tint_is_the_identity_colour_at_the_configured_fraction( + def test_audible_tint_is_the_identity_color_at_the_configured_fraction( self, channel: ChannelName, expected: Tuple[int, int, int, int], diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py index 428dd4fe1..d21dadd59 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -69,7 +69,7 @@ def shown(self, channel: Optional[ChannelName]) -> str: return self.panel._editable_cells.values.get((0, channel, SubColumn.VOICE), STORED_LABEL) def kind(self, channel: Optional[ChannelName]) -> Optional[VoiceKind]: - """The kind the cell cache holds, which is the colour the slot takes with its number.""" + """The kind the cell cache holds, which is the color the slot takes with its number.""" return self.panel._cell_kinds.get((0, channel, SubColumn.VOICE)) @@ -128,7 +128,7 @@ def test_typing_into_an_empty_pool_names_no_voice(self, panel: Panel) -> None: assert panel.shown(ChannelName.PULSE1) == STORED_LABEL -class TestWhatColourATypedVoiceTakes: +class TestWhatColorATypedVoiceTakes: """The cell takes the kind with the number, so a typed voice reads whole before the project answers.""" def test_a_typed_sample_takes_the_sample_kind(self, panel: Panel) -> None: @@ -142,7 +142,7 @@ def test_a_typed_instrument_takes_the_instrument_kind(self, panel: Panel) -> Non assert panel.kind(ChannelName.NOISE) is VoiceKind.INSTRUMENT def test_a_refused_voice_leaves_the_cell_its_own_kind(self, panel: Panel) -> None: - """Nothing is written, so the slot keeps the colour it already wore.""" + """Nothing is written, so the slot keeps the color it already wore.""" panel.type_voice(INSTRUMENT_INDEX, None) assert panel.kind(None) is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py index 6275210ea..d7f26fd83 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py @@ -28,26 +28,26 @@ def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout: def _panel(sequencer_layout: SequencerLayout) -> GUISequencerVoicesPanel: """Builds a panel without its DearPyGui-dependent constructor. - The kind's colour is read from the layout alone, so a running GUI context is unnecessary here. + The kind's color is read from the layout alone, so a running GUI context is unnecessary here. """ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) panel._layout = sequencer_layout return panel -class TestWhatColourAKindWears: - def test_a_sample_wears_the_sample_colour(self, sequencer_layout: SequencerLayout) -> None: +class TestWhatColorAKindWears: + def test_a_sample_wears_the_sample_color(self, sequencer_layout: SequencerLayout) -> None: panel = _panel(sequencer_layout) assert panel._kind_color(VoiceKind.SAMPLE) is sequencer_layout.colors.text.sample - def test_an_instrument_wears_the_instrument_colour(self, sequencer_layout: SequencerLayout) -> None: + def test_an_instrument_wears_the_instrument_color(self, sequencer_layout: SequencerLayout) -> None: panel = _panel(sequencer_layout) assert panel._kind_color(VoiceKind.INSTRUMENT) is sequencer_layout.colors.text.instrument def test_the_two_kinds_are_told_apart(self, sequencer_layout: SequencerLayout) -> None: - """The colour carries the kind, so a list of one hue would say nothing the glyph does not.""" + """The color carries the kind, so a list of one hue would say nothing the glyph does not.""" panel = _panel(sequencer_layout) assert panel._kind_color(VoiceKind.SAMPLE).rgba != panel._kind_color(VoiceKind.INSTRUMENT).rgba diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index 1d62197d8..4d29db63e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -71,7 +71,7 @@ def _unreachable() -> None: - """Stands where a greyed-out item would carry a callback, which a reader never fires.""" + """Stands where a grayed-out item would carry a callback, which a reader never fires.""" @dataclass @@ -346,7 +346,7 @@ def test_the_items_act_on_the_voice_they_were_raised_on( ] assert fixture.requests.exported == [(SELECTED_ID, ChannelName.PULSE1)] - def test_a_move_with_nowhere_to_go_is_greyed_out( + def test_a_move_with_nowhere_to_go_is_grayed_out( self, monkeypatch: pytest.MonkeyPatch, recorder: _MenuRecorder, diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 5da7968d6..46032aaec 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -523,7 +523,7 @@ class TestEditActionsSection: """The Edit menu carries the actions of the grid holding the cursor, and names them itself while no grid holds one.""" - def test_the_clipboard_actions_are_named_greyed_out_with_no_grid_focused( + def test_the_clipboard_actions_are_named_grayed_out_with_no_grid_focused( self, framework: _DearPyGuiRecorder, ) -> None: diff --git a/tests/unit/sampletones_application/ui/themes/test_inline.py b/tests/unit/sampletones_application/ui/themes/test_inline.py index 843cf25e6..e68a96093 100644 --- a/tests/unit/sampletones_application/ui/themes/test_inline.py +++ b/tests/unit/sampletones_application/ui/themes/test_inline.py @@ -34,7 +34,7 @@ def _item_types(theme: int) -> Set[int]: def _colors(theme: int, *, enabled_state: bool) -> Dict[int, ColorRGBA]: - """The colours one component carries, keyed by the DearPyGui colour they target.""" + """The colors one component carries, keyed by the DearPyGui color they target.""" component = _components(theme)[enabled_state] return { dpg.get_item_configuration(entry)["target"]: tuple(int(value) for value in dpg.get_value(entry)) @@ -52,14 +52,14 @@ def context() -> Generator[None, None, None]: class TestSelectableTextTheme: - def test_the_text_colour_is_the_theme_s_whole_claim(self, context: None) -> None: + def test_the_text_color_is_the_theme_s_whole_claim(self, context: None) -> None: """A cell keeps the hover and selection shades of the table it sits in.""" theme = create_selectable_text_theme(TEXT_COLOR) assert _colors(theme, enabled_state=True) == {dpg.mvThemeCol_Text: TEXT_RGBA} @pytest.mark.parametrize("enabled_state", ENABLED_STATES, ids=["enabled", "disabled"]) - def test_both_enabled_states_carry_the_colour(self, context: None, enabled_state: bool) -> None: + def test_both_enabled_states_carry_the_color(self, context: None, enabled_state: bool) -> None: theme = create_selectable_text_theme(TEXT_COLOR) assert _colors(theme, enabled_state=enabled_state)[dpg.mvThemeCol_Text] == TEXT_RGBA diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py index 7e5418b31..205a3c464 100644 --- a/tests/unit/sampletones_application/ui/themes/test_loader.py +++ b/tests/unit/sampletones_application/ui/themes/test_loader.py @@ -69,7 +69,7 @@ def test_an_explicit_parent_is_respected(self) -> None: class TestLoadedInheritance: """The real theme set: every theme resolves to the base plus its own overrides, - so a bound item theme keeps the base's colours instead of dropping to DearPyGui + so a bound item theme keeps the base's colors instead of dropping to DearPyGui defaults for anything it omits. """ @@ -118,7 +118,7 @@ def test_the_danger_button_states_its_own_disabled_look(self, themes: Dict[str, Every theme is completed for both states, so one stating only the tone it wears while it can be pressed would wear that same tone once it is held back and read as a button that simply - does nothing. The danger button states the greyed look itself, which is what the stems list + does nothing. The danger button states the grayed look itself, which is what the stems list relies on to show that its last row stays. """ dpg.create_context() @@ -157,7 +157,7 @@ class TestComponentOrder: """A theme lays its ground before it paints on it. DearPyGui fills an item from a theme's components in the order they were created, so the last - one covering a colour is the one the item wears. A component naming every item type is the + one covering a color is the one the item wears. A component naming every item type is the ground a theme stands on; one naming a single type states what that type is meant to look like, and it only reaches the item if it comes after the ground. """ diff --git a/tests/unit/sampletones_application/ui/themes/test_theme.py b/tests/unit/sampletones_application/ui/themes/test_theme.py index 396ebe551..5c35b2741 100644 --- a/tests/unit/sampletones_application/ui/themes/test_theme.py +++ b/tests/unit/sampletones_application/ui/themes/test_theme.py @@ -45,14 +45,14 @@ class _Styled(NamedTuple): - """A created theme and the source whose palette its colours read.""" + """A created theme and the source whose palette its colors read.""" theme: Theme source: PaletteSource def _live_colors(theme: Theme) -> Dict[int, ColorRGBA]: - """The colours DearPyGui holds for the theme's enabled ``All`` component, keyed by target.""" + """The colors DearPyGui holds for the theme's enabled ``All`` component, keyed by target.""" component = dpg.get_item_children(theme.tag, slot=1)[0] return { dpg.get_item_configuration(entry)["target"]: tuple(int(channel) for channel in dpg.get_value(entry)) @@ -91,14 +91,14 @@ def test_the_theme_is_built_once_for_its_tag(self, styled: _Styled) -> None: assert dpg.get_item_children(styled.theme.tag, slot=1) == components - def test_a_referenced_colour_reaches_dearpygui_resolved(self, styled: _Styled) -> None: + def test_a_referenced_color_reaches_dearpygui_resolved(self, styled: _Styled) -> None: assert _live_colors(styled.theme)[dpg.mvThemeCol_Text] == STUDIO_TEXT class TestRestyle: - """A palette swap reaches themed widgets by rewriting the colour items already created.""" + """A palette swap reaches themed widgets by rewriting the color items already created.""" - def test_a_referenced_colour_takes_the_newly_activated_palette( + def test_a_referenced_color_takes_the_newly_activated_palette( self, styled: _Styled, light: Palette, @@ -108,13 +108,13 @@ def test_a_referenced_colour_takes_the_newly_activated_palette( assert _live_colors(styled.theme)[dpg.mvThemeCol_Text] == LIGHT_TEXT - def test_a_literal_colour_stays_as_written(self, styled: _Styled, light: Palette) -> None: + def test_a_literal_color_stays_as_written(self, styled: _Styled, light: Palette) -> None: styled.source.activate(light) PaletteBindings.apply() assert _live_colors(styled.theme)[dpg.mvThemeCol_WindowBg] == LITERAL_BACKGROUND - def test_the_reported_colour_follows_the_palette_before_any_restyle( + def test_the_reported_color_follows_the_palette_before_any_restyle( self, styled: _Styled, light: Palette, diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py index c1ebf3e64..71243a974 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py @@ -241,7 +241,7 @@ class TestCase(BaseRegularTestCase): test_cases, ids=lambda test_case: test_case.label, ) - def test_a_written_key_seats_between_the_keys_it_neighbours(self, test_case: TestCase) -> None: + def test_a_written_key_seats_between_the_keys_it_neighbors(self, test_case: TestCase) -> None: assert test_case.key == test_case.preceding + 1 assert test_case.following == test_case.key + 1 diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py index 96a617850..e1fc84962 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py @@ -190,7 +190,7 @@ def test_the_super_key_leads_the_combination_it_is_part_of( class TestSuperName(BaseTestSuite): - """One key wears three names, so a combination reads the way the keyboard is labelled.""" + """One key wears three names, so a combination reads the way the keyboard is labeled.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py index 92e9d4112..158dfb161 100644 --- a/tests/unit/sampletones_application/utils/gui/test_palette.py +++ b/tests/unit/sampletones_application/utils/gui/test_palette.py @@ -48,7 +48,7 @@ def context() -> Generator[None, None, None]: def _text_color(item: Sender) -> ColorRGBA: - """The item's colour as eight-bit channels, which DearPyGui reports as fractions.""" + """The item's color as eight-bit channels, which DearPyGui reports as fractions.""" configuration: Dict[str, object] = dpg.get_item_configuration(item) color = configuration["color"] assert isinstance(color, (list, tuple)) @@ -62,7 +62,7 @@ def _add_text() -> Sender: class TestArgumentBinding: - def test_the_colour_reaches_the_item_as_it_is_bound( + def test_the_color_reaches_the_item_as_it_is_bound( self, context: None, accent: BaseColor, @@ -88,7 +88,7 @@ def test_the_item_takes_the_newly_activated_palette( assert _text_color(item) == LIGHT_ACCENT - def test_a_literal_colour_stays_as_written( + def test_a_literal_color_stays_as_written( self, context: None, source: PaletteSource, @@ -102,7 +102,7 @@ def test_a_literal_colour_stays_as_written( assert _text_color(item) == LITERAL - def test_recolouring_one_argument_leaves_one_entry( + def test_recoloring_one_argument_leaves_one_entry( self, context: None, accent: BaseColor, @@ -130,7 +130,7 @@ def test_a_deleted_item_is_dropped( class TestThemeColorBinding: - def test_the_theme_colour_takes_the_newly_activated_palette( + def test_the_theme_color_takes_the_newly_activated_palette( self, context: None, source: PaletteSource, @@ -146,7 +146,7 @@ def test_the_theme_colour_takes_the_newly_activated_palette( assert tuple(int(channel) for channel in dpg.get_value(item)) == LIGHT_ACCENT - def test_a_derived_colour_follows_the_colour_it_came_from( + def test_a_derived_color_follows_the_color_it_came_from( self, context: None, source: PaletteSource, diff --git a/tests/unit/sampletones_application/utils/palette/test_catalog.py b/tests/unit/sampletones_application/utils/palette/test_catalog.py index b9f0f090d..4df0262a8 100644 --- a/tests/unit/sampletones_application/utils/palette/test_catalog.py +++ b/tests/unit/sampletones_application/utils/palette/test_catalog.py @@ -79,12 +79,12 @@ def test_every_palette_declares_the_same_tokens(self, catalog: PaletteCatalog) - assert set(palette.colors) == expected, f"Palette {name!r} token set differs from {DEFAULT_PALETTE_NAME!r}" def test_every_palette_tells_the_two_voice_kinds_apart(self, catalog: PaletteCatalog) -> None: - """A recording and a hand-written voice wear their colours in the tracker and the list alike. + """A recording and a hand-written voice wear their colors in the tracker and the list alike. - Each palette states the pair for itself, so a colour that separates on one ground can go + Each palette states the pair for itself, so a color that separates on one ground can go dark and saturated on another and the kinds stay apart in all of them. """ for name, palette in catalog.palettes.items(): assert ( palette.colors["voice_sample"] != palette.colors["voice_instrument"] - ), f"Palette {name!r} gives both voice kinds one colour" + ), f"Palette {name!r} gives both voice kinds one color" diff --git a/tests/unit/sampletones_application/utils/palette/test_colors.py b/tests/unit/sampletones_application/utils/palette/test_colors.py index f90f20cf1..fc5784f2f 100644 --- a/tests/unit/sampletones_application/utils/palette/test_colors.py +++ b/tests/unit/sampletones_application/utils/palette/test_colors.py @@ -23,7 +23,7 @@ def accent(source: PaletteSource) -> BaseColor: class TestComposedColor: - """Fading, desaturating and mixing each answer with a colour that still reads the palette.""" + """Fading, desaturating and mixing each answer with a color that still reads the palette.""" def test_fading_keeps_the_hue_and_sets_the_opacity(self, accent: BaseColor) -> None: assert FadedColor(color=accent, fraction=0.5).rgba == (169, 127, 227, 128) @@ -41,7 +41,7 @@ def test_mixing_lands_between_the_two_ends(self) -> None: 255, ) - def test_a_composed_colour_answers_with_the_newly_activated_palette( + def test_a_composed_color_answers_with_the_newly_activated_palette( self, source: PaletteSource, light: Palette, @@ -69,11 +69,11 @@ def test_compositions_nest( gray = round(0.299 * 107 + 0.587 * 63 + 0.114 * 176) assert dimmed.rgba == (gray, gray, gray, 64) - def test_the_same_composition_of_the_same_colour_is_one_value( + def test_the_same_composition_of_the_same_color_is_one_value( self, accent: BaseColor, ) -> None: - """A theme cache keyed by colour holds one entry per shade the application draws.""" + """A theme cache keyed by color holds one entry per shade the application draws.""" assert { FadedColor(color=accent, fraction=0.5), FadedColor(color=accent, fraction=0.5), @@ -83,7 +83,7 @@ def test_the_same_composition_of_the_same_colour_is_one_value( FadedColor(color=accent, fraction=0.25), } - def test_the_same_composition_of_two_colours_stays_two_values( + def test_the_same_composition_of_two_colors_stays_two_values( self, accent: BaseColor, ) -> None: diff --git a/tests/unit/sampletones_application/utils/palette/test_palette.py b/tests/unit/sampletones_application/utils/palette/test_palette.py index eb06d836a..96b7f64e9 100644 --- a/tests/unit/sampletones_application/utils/palette/test_palette.py +++ b/tests/unit/sampletones_application/utils/palette/test_palette.py @@ -19,7 +19,7 @@ def palette() -> Palette: class TestPaletteResolution: - def test_a_reference_resolves_to_its_token_colour(self, palette: Palette) -> None: + def test_a_reference_resolves_to_its_token_color(self, palette: Palette) -> None: assert palette.resolve(PaletteReference(token="accent")) == (169, 127, 227, 255) def test_an_alpha_override_replaces_only_the_alpha_channel(self, palette: Palette) -> None: diff --git a/tests/unit/sampletones_application/utils/palette/test_written.py b/tests/unit/sampletones_application/utils/palette/test_written.py index 4bd1981df..ec0926856 100644 --- a/tests/unit/sampletones_application/utils/palette/test_written.py +++ b/tests/unit/sampletones_application/utils/palette/test_written.py @@ -35,8 +35,8 @@ def test_a_reference_resolves_against_the_source_palette(self, source: PaletteSo def test_a_reference_alpha_override_is_applied(self, source: PaletteSource) -> None: assert _swatch(".accent/0.5", source).color.rgba == (169, 127, 227, 128) - def test_a_colour_built_in_code_stands_as_it_is(self, source: PaletteSource) -> None: - """A derived shade reaches a field as the colour it already is.""" + def test_a_color_built_in_code_stands_as_it_is(self, source: PaletteSource) -> None: + """A derived shade reaches a field as the color it already is.""" color: BaseColor = FadedColor( color=LiteralColor((240, 146, 86, 255)), fraction=0.5, diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index b74daa833..453a2667c 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -204,7 +204,7 @@ def test_a_row_holding_no_channel_offers_nothing_to_convert(self) -> None: class TestRowStanding: - """A row states where it stands, so the moves it offers grey themselves out from the row alone.""" + """A row states where it stands, so the moves it offers gray themselves out from the row alone.""" def test_the_only_row_of_the_only_level_can_go_nowhere(self) -> None: row = _row("bass") diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index 595924e32..59a232b55 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -225,7 +225,7 @@ def test_sample_column_aggregates_over_the_channels_it_spans( class TestWhichKindTheSampleColumnNames: - """The slot's kind is what colours it, so it states one only where its channels agree.""" + """The slot's kind is what colors it, so it states one only where its channels agree.""" @staticmethod def _row( diff --git a/tests/unit/sampletones_application/view_model/shared/test_export.py b/tests/unit/sampletones_application/view_model/shared/test_export.py index d803a5950..76a0618ce 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_export.py +++ b/tests/unit/sampletones_application/view_model/shared/test_export.py @@ -75,7 +75,7 @@ def test_a_travelling_stage_hides_the_turning_symbol(self) -> None: def test_a_stage_without_an_end_shows_the_turning_symbol(self) -> None: assert view_model(travelling=False, figure=SIZE).working_visible is True - def test_a_bar_is_labelled_with_the_share_it_has_covered(self) -> None: + def test_a_bar_is_labeled_with_the_share_it_has_covered(self) -> None: assert view_model(progress=HALFWAY).progress_overlay == "50%" diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py index f2f37bf01..7772e1068 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_render.py +++ b/tests/unit/sampletones_application/view_model/shared/test_render.py @@ -72,7 +72,7 @@ def test_a_depth_survives_a_rate_change(self) -> None: assert settings.spec.sample_rate == 8000 assert settings.depth == AudioDepth.PCM_U8 - def test_the_normalise_choice_stands_through_a_format_change(self) -> None: + def test_the_normalize_choice_stands_through_a_format_change(self) -> None: settings = wave_settings().with_normalize(True).with_format(AudioFormat.MP3) assert settings.normalize diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py index 5ae61ddf3..91b5ae74c 100644 --- a/tests/unit/sampletones_assets/mark/test_raster.py +++ b/tests/unit/sampletones_assets/mark/test_raster.py @@ -26,20 +26,20 @@ def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) -> image = MarkRaster(mark).render() assert image.getpixel(CORNER)[ALPHA] == 0 - def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None: + def test_the_frame_center_carries_the_background(self, mark: Mark) -> None: """The frame reaches the top edge between its rounded corners, so the ground there is opaque.""" image = MarkRaster(mark).render() - centre = image.size[0] // 2 - assert image.getpixel((centre, 1))[ALPHA] == 255 + center = image.size[0] // 2 + assert image.getpixel((center, 1))[ALPHA] == 255 - def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + def test_the_smooth_half_is_drawn_in_its_own_color(self, mark: Mark) -> None: image = MarkRaster(mark).render() scale = mark.render.supersample start = mark.waves.sine.start pixel = image.getpixel((round(start.x * scale), round(start.y * scale))) assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS] - def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + def test_the_stepped_half_is_drawn_in_its_own_color(self, mark: Mark) -> None: image = MarkRaster(mark).render() scale = mark.render.supersample corner = mark.waves.square.points[1] diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py index 081cb682d..108abb064 100644 --- a/tests/unit/sampletones_assets/mark/test_vector.py +++ b/tests/unit/sampletones_assets/mark/test_vector.py @@ -18,7 +18,7 @@ def test_the_shipped_vector_is_what_the_definition_renders(self) -> None: def test_the_template_is_filled_throughout(self) -> None: assert PLACEHOLDER_PREFIX not in render_vector(Mark.load()) - def test_every_colour_reaches_the_document(self) -> None: + def test_every_color_reaches_the_document(self) -> None: mark = Mark.load() document = render_vector(mark) colors = ( @@ -33,7 +33,7 @@ def test_every_colour_reaches_the_document(self) -> None: assert color in document def test_the_document_follows_the_definition(self) -> None: - """A colour changed in the definition is the colour the vector is drawn with.""" + """A color changed in the definition is the color the vector is drawn with.""" mark = Mark.load() recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})}) document = render_vector(recolored) diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py index 785563b0d..bbc067dd4 100644 --- a/tests/unit/sampletones_core/exporters/test_lengths.py +++ b/tests/unit/sampletones_core/exporters/test_lengths.py @@ -60,13 +60,13 @@ def test_an_over_long_envelope_keeps_its_opening_items(self) -> None: assert len(limited[ARPEGGIO]) == ITEM_LIMIT def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), limit=ITEM_LIMIT) assert str(ITEM_LIMIT) in caplog.text def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): limit_lengths(volume_and_arpeggio(ITEM_LIMIT), limit=ITEM_LIMIT) assert caplog.text == "" @@ -83,13 +83,13 @@ def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None assert len(equalized[ARPEGGIO]) == ITEM_LIMIT def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False, limit=ITEM_LIMIT) assert str(ITEM_LIMIT) in caplog.text def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): equalize_lengths(volume_and_arpeggio(ITEM_LIMIT), loop=False, limit=ITEM_LIMIT) assert caplog.text == "" @@ -105,7 +105,7 @@ def test_an_absent_limit_keeps_every_item(self) -> None: assert len(equalized[ARPEGGIO]) == length def test_an_absent_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.WARNING): + with caplog.at_level(logging.DEBUG): equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False) assert caplog.text == "" diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py index f728dd2b3..91de10bf0 100644 --- a/tests/unit/sampletones_core/timing/test_groove.py +++ b/tests/unit/sampletones_core/timing/test_groove.py @@ -749,7 +749,7 @@ def test_every_row_lies_within_the_engine_range(self, test_case: TestCase) -> No test_cases, ids=lambda test_case: test_case.label, ) - def test_every_row_neighbours_the_average(self, test_case: TestCase) -> None: + def test_every_row_neighbors_the_average(self, test_case: TestCase) -> None: groove = test_case.groove shorter, remainder = divmod(groove.total_ticks, test_case.rows) longer = shorter + 1 if remainder else shorter diff --git a/tests/unit/sampletones_player/clock/test_schedule.py b/tests/unit/sampletones_player/clock/test_schedule.py index cd12f99a2..e60e8b1a7 100644 --- a/tests/unit/sampletones_player/clock/test_schedule.py +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -79,7 +79,7 @@ def test_the_advances_sum_to_the_cumulative_count(self, test_case: TestCase) -> ids=lambda test_case: test_case.label, ) def test_only_the_floor_and_the_ceiling_appear(self, test_case: TestCase) -> None: - """Consecutive calls advance by one of two neighbouring amounts, so the stream moves evenly.""" + """Consecutive calls advance by one of two neighboring amounts, so the stream moves evenly.""" schedule = test_case.schedule advances = {schedule.advance_at(play_call) for play_call in range(LONG_RUN_PLAY_CALLS)} assert max(advances) - min(advances) <= 1 diff --git a/tests/unit/sampletones_player/nsf/test_file.py b/tests/unit/sampletones_player/nsf/test_file.py index 391a00774..988dc8a7e 100644 --- a/tests/unit/sampletones_player/nsf/test_file.py +++ b/tests/unit/sampletones_player/nsf/test_file.py @@ -149,7 +149,7 @@ def test_a_song_too_large_for_the_program_area_raises( class TestWriteNSF: """The bytes reaching a file on disk.""" - def test_the_file_holds_the_bytes_the_song_serialises_to( + def test_the_file_holds_the_bytes_the_song_serializes_to( self, song: Song, image: DriverImage, diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py index 4d22e3204..c8a0fbd84 100644 --- a/tests/unit/sampletones_player/nsf/test_header.py +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -69,7 +69,7 @@ def read_string(data: bytes, offset: int) -> bytes: class TestHeaderBytes: - """The exact bytes an NSF header serialises to. + """The exact bytes an NSF header serializes to. The layout is what every console player reads a file through, so the literal states it in full: the identity, the three addresses, the three text fields, and the playback fields @@ -90,7 +90,7 @@ class TestHeaderBytes: + bytes(NSF2_LENGTH_SIZE) ) - def test_the_header_serialises_to_the_expected_bytes(self) -> None: + def test_the_header_serializes_to_the_expected_bytes(self) -> None: assert header() == self.EXPECTED def test_the_header_fills_the_program_area_it_precedes(self) -> None: diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index 052472205..e3a46a622 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -76,7 +76,7 @@ def repeating_song() -> Song: class TestSongBytes: - """The exact bytes a hand-built song serialises to. + """The exact bytes a hand-built song serializes to. The layout is the contract the driver reads the song through, so the literal states it in full: the header, the timer every pitch sounds at, the dictionary the tokens name, and the @@ -107,7 +107,7 @@ class TestSongBytes: b"\x40\x0a\x00" ) - def test_the_song_serialises_to_the_expected_bytes(self) -> None: + def test_the_song_serializes_to_the_expected_bytes(self) -> None: song = two_tick_song(HALF_RATE_FREQUENCY) expected = self.EXPECTED_HEADER + song.pitches.data + self.EXPECTED_STREAMS assert song_to_bytes(song, PROGRAM_AREA_BYTES) == expected diff --git a/tests/unit/sampletones_player/trace/test_trace.py b/tests/unit/sampletones_player/trace/test_trace.py index 0f666be5f..f800fd3bc 100644 --- a/tests/unit/sampletones_player/trace/test_trace.py +++ b/tests/unit/sampletones_player/trace/test_trace.py @@ -49,32 +49,32 @@ def addresses(writes: Tuple[RegisterWrite, ...]) -> Tuple[int, ...]: return tuple(write.address for write in writes) -class TestInitialisation: +class TestInitialization: """The init routine leaves a silent console enabled and sounding the song's first tick.""" SONG: Final = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None) @property - def initialisation(self) -> Tuple[RegisterWrite, ...]: - return RegisterTrace.from_song(self.SONG, play_calls=0).initialisation + def initialization(self) -> Tuple[RegisterWrite, ...]: + return RegisterTrace.from_song(self.SONG, play_calls=0).initialization def test_every_channel_register_is_cleared_first(self) -> None: - cleared = self.initialisation[: LAST_CHANNEL_REGISTER - FIRST_CHANNEL_REGISTER + 1] + cleared = self.initialization[: LAST_CHANNEL_REGISTER - FIRST_CHANNEL_REGISTER + 1] assert addresses(cleared) == tuple(range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1)) assert all(write.value == SILENCED_REGISTER for write in cleared) def test_the_channels_are_enabled(self) -> None: - assert RegisterWrite(APU_STATUS, CHANNELS_ENABLED) in self.initialisation + assert RegisterWrite(APU_STATUS, CHANNELS_ENABLED) in self.initialization def test_the_frame_counter_runs_without_an_interrupt(self) -> None: - assert RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE) in self.initialisation + assert RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE) in self.initialization def test_both_sweep_units_are_disabled(self) -> None: - assert RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED) in self.initialisation - assert RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED) in self.initialisation + assert RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED) in self.initialization + assert RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED) in self.initialization def test_the_sweep_survives_the_clearing_pass(self) -> None: - sweeps = [write.value for write in self.initialisation if write.address == PULSE1_SWEEP] + sweeps = [write.value for write in self.initialization if write.address == PULSE1_SWEEP] assert sweeps[-1] == SWEEP_DISABLED def test_every_length_counter_loads_once_the_channels_are_enabled(self) -> None: @@ -85,19 +85,19 @@ def test_every_length_counter_loads_once_the_channels_are_enabled(self) -> None: :data:`APU_STATUS`. The noise channel's is written for that alone; the other three carry the first tick's timer high byte. """ - writes = self.initialisation + writes = self.initialization enabled = writes.index(RegisterWrite(APU_STATUS, CHANNELS_ENABLED)) for address in (PULSE1_TIMER_HIGH, PULSE2_TIMER_HIGH, TRIANGLE_TIMER_HIGH, NOISE_LENGTH_COUNTER): loaded = max(index for index, write in enumerate(writes) if write.address == address) assert loaded > enabled - def test_the_first_tick_sounds_from_initialisation(self) -> None: - first_tick = self.initialisation[-WRITES_PER_TICK:] + def test_the_first_tick_sounds_from_initialization(self) -> None: + first_tick = self.initialization[-WRITES_PER_TICK:] assert len(first_tick) == WRITES_PER_TICK assert first_tick[0] == RegisterWrite(PULSE1_CONTROL, self.SONG.streams.pulse1[0].control) def test_the_first_tick_writes_the_registers_that_reset_a_channel(self) -> None: - first_tick = self.initialisation[-WRITES_PER_TICK:] + first_tick = self.initialization[-WRITES_PER_TICK:] assert REGISTERS_WRITTEN_ON_CHANGE.issubset(set(addresses(first_tick))) diff --git a/tests/unit/sampletones_shared/utils/test_color.py b/tests/unit/sampletones_shared/utils/test_color.py index a4de4b939..959d1b51d 100644 --- a/tests/unit/sampletones_shared/utils/test_color.py +++ b/tests/unit/sampletones_shared/utils/test_color.py @@ -181,7 +181,7 @@ def test_a_transparent_overlay_leaves_the_base(self) -> None: def test_a_transparent_base_leaves_the_overlay(self) -> None: assert composite(self.TRANSPARENT, self.GREEN) == self.GREEN - def test_two_transparent_colours_stay_transparent(self) -> None: + def test_two_transparent_colors_stay_transparent(self) -> None: assert composite(self.TRANSPARENT, self.TRANSPARENT) == self.TRANSPARENT def test_stacked_washes_cover_more_than_either_alone(self) -> None: diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index 2056eb0d4..9e13f2873 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -70,7 +70,7 @@ def test_an_attribute_assigned_the_resolved_value_is_reported(self) -> None: def test_the_report_names_the_assignment_line(self) -> None: assert locations(PANEL_SOURCE) == [f"{PANEL_MODULE}:4", f"{PANEL_MODULE}:6"] - def test_an_attribute_holding_the_palette_colour_passes(self) -> None: + def test_an_attribute_holding_the_palette_color_passes(self) -> None: source = "class GUIPanel:\n def __init__(self, layout) -> None:\n self._c = layout.colors\n" assert not messages(source) @@ -86,7 +86,7 @@ def test_a_local_holding_the_resolved_value_passes(self) -> None: class TestUnregisteredThemeColors: - def test_a_theme_colour_filled_directly_is_reported( + def test_a_theme_color_filled_directly_is_reported( self, module_helpers: Tuple[Path, str], ) -> None: @@ -135,14 +135,14 @@ def test_the_palettes_sit_inside_the_configuration_package(self) -> None: class TestLiteralColors: - def test_a_hex_colour_outside_the_palettes_is_reported(self, tmp_path: Path) -> None: + def test_a_hex_color_outside_the_palettes_is_reported(self, tmp_path: Path) -> None: (tmp_path / "settings.yaml").write_text(LAYOUT_FILE) findings = check_palette_colors.find_literal_colors(tmp_path, tmp_path / "palettes") assert [finding.location for finding in findings] == [f"{tmp_path / 'settings.yaml'}:2"] - def test_a_palette_carries_its_colours_as_values(self, tmp_path: Path) -> None: + def test_a_palette_carries_its_colors_as_values(self, tmp_path: Path) -> None: palettes = tmp_path / "palettes" palettes.mkdir() (palettes / "studio.yaml").write_text(PALETTE_FILE) diff --git a/tests/unit/scripts/checks/test_shortcut_actions.py b/tests/unit/scripts/checks/test_shortcut_actions.py index eac948f9c..569d95c5a 100644 --- a/tests/unit/scripts/checks/test_shortcut_actions.py +++ b/tests/unit/scripts/checks/test_shortcut_actions.py @@ -44,7 +44,7 @@ def test_a_binding_map_names_its_actions_as_keys(self) -> None: assert check_shortcut_actions.mapping_keys(_module(SHELL_SOURCE)) == {"NEW_PROJECT", "OPEN_PROJECT"} def test_a_family_declares_the_actions_it_dispatches(self) -> None: - """A family is declared rather than recognised, so what counts as one is never guessed.""" + """A family is declared rather than recognized, so what counts as one is never guessed.""" assert FAMILY_ACTION in FAMILY_SHORTCUT_IDS def test_the_lookup_of_every_action_by_name_is_no_family(self) -> None: From 8c5b145a5f65389110bd0150b16be0a510bb07aa Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 10:49:40 +0200 Subject: [PATCH 107/142] Temporarily disabled: pylint fixme --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 473b5cbc2..84c169dbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,7 +172,7 @@ ignore-paths = "^tests/.*$" load-plugins = ["pylint_pydantic"] fail-on = [ "fatal", - "fixme", + # "fixme", "redefined-outer-name", "used-before-assignment", "unused-import", From b368038d22b603a3598a62f0127c8d677678b85b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 11:31:49 +0200 Subject: [PATCH 108/142] Renaming --- docs/development/architecture.md | 2 +- src/sampletones_application/application.py | 6 +++--- .../categories/elements/instructions.py | 2 +- .../coordinators/reconstruction.py | 6 +++--- .../coordinators/render.py | 4 ++-- .../coordinators/tabs/instructions.py | 6 +++--- .../coordinators/tabs/main.py | 4 ++-- .../logic/export/logic.py | 4 ++-- .../logic/instruction/library.py | 12 +++++------ .../logic/instruction/library_manager.py | 6 +++--- .../logic/main/converter.py | 12 +++++------ .../logic/render/logic.py | 14 ++++++------- .../services/__init__.py | 4 ++-- .../services/conversion.py | 8 ++++---- .../services/export/result.py | 4 ++-- .../services/export/service.py | 10 +++++----- .../services/regeneration.py | 14 ++++++------- .../services/render/result.py | 4 ++-- .../services/render/service.py | 6 +++--- .../services/result.py | 4 ++-- .../services/retune/result.py | 4 ++-- .../ui/elements/tree/tree.py | 4 ++-- .../ui/panels/dialogs/keybindings.py | 4 ++-- .../ui/panels/instruction/library.py | 2 +- .../utils/file_dialogs/result.py | 6 +++--- .../utils/gui/dialogs/renderer.py | 2 +- .../gui/dialogs/windows/save_confirmation.py | 2 +- .../utils/gui/keyboard/capture.py | 4 ++-- .../utils/parallelization/thread.py | 6 +++--- .../view_model/main/converter.py | 2 +- .../view_model/shared/render.py | 2 +- src/sampletones_config/lang/en.yaml | 6 +++--- src/sampletones_core/exports/backend.py | 6 +++--- src/sampletones_core/exports/progress.py | 6 +++--- .../parallelization/processor.py | 12 +++++------ src/sampletones_core/parallelization/task.py | 2 +- src/sampletones_core/performance/progress.py | 6 +++--- src/sampletones_core/performance/song.py | 2 +- src/sampletones_core/scripts/library.py | 8 ++++---- .../scripts/reconstruction.py | 8 ++++---- src/sampletones_player/builder.py | 6 +++--- src/sampletones_player/compression/encode.py | 2 +- .../compression/parse/song.py | 4 ++-- .../compression/progress/monitor.py | 8 ++++---- src/sampletones_player/compression/search.py | 2 +- src/sampletones_player/compression/song.py | 2 +- src/sampletones_player/export.py | 6 +++--- src/sampletones_player/song.py | 2 +- src/sampletones_shared/exceptions/__init__.py | 4 ++-- .../exceptions/operation.py | 4 ++-- tests/suite/render.py | 4 ++-- .../coordinators/export/test_instrument.py | 6 +++--- .../coordinators/tabs/test_sequencer.py | 6 +++--- .../coordinators/test_render.py | 8 ++++---- .../logic/export/test_logic.py | 6 +++--- .../logic/instruction/test_library_logic.py | 18 ++++++++--------- .../logic/main/test_converter.py | 10 +++++----- .../logic/render/test_logic.py | 12 +++++------ .../services/export/test_service.py | 10 +++++----- .../services/render/test_service.py | 12 +++++------ .../services/test_conversion.py | 10 +++++----- .../services/test_regeneration.py | 20 +++++++++---------- .../services/test_result.py | 14 ++++++------- .../test_application_retune.py | 6 +++--- .../ui/panels/dialogs/test_keybindings.py | 2 +- .../panels/sequencer/input/test_grid_input.py | 8 ++++---- .../sequencer/input/test_order_input.py | 6 +++--- .../sequencer/input/test_tracker_input.py | 6 +++--- .../ui/panels/sequencer/voices/test_keys.py | 6 +++--- .../gui/dialogs/windows/test_confirmation.py | 6 +++--- .../dialogs/windows/test_save_confirmation.py | 2 +- .../utils/gui/keyboard/test_capture.py | 14 ++++++------- .../utils/parallelization/test_thread.py | 6 +++--- .../view_model/main/test_converter.py | 4 ++-- .../view_model/shared/test_render.py | 2 +- .../exports/test_famitracker.py | 4 ++-- .../sampletones_core/exports/test_progress.py | 6 +++--- .../sampletones_core/performance/test_song.py | 4 ++-- .../compression/progress/test_monitor.py | 8 ++++---- .../compression/test_encode.py | 6 +++--- tests/unit/sampletones_player/test_export.py | 8 ++++---- 81 files changed, 253 insertions(+), 253 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 965d406a9..d4fde5e92 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -302,7 +302,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m **Contracts:** - Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)`, and `_emit(result)`. - `_emit` always posts the result to `CallbackQueue`; it never calls a handler directly from the background thread. -- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCancelled`, enabling exhaustive `match` handling by subscribers. +- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCanceled`, enabling exhaustive `match` handling by subscribers. - Services hold no references to panels, view models, or logic objects. **May import:** `sampletones_core`, `sampletones_shared`, `utils/callbacks/`. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 257b783e1..ee2c1059e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -84,7 +84,7 @@ RetunedSample, RetuneResult, SampleRetuneService, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceSuccess, @@ -486,7 +486,7 @@ def __init__( status_bar=self.status_bar, on_load_file=self._on_converted_reconstruction_loaded, on_load_directory=self._navigate_to_reconstructions, - on_cancelled=self._refresh_reconstruction_trees, + on_canceled=self._refresh_reconstruction_trees, on_refresh_trees=self._refresh_reconstruction_trees, on_generate_library=self._instructions_tab.ensure_library_loaded, stem_selection_window=self.stem_selection_window, @@ -1176,7 +1176,7 @@ def _on_retune_result(self, result: RetuneResult) -> None: self._apply_retuned_sample(retuned) case ServiceError(exception=exception): logger.error_with_traceback(exception, "Sample retune failed") - case ServiceCancelled(): + case ServiceCanceled(): pass if not self.retune_service.is_running(): diff --git a/src/sampletones_application/categories/elements/instructions.py b/src/sampletones_application/categories/elements/instructions.py index 7414a97bf..feb7b13d1 100644 --- a/src/sampletones_application/categories/elements/instructions.py +++ b/src/sampletones_application/categories/elements/instructions.py @@ -23,7 +23,7 @@ class InstructionsLibraryElements(AbstractElement): STATUS_SAVING = "status_saving" STATUS_GENERATION_SUCCESS = "status_generation_success" STATUS_WINDOW_NOT_AVAILABLE = "status_window_not_available" - STATUS_GENERATION_CANCELLED = "status_generation_cancelled" + STATUS_GENERATION_CANCELED = "status_generation_canceled" STATUS_GENERATION_FAILED = "status_generation_failed" STATUS_FILE_NOT_FOUND = "status_file_not_found" STATUS_FILE_LOAD_ERROR = "status_file_load_error" diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 62a8813dc..bde8f0b94 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -16,7 +16,7 @@ RegeneratedInstrument, RegenerationResult, RegenerationService, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -333,8 +333,8 @@ def _on_regeneration_result(self, result: RegenerationResult) -> None: case ServiceError(exception=exception): logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) - case ServiceCancelled(): - logger.info("Regeneration cancelled") + case ServiceCanceled(): + logger.info("Regeneration canceled") self._set_reconstruction_dimmed(self._regeneration_service.is_running()) diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py index b2ed163a5..5b024108f 100644 --- a/src/sampletones_application/coordinators/render.py +++ b/src/sampletones_application/coordinators/render.py @@ -57,7 +57,7 @@ def __init__( self._logic.on_choose_destination = self._choose_destination self._logic.on_success = self._on_success self._logic.on_error = self._on_error - self._logic.on_cancelled = self._on_cancelled + self._logic.on_canceled = self._on_canceled self._window.on_settings_changed = self._logic.apply self._window.on_browse = self._logic.request_destination @@ -138,7 +138,7 @@ def _on_error(self, exception: Exception) -> None: self._close() self._present(partial(self._dialogs.show_error, exception, self._msg_failed)) - def _on_cancelled(self) -> None: + def _on_canceled(self) -> None: """Closes the dialog of a render that was stopped, which leaves no file to report.""" self._close() diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index ac684e0a4..96e0a5b94 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -156,7 +156,7 @@ def __init__( self._library_logic.on_view_changed = self._library_panel.update_view self._library_logic.on_generation_completed = self._on_generation_completed self._library_logic.on_generation_error = self._on_generation_error - self._library_logic.on_generation_cancelled = self._on_generation_cancelled + self._library_logic.on_generation_canceled = self._on_generation_canceled self._library_logic.on_load_file_not_found = self._on_library_file_not_found self._library_logic.on_load_error = self._on_library_load_error @@ -288,10 +288,10 @@ def _on_generation_error(self, exception: Exception) -> None: self._language_manager["instructions.library.message.status_generation_failed"], ) - def _on_generation_cancelled(self) -> None: + def _on_generation_canceled(self) -> None: self._dialogs.show_info( TAG_INSTRUCTIONS_LIBRARY_PANEL, - self._language_manager["instructions.library.message.status_generation_cancelled"], + self._language_manager["instructions.library.message.status_generation_canceled"], self._ttl_generation_status, modal=True, ) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index ae78d9229..958dcc834 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -107,7 +107,7 @@ def __init__( status_bar: GUIStatusBar, on_load_file: PathCallback, on_load_directory: VoidCallback, - on_cancelled: VoidCallback, + on_canceled: VoidCallback, on_refresh_trees: VoidCallback, on_generate_library: VoidCallback, stem_selection_window: GUIStemSelectionWindow, @@ -259,7 +259,7 @@ def __init__( self._converter_logic.cancel_library_generation = library_manager.cancel_generation self._converter_logic.on_load_file = on_load_file self._converter_logic.on_load_directory = on_load_directory - self._converter_logic.on_cancelled = on_cancelled + self._converter_logic.on_canceled = on_canceled self._converter_logic.generate_library = on_generate_library config_manager.add_config_change_callback(self._converter_logic.refresh_view) library_manager.on_generation_progress_extra = conversion_service.forward_library_progress diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index a4d528d45..6c090007b 100644 --- a/src/sampletones_application/logic/export/logic.py +++ b/src/sampletones_application/logic/export/logic.py @@ -6,7 +6,7 @@ ExportSuccess, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -94,7 +94,7 @@ def _on_service_result(self, result: ExportResult) -> None: self._on_started() case ServiceProgress() as progress: self._on_progress(progress) - case ExportSuccess() | ExportError() | ServiceCancelled(): + case ExportSuccess() | ExportError() | ServiceCanceled(): self._on_finished() def _on_started(self) -> None: diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 271f878cb..2d56dde0f 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -73,7 +73,7 @@ def __init__( self.on_apply_library_config: Optional[OnApplyLibraryConfigCallback] = None self.on_generation_completed: Optional[VoidCallback] = None self.on_generation_error: Optional[Callable[[Exception], None]] = None - self.on_generation_cancelled: Optional[VoidCallback] = None + self.on_generation_canceled: Optional[VoidCallback] = None self.on_load_file_not_found: Optional[Callable[[Path, str], None]] = None self.on_load_error: Optional[Callable[[Exception, str], None]] = None @@ -85,7 +85,7 @@ def __init__( on_generation_progress=self._on_generation_progress, on_generation_completed=self._on_generation_completed, on_generation_error=self._on_generation_error, - on_generation_cancelled=self._on_generation_cancelled, + on_generation_canceled=self._on_generation_canceled, ) def configure_lock( @@ -373,8 +373,8 @@ def _on_generation_progress( self._emit_view(self._language_manager["instructions.library.message.status_saving"], progress=1.0) case TaskStatus.FAILED: self._emit_view(self._language_manager["instructions.library.message.status_generation_failed"]) - case TaskStatus.CANCELLED: - self._emit_view(self._language_manager["instructions.library.message.status_generation_cancelled"]) + case TaskStatus.CANCELED: + self._emit_view(self._language_manager["instructions.library.message.status_generation_canceled"]) case TaskStatus.RUNNING: self._update_progress_state(task_progress) @@ -405,8 +405,8 @@ def _on_generation_error(self, exception: Exception) -> None: self.call(self.on_generation_error, exception) self._finalize_generation_error() - def _on_generation_cancelled(self) -> None: - self.call(self.on_generation_cancelled) + def _on_generation_canceled(self) -> None: + self.call(self.on_generation_canceled) self._finalize_generation() def _finalize_generation(self) -> None: diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 291f42516..40df1830a 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -57,7 +57,7 @@ def __init__( self.on_generation_progress: Optional[OnGenerationProgressCallback] = None self.on_generation_progress_extra: Optional[OnGenerationProgressCallback] = None self.on_generation_error: Optional[OnGenerationErrorCallback] = None - self.on_generation_cancelled: Optional[VoidCallback] = None + self.on_generation_canceled: Optional[VoidCallback] = None def set_library_directory(self, directory: Path) -> None: self._library = InstructionLibrary(directory=str(directory)) @@ -191,7 +191,7 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: on_start=self.on_generation_start, on_completed=self._complete_generation, on_error=self.on_generation_error, - on_cancelled=self.on_generation_cancelled, + on_canceled=self.on_generation_canceled, on_progress=_on_progress, ) @@ -248,7 +248,7 @@ def shutdown(self) -> None: """Tears the library creator's process pool down synchronously for application exit. A conversion generates its library first, so this pool is the one still spawning - workers when a run is cancelled and the window is closed; this blocks until it has + workers when a run is canceled and the window is closed; this blocks until it has stopped so the process reaps its workers before releasing shared resources.""" if self._creator: self._creator.shutdown() diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 19b219b54..248d7d35d 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -15,7 +15,7 @@ ) from sampletones_application.services.result import ( ConversionResult, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -126,7 +126,7 @@ def __init__( self.on_target_exists: Optional[PathCallback] = None self.on_load_file: Optional[PathCallback] = None self.on_load_directory: Optional[VoidCallback] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None self.generate_library: Optional[VoidCallback] = None self.cancel_library_generation: Optional[VoidCallback] = None self.is_library_available: Optional[Callable[[], bool]] = None @@ -333,7 +333,7 @@ def _on_service_result(self, result: ConversionResult) -> None: self._on_conversion_complete(written) case ServiceError(exception=exception): self._on_conversion_error(exception) - case ServiceCancelled(): + case ServiceCanceled(): self._on_cancellation_complete() def _handle_progress_result(self, progress: ServiceProgress[Path]) -> None: @@ -561,10 +561,10 @@ def _on_conversion_error(self, exception: Exception) -> None: self.call(self.on_error, exception) def _on_cancellation_complete(self) -> None: - self._phase = ConversionPhase.CANCELLED - self._emit_view_model(self._language_manager["main.converter.message.status_cancelled"], 0.0) + self._phase = ConversionPhase.CANCELED + self._emit_view_model(self._language_manager["main.converter.message.status_canceled"], 0.0) self._schedule_return_to_idle() - self.call(self.on_cancelled) + self.call(self.on_canceled) def _schedule_return_to_idle(self) -> None: CallbackQueue.add( diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py index 0cc91e353..33ad4c209 100644 --- a/src/sampletones_application/logic/render/logic.py +++ b/src/sampletones_application/logic/render/logic.py @@ -13,7 +13,7 @@ from sampletones_application.logic.shared.project_source import ProjectSnapshot from sampletones_application.services.render.result import RenderResult, RenderStage from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceStarted, @@ -69,7 +69,7 @@ def __init__( self._service = render_service self._is_operation_active = is_operation_active self._msg_cancelling = language_manager["settings.render.message.status_cancelling"] - self._msg_cancelled = language_manager["settings.render.message.status_cancelled"] + self._msg_canceled = language_manager["settings.render.message.status_canceled"] self._msg_completed = language_manager["settings.render.message.status_completed"] self._msg_failed = language_manager["settings.render.message.status_failed"] self._eta_template = language_manager["global.dialog.template.time_estimation"] @@ -91,7 +91,7 @@ def __init__( self.on_choose_destination: Optional[Callable[[Path, AudioFormat], None]] = None self.on_success: Optional[PathCallback] = None self.on_error: Optional[Callable[[Exception], None]] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None @property def is_active(self) -> bool: @@ -206,7 +206,7 @@ def _on_service_result(self, result: RenderResult) -> None: self._on_render_complete(destination) case ServiceError(exception=exception): self._on_render_error(exception) - case ServiceCancelled(): + case ServiceCanceled(): self._on_cancellation_complete() def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None: @@ -239,9 +239,9 @@ def _on_render_error(self, exception: Exception) -> None: self.call(self.on_error, exception) def _on_cancellation_complete(self) -> None: - self._phase = RenderPhase.CANCELLED - self._report(self._msg_cancelled, 0.0) - self.call(self.on_cancelled) + self._phase = RenderPhase.CANCELED + self._report(self._msg_canceled, 0.0) + self.call(self.on_canceled) def _report(self, status_text: str, progress: float) -> None: self._status_text = status_text diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index 59f0dcca7..227b9415b 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -17,7 +17,7 @@ ) from sampletones_application.services.result import ( ConversionResult, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -45,7 +45,7 @@ "RowSynthesizerProtocol", "SampleRetuneService", "ServiceBase", - "ServiceCancelled", + "ServiceCanceled", "ServiceError", "ServiceIntermediate", "ServiceProgress", diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion.py index 4af0d8b74..8cc7f0f26 100644 --- a/src/sampletones_application/services/conversion.py +++ b/src/sampletones_application/services/conversion.py @@ -4,7 +4,7 @@ from sampletones_application.services.base import ServiceBase from sampletones_application.services.result import ( ConversionResult, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -44,7 +44,7 @@ def start(self, config: Config, plan: ConversionPlan) -> None: on_progress=self._on_progress, on_completed=self._on_completed, on_error=self._on_error, - on_cancelled=self._on_cancelled, + on_canceled=self._on_canceled, ) self._converter.start() @@ -110,8 +110,8 @@ def _on_completed(self, written: Tuple[Path, ...]) -> None: def _on_error(self, exception: Exception) -> None: self._emit(ServiceError(exception=exception)) - def _on_cancelled(self) -> None: - self._emit(ServiceCancelled()) + def _on_canceled(self) -> None: + self._emit(ServiceCanceled()) def forward_library_progress( self, diff --git a/src/sampletones_application/services/export/result.py b/src/sampletones_application/services/export/result.py index e88ee5674..56e08c3a1 100644 --- a/src/sampletones_application/services/export/result.py +++ b/src/sampletones_application/services/export/result.py @@ -3,7 +3,7 @@ from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.success import ExportSuccess from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -14,7 +14,7 @@ ServiceProgress[ExportStage], ExportSuccess, ExportError, - ServiceCancelled, + ServiceCanceled, ] __all__ = [ diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index e7c3e4136..2ffc23402 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -11,7 +11,7 @@ from sampletones_application.services.export.reporter import ExportProgressReporter from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.result import ServiceCancelled, ServiceStarted +from sampletones_application.services.result import ServiceCanceled, ServiceStarted from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave from sampletones_core.exports.artifact import ExportArtifact @@ -23,7 +23,7 @@ ProjectExport, SampleExport, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.logger import logger NO_EXPORT_FORMAT: None = None @@ -176,9 +176,9 @@ def _run( try: self._emit(ServiceStarted(total=UNMEASURED_AT_THE_START)) self._report_written(kind, destination, export_format, write(self._reporter())) - except OperationCancelled: - logger.info(f"The export to {logger.format_path(destination)} was cancelled") - self._emit(ServiceCancelled()) + except OperationCanceled: + logger.info(f"The export to {logger.format_path(destination)} was canceled") + self._emit(ServiceCanceled()) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"Failed to export to: {destination}") self._emit( diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index ac5d2c0ac..1909be867 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -5,7 +5,7 @@ from sampletones_application.services.base import ServiceBase from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -31,7 +31,7 @@ class RegeneratedInstrument: feature_key: FeatureKey -RegenerationResult = ServiceSuccess[RegeneratedInstrument] | ServiceError | ServiceCancelled +RegenerationResult = ServiceSuccess[RegeneratedInstrument] | ServiceError | ServiceCanceled class RegenerationService(ServiceBase[RegenerationResult]): @@ -51,7 +51,7 @@ class RegenerationService(ServiceBase[RegenerationResult]): def __init__(self, priority: int = 0) -> None: super().__init__(priority) self._executor = LatestWinsExecutor() - self._cancelled: bool = False + self._canceled: bool = False def start( self, @@ -61,7 +61,7 @@ def start( feature_key: FeatureKey, value: FeatureValue, ) -> bool: - if self._cancelled: + if self._canceled: return False return self._executor.submit( @@ -78,7 +78,7 @@ def is_running(self) -> bool: return self._executor.is_running def cancel(self) -> None: - self._cancelled = True + self._canceled = True def _run( self, @@ -88,8 +88,8 @@ def _run( feature_key: FeatureKey, value: FeatureValue, ) -> None: - if self._cancelled: - self._emit(ServiceCancelled()) + if self._canceled: + self._emit(ServiceCanceled()) return try: exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] diff --git a/src/sampletones_application/services/render/result.py b/src/sampletones_application/services/render/result.py index 9ee105e5e..1b18accb8 100644 --- a/src/sampletones_application/services/render/result.py +++ b/src/sampletones_application/services/render/result.py @@ -3,7 +3,7 @@ from typing import Union from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceStarted, @@ -27,5 +27,5 @@ class RenderStage(StrEnum): ServiceProgress[RenderStage], ServiceSuccess[Path], ServiceError, - ServiceCancelled, + ServiceCanceled, ] diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py index 5fcf5789b..67eaf113a 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -11,7 +11,7 @@ build_render_sink, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceStarted, ServiceSuccess, @@ -32,7 +32,7 @@ class SongRenderService(ServiceBase[RenderResult]): pass or two without knowing which format waits on the other side. A render is one at a time. Cancelling is honored between rows and between encoded blocks, - and the file a cancelled or failed run was writing is removed, so a result names a path only + and the file a canceled or failed run was writing is removed, so a result names a path only where a finished file stands. """ @@ -160,7 +160,7 @@ def _report_encoded(self, progress: StageProgress[RenderStage], encoded: int) -> def _report_outcome(self, sink: RenderSink, completed: bool) -> None: if not completed: sink.discard() - self._emit(ServiceCancelled()) + self._emit(ServiceCanceled()) return logger.info(f"Rendered the song to: {logger.format_path(sink.destination)}") diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index 2f237ea9e..20c7c1176 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -31,7 +31,7 @@ class ServiceError: @dataclass(frozen=True) -class ServiceCancelled: +class ServiceCanceled: pass @@ -46,5 +46,5 @@ class ServiceIntermediate(Generic[T]): ServiceIntermediate[TaskProgress], ServiceSuccess[Tuple[Path, ...]], ServiceError, - ServiceCancelled, + ServiceCanceled, ] diff --git a/src/sampletones_application/services/retune/result.py b/src/sampletones_application/services/retune/result.py index 98ceccbbd..ef91919af 100644 --- a/src/sampletones_application/services/retune/result.py +++ b/src/sampletones_application/services/retune/result.py @@ -1,8 +1,8 @@ from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) from sampletones_application.services.retune.sample import RetunedSample -RetuneResult = ServiceSuccess[RetunedSample] | ServiceError | ServiceCancelled +RetuneResult = ServiceSuccess[RetunedSample] | ServiceError | ServiceCanceled diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 80d9072a1..ceb332a12 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -80,7 +80,7 @@ ) from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.parallelization.thread import ( - BackgroundWorkCancelled, + BackgroundWorkCanceled, SingleThreadExecutor, ) from sampletones_core.configs.display import ( @@ -414,7 +414,7 @@ def _append_spec( decision covers the whole subtree and the traversal walks on. """ if SingleThreadExecutor.is_shutting_down(): - raise BackgroundWorkCancelled + raise BackgroundWorkCanceled if not self._is_node_drawn(node): return diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py index c5db0248f..b5ca658e5 100644 --- a/src/sampletones_application/ui/panels/dialogs/keybindings.py +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -256,13 +256,13 @@ def _create_action_buttons(self) -> None: ) def _install_capture(self) -> None: - """Readies the capture that reads a press, cancelled by whatever a dialog is cancelled by.""" + """Readies the capture that reads a press, canceled by whatever a dialog is canceled by.""" self._capture = KeyCapture( key_router=self._router, cancel=self._shortcuts.shortcut(ShortcutId.DIALOG_CANCEL).combinations(), ) self._capture.on_captured = self._report_captured - self._capture.on_cancelled = self._render + self._capture.on_canceled = self._render def _teardown(self) -> None: """Stops the capture this appearance armed before the keyboard claim is released.""" diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 3807946b2..23186dad4 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -279,7 +279,7 @@ def refresh_action_buttons(self) -> None: Called whenever a long operation starts or finishes. The button stays enabled only while the panel is unlocked and no conversion or library generation is running, leaving the rest of the panel usable during such an operation. The cancel button stays enabled so a generation can - always be cancelled.""" + always be canceled.""" self._apply_action_button_states() def _apply_action_button_states(self) -> None: diff --git a/src/sampletones_application/utils/file_dialogs/result.py b/src/sampletones_application/utils/file_dialogs/result.py index 590c4b217..36b32d9f5 100644 --- a/src/sampletones_application/utils/file_dialogs/result.py +++ b/src/sampletones_application/utils/file_dialogs/result.py @@ -43,11 +43,11 @@ def ignore_none_path( Callable[Concatenate[T, Optional[Path], P], R], ], ]: - """Wraps a path handler so a cancelled dialog resolves to ``default``. + """Wraps a path handler so a canceled dialog resolves to ``default``. Applied bare (``@ignore_none_path``) the wrapped method runs with the selected ``Path`` and - yields ``None`` when the dialog was cancelled. Applied with a ``default`` - (``@ignore_none_path(default=...)``) the cancelled case yields that value instead, so a handler + yields ``None`` when the dialog was canceled. Applied with a ``default`` + (``@ignore_none_path(default=...)``) the canceled case yields that value instead, so a handler that reports an outcome — such as a save returning whether it wrote — carries a truthful result through the cancellation. Each handler body runs only with a real path. """ diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py index c818215a7..84daefe61 100644 --- a/src/sampletones_application/utils/gui/dialogs/renderer.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -421,7 +421,7 @@ def show_save_confirmation( """Modal save-or-proceed prompt for an unsaved document. ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a cancelled save keeps the + ``on_confirm`` and closes once the save reports success, so a canceled save keeps the prompt open for another attempt. The middle button discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt. """ diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py index d3b2d8bf9..f100c6e3c 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -24,7 +24,7 @@ class GUISaveConfirmationWindow(GUIDialogWindow): """A modal save-or-proceed prompt for an unsaved document. ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a cancelled save keeps the + ``on_confirm`` and closes once the save reports success, so a canceled save keeps the prompt open for another attempt. The middle button discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt. diff --git a/src/sampletones_application/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py index 1bbb001ef..804c681f1 100644 --- a/src/sampletones_application/utils/gui/keyboard/capture.py +++ b/src/sampletones_application/utils/gui/keyboard/capture.py @@ -38,7 +38,7 @@ def __init__( self._listening = False self.on_captured: Optional[Callback] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None @property def is_listening(self) -> bool: @@ -69,7 +69,7 @@ def handle_key(self, event: KeyEvent) -> None: combination = KeyCombination(event.key, event.modifiers) self.stop() if combination in self._cancel: - self.call(self.on_cancelled) + self.call(self.on_canceled) return self.call(self.on_captured, combination) diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py index d13f101c6..3c75aab52 100644 --- a/src/sampletones_application/utils/parallelization/thread.py +++ b/src/sampletones_application/utils/parallelization/thread.py @@ -11,7 +11,7 @@ CONCURRENT_EXECUTOR_NAME: Final[str] = "_concurrent_executor" -class BackgroundWorkCancelled(Exception): +class BackgroundWorkCanceled(Exception): """Unwinds a background task promptly once shutdown has been requested. Long-running tasks poll :meth:`SingleThreadExecutor.is_shutting_down` at their @@ -64,7 +64,7 @@ def request_shutdown(cls) -> None: """Signal running background tasks to wind down at their next cancellation point. Set before :meth:`join_all` at teardown so an in-flight task raises - :class:`BackgroundWorkCancelled` and finishes promptly, letting the join + :class:`BackgroundWorkCanceled` and finishes promptly, letting the join return quickly. """ cls._shutdown.set() @@ -140,7 +140,7 @@ def task() -> None: try: function(self, *args, **kwargs) - except BackgroundWorkCancelled: + except BackgroundWorkCanceled: return except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback( diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 757e4b418..4e6a3a87d 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -18,7 +18,7 @@ class ConversionPhase(StrEnum): RUNNING = "running" CANCELLING = "cancelling" COMPLETED = "completed" - CANCELLED = "cancelled" + CANCELED = "canceled" FAILED = "failed" diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py index 5e8928c7d..926e145ae 100644 --- a/src/sampletones_application/view_model/shared/render.py +++ b/src/sampletones_application/view_model/shared/render.py @@ -28,7 +28,7 @@ class RenderPhase(StrEnum): RENDERING = "rendering" CANCELLING = "cancelling" COMPLETED = "completed" - CANCELLED = "cancelled" + CANCELED = "canceled" FAILED = "failed" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index bd52ec466..e27482be3 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -362,7 +362,7 @@ main.converter.message.status_idle: "No tasks in progress." main.converter.message.status_waiting: "Waiting to start..." main.converter.message.status_generating_library: "Generating instructions library... (this may take a while)" main.converter.message.status_cancelling: "Aborting the conversion..." -main.converter.message.status_cancelled: "Conversion cancelled." +main.converter.message.status_canceled: "Conversion canceled." main.converter.message.status_input_label: "Input:" main.converter.message.status_output_label: "Output:" main.converter.message.status_empty_hint: "Select a WAV file or a folder in the browser to begin." @@ -694,7 +694,7 @@ instructions.library.message.status_generating: "Generating library..." instructions.library.message.status_saving: "Saving generated library..." instructions.library.message.status_generation_success: "Library generated successfully." instructions.library.message.status_window_not_available: "Window not available." -instructions.library.message.status_generation_cancelled: "Library generation cancelled." +instructions.library.message.status_generation_canceled: "Library generation canceled." instructions.library.message.status_generation_failed: "Error generating library." instructions.library.message.status_file_not_found: "The library file could not be found." instructions.library.message.status_file_load_error: "Error while loading the library file." @@ -809,7 +809,7 @@ settings.render.template.bitrate: "{bitrate} kbps" settings.render.message.status_synthesis: "Rendering the song..." settings.render.message.status_encoding: "Writing the file..." settings.render.message.status_cancelling: "Stopping the render..." -settings.render.message.status_cancelled: "Render cancelled." +settings.render.message.status_canceled: "Render canceled." settings.render.message.status_completed: "Render complete." settings.render.message.status_failed: "Render failed." settings.render.message.rendered: "The song was rendered successfully." diff --git a/src/sampletones_core/exports/backend.py b/src/sampletones_core/exports/backend.py index a4c1856b2..dcac7c715 100644 --- a/src/sampletones_core/exports/backend.py +++ b/src/sampletones_core/exports/backend.py @@ -60,7 +60,7 @@ def write_instrument( ExportArtifact: The paths written and what the format's limits left out. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. OSError: If the destination cannot be written. """ @@ -83,7 +83,7 @@ def write_sample( ExportArtifact: The paths written and what the format's limits left out. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. OSError: If the destination cannot be written. """ @@ -104,7 +104,7 @@ def write_project( ExportArtifact: The paths written and what the format's limits left out. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. OSError: If the destination cannot be written. ValueError: If the project holds more than the format has room for. """ diff --git a/src/sampletones_core/exports/progress.py b/src/sampletones_core/exports/progress.py index f0159edbd..73b38cd13 100644 --- a/src/sampletones_core/exports/progress.py +++ b/src/sampletones_core/exports/progress.py @@ -2,7 +2,7 @@ from typing import Callable, Final, Optional from sampletones_core.exports.stage import ExportStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled @dataclass(frozen=True) @@ -47,7 +47,7 @@ def announce( total: What the stage counts up to, and ``None`` where only the data decides. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ if not report(ExportProgress(stage=stage, completed=completed, total=total)): - raise OperationCancelled(f"the export was withdrawn while {stage}") + raise OperationCanceled(f"the export was withdrawn while {stage}") diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index a377bf3ce..7d67834a7 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -49,7 +49,7 @@ def __init__( self.on_progress: Optional[Callable[[TaskStatus, TaskProgress], None]] = None self.on_completed: Optional[Callable[[T], None]] = None self.on_error: Optional[Callable[[Exception], None]] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None def start(self) -> None: self.monitor_thread = threading.Thread( @@ -107,8 +107,8 @@ def is_running(self) -> bool: def is_completed(self) -> bool: return self.status == TaskStatus.COMPLETED - def is_cancelled(self) -> bool: - return self.status == TaskStatus.CANCELLED + def is_canceled(self) -> bool: + return self.status == TaskStatus.CANCELED def is_cancelling(self) -> bool: return self.status == TaskStatus.CANCELLING @@ -206,12 +206,12 @@ def _finalize_cancellation(self) -> None: if not self.cancelling: return - self.logger.info("Task processing was cancelled.") - self.status = TaskStatus.CANCELLED + self.logger.info("Task processing was canceled.") + self.status = TaskStatus.CANCELED self.cancelling = False self.running = False self._notify_progress() - self.call(self.on_cancelled) + self.call(self.on_canceled) def _finalize_completion(self, results: List[T]) -> None: self.logger.info("Conversion completed successfully") diff --git a/src/sampletones_core/parallelization/task.py b/src/sampletones_core/parallelization/task.py index 6c7226610..7eb36abdf 100644 --- a/src/sampletones_core/parallelization/task.py +++ b/src/sampletones_core/parallelization/task.py @@ -13,7 +13,7 @@ class TaskStatus(Enum): COMPLETED = "COMPLETED" FAILED = "FAILED" CANCELLING = "CANCELLING" - CANCELLED = "CANCELLED" + CANCELED = "CANCELED" CLEANING_UP = "CLEANING_UP" diff --git a/src/sampletones_core/performance/progress.py b/src/sampletones_core/performance/progress.py index 7579addec..aaa670170 100644 --- a/src/sampletones_core/performance/progress.py +++ b/src/sampletones_core/performance/progress.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Callable, Final -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled @dataclass(frozen=True) @@ -38,7 +38,7 @@ def announce(report: WalkReporter, ticks: int, total: int) -> None: total: The engine ticks the whole order lasts. Raises: - OperationCancelled: If the walk is no longer wanted. + OperationCanceled: If the walk is no longer wanted. """ if not report(WalkProgress(ticks=ticks, total=total)): - raise OperationCancelled(f"the walk was withdrawn having sounded {ticks} of {total} ticks") + raise OperationCanceled(f"the walk was withdrawn having sounded {ticks} of {total} ticks") diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index c92f96d70..985080de9 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -40,7 +40,7 @@ def song_instructions( Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, tick by tick. Raises: - OperationCancelled: If ``report`` withdraws the walk. + OperationCanceled: If ``report`` withdraws the walk. """ song = project.song groove = SongTiming.from_project(project).groove() diff --git a/src/sampletones_core/scripts/library.py b/src/sampletones_core/scripts/library.py index 9c2c44859..068e44a4d 100644 --- a/src/sampletones_core/scripts/library.py +++ b/src/sampletones_core/scripts/library.py @@ -50,13 +50,13 @@ def on_progress( if task_status in ( TaskStatus.COMPLETED, - TaskStatus.CANCELLED, + TaskStatus.CANCELED, TaskStatus.FAILED, ): progress_bar.close() - def on_cancelled() -> None: - logger.info("Library generation cancelled by user") + def on_canceled() -> None: + logger.info("Library generation canceled by user") progress_bar.close() def on_error(_exception: Exception) -> None: @@ -66,7 +66,7 @@ def on_error(_exception: Exception) -> None: on_start=on_start, on_completed=on_completed, on_progress=on_progress, - on_cancelled=on_cancelled, + on_canceled=on_canceled, on_error=on_error, ) diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py index b3ab0de2d..ee8350290 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -89,13 +89,13 @@ def on_progress( if task_status in ( TaskStatus.COMPLETED, - TaskStatus.CANCELLED, + TaskStatus.CANCELED, TaskStatus.FAILED, ): progress_bar.close() - def on_cancelled() -> None: - logger.info("Reconstruction cancelled by user") + def on_canceled() -> None: + logger.info("Reconstruction canceled by user") progress_bar.close() def on_error(_exception: Exception) -> None: @@ -111,7 +111,7 @@ def on_error(_exception: Exception) -> None: on_start=on_start, on_completed=on_completed, on_progress=on_progress, - on_cancelled=on_cancelled, + on_canceled=on_canceled, on_error=on_error, ) diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 5e099eac4..41c59d9ba 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -77,7 +77,7 @@ def song_from_reconstruction( Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCanceled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ @@ -164,7 +164,7 @@ def song_from_sample( Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCanceled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If two slices name the same channel. """ @@ -209,7 +209,7 @@ def song_from_project( Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` or ``walk`` withdraws the run. + OperationCanceled: If ``report`` or ``walk`` withdraws the run. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks, or the project's samples were reconstructed against tunings that differ. diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index 1078bebd3..6d44ceecf 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -141,7 +141,7 @@ def encode_planes( CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. Raises: - OperationCancelled: If ``report`` withdraws the run. + OperationCanceled: If ``report`` withdraws the run. """ cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes.planes) monitor = CodecMonitor(report) diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py index e9dbfee00..dded5b55f 100644 --- a/src/sampletones_player/compression/parse/song.py +++ b/src/sampletones_player/compression/parse/song.py @@ -33,7 +33,7 @@ def parse_planes( Tuple[Parse, ...]: One parse per plane, in the order the planes were given. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ parses: List[Parse] = [] for plane in range(len(cache.indices)): @@ -73,7 +73,7 @@ def parse_planes_offered( Tuple[Parse, ...]: One parse per plane, in the order the planes were given. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ trial: List[Parse] = [] for plane in range(len(cache.indices)): diff --git a/src/sampletones_player/compression/progress/monitor.py b/src/sampletones_player/compression/progress/monitor.py index 10ce80f01..58670943b 100644 --- a/src/sampletones_player/compression/progress/monitor.py +++ b/src/sampletones_player/compression/progress/monitor.py @@ -1,7 +1,7 @@ from typing import Final from sampletones_player.compression.progress.report import CodecProgress, CodecReporter -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled NOTHING_FOUND: Final[int] = 0 NOTHING_LAID_DOWN: Final[int] = 0 @@ -35,7 +35,7 @@ def reached(self, phrases: int, size: int) -> None: size: The bytes the dictionary and the eight streams now take together. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ self._progress = CodecProgress(phrases=phrases, size=size) self.poll() @@ -44,10 +44,10 @@ def poll(self) -> None: """Offers what the run last reached, which is how a long stretch answers a withdrawal. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ if not self._report(self._progress): - raise OperationCancelled( + raise OperationCanceled( f"the encoding was withdrawn holding {self._progress.phrases} phrases " f"and {self._progress.size} bytes" ) diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py index a30167f70..830e350cf 100644 --- a/src/sampletones_player/compression/search.py +++ b/src/sampletones_player/compression/search.py @@ -166,7 +166,7 @@ def search_phrases( PhraseTable: The seeded phrases alongside the ones the search earned. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ indices = cache.indices parses = parse_planes(cache, table, options, boundaries, monitor) diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py index 673099605..dae432908 100644 --- a/src/sampletones_player/compression/song.py +++ b/src/sampletones_player/compression/song.py @@ -44,7 +44,7 @@ def compress_song( CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. Raises: - OperationCancelled: If ``report`` withdraws the run. + OperationCanceled: If ``report`` withdraws the run. ValueError: If a stream sounds a timer the pitch table states no index for. """ return encode_planes( diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py index b181137d7..d91f20516 100644 --- a/src/sampletones_player/export.py +++ b/src/sampletones_player/export.py @@ -137,7 +137,7 @@ def write_instrument( """Writes a program playing one channel slice. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. SongTooLargeError: If the slice runs longer than the program area holds. OSError: If the destination cannot be written. """ @@ -162,7 +162,7 @@ def write_sample( seconds reads as the work it is doing. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. SongTooLargeError: If the reconstruction runs longer than the program area holds. OSError: If the destination cannot be written. """ @@ -200,7 +200,7 @@ def write_project( what an NSF player expects of a song that has reached its end. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. SongTooLargeError: If the song holds more than the program area has room for. OSError: If the destination cannot be written. ValueError: If the project's samples were reconstructed against tunings that differ. diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index eed2f374c..52487cb31 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -63,7 +63,7 @@ def from_streams( Song: The song as the console holds it. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCanceled: If ``report`` withdraws the compression. ValueError: If ``loop_tick`` lies outside the song's ticks, or a channel sounds a timer the pitch table states no index for. """ diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 62cae0b58..05f0972fe 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -24,7 +24,7 @@ NoLibraryDataError, UnhandledLibraryError, ) -from .operation import OperationCancelled +from .operation import OperationCanceled from .player import ( DriverBuildError, PlayerError, @@ -95,7 +95,7 @@ "NoLibraryDataError", "NotAValidArchiveError", "NotAnInstrumentFileError", - "OperationCancelled", + "OperationCanceled", "PlaybackError", "PlayerError", "ReconstructionError", diff --git a/src/sampletones_shared/exceptions/operation.py b/src/sampletones_shared/exceptions/operation.py index 6d8918e0b..9abd09bcc 100644 --- a/src/sampletones_shared/exceptions/operation.py +++ b/src/sampletones_shared/exceptions/operation.py @@ -1,10 +1,10 @@ from .base import SampleToNESError -class OperationCancelled(SampleToNESError): +class OperationCanceled(SampleToNESError): """Raised when work in progress is withdrawn by whoever asked for it. Long operations look up between the steps they are made of and ask the caller whether the answer is still wanted. A caller that says no leaves the work unwound at that point, so the - boundary that started it reports a cancelled run rather than a finished or failed one. + boundary that started it reports a canceled run rather than a finished or failed one. """ diff --git a/tests/suite/render.py b/tests/suite/render.py index 5831f05ff..8f51ba81a 100644 --- a/tests/suite/render.py +++ b/tests/suite/render.py @@ -5,7 +5,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_application.services.render.result import RenderResult from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -74,7 +74,7 @@ def shutdown(self) -> None: def emit(self, result: RenderResult) -> None: assert self._handler is not None, "The logic subscribes to the service it is given" - self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) + self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCanceled)) self._handler(result) @property diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py index a57e4919b..49d85c0b6 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_instrument.py +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -62,7 +62,7 @@ def _save(**kwargs: object) -> Path: @pytest.fixture -def cancelled(monkeypatch: pytest.MonkeyPatch) -> None: +def canceled(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(instrument_module, "save_file_dialog", lambda **_kwargs: None) @@ -187,11 +187,11 @@ def test_the_destination_reaches_the_write( logic.export.assert_called_once_with(DESTINATION, source) - def test_a_cancelled_dialog_writes_nothing( + def test_a_canceled_dialog_writes_nothing( self, coordinator: InstrumentExportCoordinator, logic: MagicMock, - cancelled: None, + canceled: None, ) -> None: coordinator.request(_source(), SUGGESTED_NAME) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 3ab90588b..bbba94a13 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -161,7 +161,7 @@ def _open(**kwargs: object) -> Path: @pytest.fixture -def cancelled_dialog(monkeypatch: pytest.MonkeyPatch) -> None: +def canceled_dialog(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sequencer_module, "open_file_dialog", lambda **_kwargs: None) @@ -199,10 +199,10 @@ def test_the_folder_the_file_came_from_is_remembered( instrument_coordinator._session_manager.set_instrument_path.assert_called_once_with(INSTRUMENT_FILE.parent) - def test_a_cancelled_dialog_leaves_the_pool_as_it_stands( + def test_a_canceled_dialog_leaves_the_pool_as_it_stands( self, instrument_coordinator: SequencerTabCoordinator, - cancelled_dialog: None, + canceled_dialog: None, ) -> None: instrument_coordinator.import_instrument() diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py index 8e642d443..e973f4e6d 100644 --- a/tests/unit/sampletones_application/coordinators/test_render.py +++ b/tests/unit/sampletones_application/coordinators/test_render.py @@ -10,7 +10,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.render.logic import SongRenderLogic from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -331,7 +331,7 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) - render.start() render.stop() - render.service.emit(ServiceCancelled()) + render.service.emit(ServiceCanceled()) render.advance_frame() assert render.window.hides == 1 @@ -343,9 +343,9 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) - [ ServiceSuccess(value=CHOSEN), ServiceError(exception=OSError("no room on the device")), - ServiceCancelled(), + ServiceCanceled(), ], - ids=["completed", "failed", "cancelled"], + ids=["completed", "failed", "canceled"], ) def test_every_outcome_hands_the_application_back( self, diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py index 3877dd4a1..3a8b5eea5 100644 --- a/tests/unit/sampletones_application/logic/export/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -6,7 +6,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.result import ExportResult, ExportSuccess from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -266,7 +266,7 @@ def test_a_finished_run_takes_the_dialog_off_screen( service.deliver(finished()) assert closed == [True] - def test_a_cancelled_run_takes_the_dialog_off_screen( + def test_a_canceled_run_takes_the_dialog_off_screen( self, logic: SongExportLogic, service: FakeExportService, @@ -274,7 +274,7 @@ def test_a_cancelled_run_takes_the_dialog_off_screen( closed: List[bool] = [] logic.on_finished = lambda: closed.append(True) service.deliver(ServiceStarted(total=NOTHING_MEASURED)) - service.deliver(ServiceCancelled()) + service.deliver(ServiceCanceled()) assert closed == [True] def test_a_run_that_ended_holds_the_screen_no_longer( diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index e1ac1669a..9a43e6715 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -27,13 +27,13 @@ INVALID_DATA_KEY: Final[str] = "instructions.library.message.status_invalid_data" DESERIALIZATION_ERROR_KEY: Final[str] = "instructions.library.message.status_deserialization_error" INCOMPATIBLE_VERSION_KEY: Final[str] = "instructions.library.template.incompatible_version_template" -GENERATION_CANCELLED_KEY: Final[str] = "instructions.library.message.status_generation_cancelled" +GENERATION_CANCELED_KEY: Final[str] = "instructions.library.message.status_generation_canceled" TEXTS: Final[Dict[str, str]] = { INCOMPATIBLE_VERSION_KEY: "got {} expected {}", "instructions.library.message.status_saving": "saving", "instructions.library.message.status_generation_failed": "failed", - GENERATION_CANCELLED_KEY: "cancelled", + GENERATION_CANCELED_KEY: "canceled", "instructions.library.label.generate_library_button": "Generate", "instructions.library.label.regenerate_library_button": "Regenerate", "instructions.library.template.library_loaded_template": "{} loaded.", @@ -191,13 +191,13 @@ class TestGenerationEmits: """Every emit passes its status and progress explicitly, so the logic retains no presentation state between emissions and each view model is complete on its own.""" - def test_cancelled_emits_the_language_managed_status(self) -> None: + def test_canceled_emits_the_language_managed_status(self) -> None: logic = _generation_logic() - logic._on_generation_progress(TaskStatus.CANCELLED, MagicMock()) + logic._on_generation_progress(TaskStatus.CANCELED, MagicMock()) view_model = logic.on_view_changed.call_args.args[0] - assert view_model.status_text == "cancelled" + assert view_model.status_text == "canceled" def test_completed_emits_saving_at_full_progress(self) -> None: logic = _generation_logic() @@ -268,11 +268,11 @@ def test_update_status_reports_an_existing_unloaded_library(self) -> None: assert view_model.generate_button_label == "Generate" -class TestCancelledStatusLanguageKey: - """The cancelled status resolves through ``LanguageManager`` at construction, so the language +class TestCanceledStatusLanguageKey: + """The canceled status resolves through ``LanguageManager`` at construction, so the language file must carry the key.""" - def test_cancelled_status_resolves_from_the_language_file(self) -> None: + def test_canceled_status_resolves_from_the_language_file(self) -> None: language_manager = LanguageManager(LANG_EN) - assert language_manager[GENERATION_CANCELLED_KEY] + assert language_manager[GENERATION_CANCELED_KEY] diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 15b698ec4..bbe251656 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -78,9 +78,9 @@ def test_cancel_while_waiting_cancels_generation_and_finishes( converter_logic: ConverterLogic, ) -> None: cancel_generation = MagicMock() - on_cancelled = MagicMock() + on_canceled = MagicMock() converter_logic.cancel_library_generation = cancel_generation - converter_logic.on_cancelled = on_cancelled + converter_logic.on_canceled = on_canceled with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): converter_logic.start_conversion() @@ -89,8 +89,8 @@ def test_cancel_while_waiting_cancels_generation_and_finishes( converter_logic.cancel() cancel_generation.assert_called_once() - on_cancelled.assert_called_once() - assert converter_logic._phase == ConversionPhase.CANCELLED + on_canceled.assert_called_once() + assert converter_logic._phase == ConversionPhase.CANCELED def test_wait_loop_aborts_once_no_longer_waiting( self, @@ -256,7 +256,7 @@ def test_active_during_non_terminal_phases( [ ConversionPhase.IDLE, ConversionPhase.COMPLETED, - ConversionPhase.CANCELLED, + ConversionPhase.CANCELED, ConversionPhase.FAILED, ], ) diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py index df2ee2414..633526a31 100644 --- a/tests/unit/sampletones_application/logic/render/test_logic.py +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -13,7 +13,7 @@ ) from sampletones_application.services.render.result import RenderStage from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceSuccess, @@ -275,14 +275,14 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture def test_a_stopped_render_reports_the_cancellation(self, render: RenderFixture) -> None: render.configure() render.logic.start() - on_cancelled = MagicMock() - render.logic.on_cancelled = on_cancelled + on_canceled = MagicMock() + render.logic.on_canceled = on_canceled render.logic.cancel() - render.service.emit(ServiceCancelled()) + render.service.emit(ServiceCanceled()) - on_cancelled.assert_called_once() - assert render.view.phase == RenderPhase.CANCELLED + on_canceled.assert_called_once() + assert render.view.phase == RenderPhase.CANCELED assert not render.logic.is_active def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 4fc6f63f3..01a3a4e28 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -10,7 +10,7 @@ from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -601,18 +601,18 @@ def test_a_stage_that_lands_on_its_total_is_reported(self, service, tmp_path) -> class TestWithdrawingARun: - """A cancelled export answers with a cancellation rather than a failure.""" + """A canceled export answers with a cancellation rather than a failure.""" - def test_a_cancelled_run_ends_cancelled(self, service, tmp_path) -> None: + def test_a_canceled_run_ends_canceled(self, service, tmp_path) -> None: export_service, results = service export_service.export_instrument( tmp_path / "instrument.nsf", CancellingBackend(export_service), build_instrument(), ) - assert isinstance(outcome(results), ServiceCancelled) + assert isinstance(outcome(results), ServiceCanceled) - def test_a_cancelled_run_reports_no_failure(self, service, tmp_path) -> None: + def test_a_canceled_run_reports_no_failure(self, service, tmp_path) -> None: export_service, results = service export_service.export_instrument( tmp_path / "instrument.nsf", diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py index cb509bbd6..f40183fff 100644 --- a/tests/unit/sampletones_application/services/render/test_service.py +++ b/tests/unit/sampletones_application/services/render/test_service.py @@ -8,7 +8,7 @@ from sampletones_application.services.render.result import RenderResult, RenderStage from sampletones_application.services.render.service import SongRenderService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceStarted, @@ -148,12 +148,12 @@ def test_the_spill_file_is_removed(self, tmp_path: Path) -> None: class TestCancelling(BaseTestSuite): - """A cancelled render reports itself cancelled and names no file.""" + """A canceled render reports itself canceled and names no file.""" def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None) - def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None: + def test_a_canceled_render_leaves_no_file(self, tmp_path: Path) -> None: destination = tmp_path / "song.wav" service = SongRenderService() service.start( @@ -166,7 +166,7 @@ def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None: assert not destination.exists() - def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> None: + def test_a_canceled_render_reports_itself_canceled(self, tmp_path: Path) -> None: service = SongRenderService() results: List[RenderResult] = [] service.subscribe(results.append) @@ -178,9 +178,9 @@ def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> No total_samples=TOTAL_SAMPLES, ) - assert results[-1] == ServiceCancelled() + assert results[-1] == ServiceCanceled() - def test_a_cancelled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: + def test_a_canceled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: service = SongRenderService() service.start( synthesizer=self._cancelling_synthesizer(service), diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index d7580c142..458146f45 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -7,7 +7,7 @@ from sampletones_application.services.conversion import ConversionService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -76,7 +76,7 @@ def test_start_wires_five_lifecycle_callbacks( "on_progress", "on_completed", "on_error", - "on_cancelled", + "on_canceled", } def test_start_while_running_does_not_create_second_converter( @@ -202,15 +202,15 @@ def test_on_error_emits_service_error( assert isinstance(result, ServiceError) assert result.exception is exception - def test_on_cancelled_emits_service_cancelled( + def test_on_canceled_emits_service_canceled( self, service: Service, ) -> None: _, _, callbacks, results = service - callbacks["on_cancelled"]() + callbacks["on_canceled"]() assert len(results) == 1 - assert isinstance(results[0], ServiceCancelled) + assert isinstance(results[0], ServiceCanceled) def test_forward_library_progress_emits_service_intermediate( self, diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 38ea45308..c8686e912 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -8,7 +8,7 @@ from sampletones_application.services.regeneration import RegenerationService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -86,7 +86,7 @@ def reconstruction() -> MockReconstruction: class TestRegenerationServiceStart: - def test_start_when_not_cancelled_returns_true( + def test_start_when_not_canceled_returns_true( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction ) -> None: service = RegenerationService() @@ -99,7 +99,7 @@ def test_start_when_not_cancelled_returns_true( ) assert result is True - def test_start_when_cancelled_returns_false(self) -> None: + def test_start_when_canceled_returns_false(self) -> None: service = RegenerationService() service.cancel() @@ -107,7 +107,7 @@ def test_start_when_cancelled_returns_false(self) -> None: assert result is False - def test_start_when_cancelled_does_not_emit(self) -> None: + def test_start_when_canceled_does_not_emit(self) -> None: service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) @@ -141,13 +141,13 @@ def test_start_reports_a_submit_failure(self) -> None: assert result is False - def test_cancel_sets_cancelled_flag(self) -> None: + def test_cancel_sets_canceled_flag(self) -> None: service = RegenerationService() - assert not service._cancelled + assert not service._canceled service.cancel() - assert service._cancelled + assert service._canceled class TestRegenerationServiceIsRunning: @@ -163,11 +163,11 @@ def test_is_running_delegates_to_the_executor(self) -> None: class TestRegenerationServiceRun: - def test_run_when_cancelled_emits_service_cancelled(self) -> None: + def test_run_when_canceled_emits_service_canceled(self) -> None: service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) - service._cancelled = True + service._canceled = True service._run( MagicMock(), @@ -178,7 +178,7 @@ def test_run_when_cancelled_emits_service_cancelled(self) -> None: ) assert len(results) == 1 - assert isinstance(results[0], ServiceCancelled) + assert isinstance(results[0], ServiceCanceled) def test_run_success_emits_service_success( self, diff --git a/tests/unit/sampletones_application/services/test_result.py b/tests/unit/sampletones_application/services/test_result.py index db1ed71a9..829598fda 100644 --- a/tests/unit/sampletones_application/services/test_result.py +++ b/tests/unit/sampletones_application/services/test_result.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -102,18 +102,18 @@ def test_same_instance_equals_itself(self) -> None: assert error == error # noqa: PLR0124 -class TestServiceCancelled: +class TestServiceCanceled: def test_instantiates(self) -> None: - cancelled = ServiceCancelled() - assert isinstance(cancelled, ServiceCancelled) + canceled = ServiceCanceled() + assert isinstance(canceled, ServiceCanceled) def test_frozen(self) -> None: - cancelled = ServiceCancelled() + canceled = ServiceCanceled() with pytest.raises(FrozenInstanceError): - cancelled.x = 1 # type: ignore[attr-defined] + canceled.x = 1 # type: ignore[attr-defined] def test_equality(self) -> None: - assert ServiceCancelled() == ServiceCancelled() + assert ServiceCanceled() == ServiceCanceled() class TestServiceIntermediate: diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index e5f8fa29a..5d2cf4377 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from sampletones_application.application import Application -from sampletones_application.services.result import ServiceCancelled +from sampletones_application.services.result import ServiceCanceled from sampletones_application.services.retune import RetunedSample from sampletones_core.project.voices.sample import Sample @@ -144,13 +144,13 @@ def test_does_not_dim_when_no_reconstruction_is_open(self) -> None: def test_restores_the_dim_when_the_batch_finishes(self) -> None: app = _app_for_rate([], open_reconstruction=None, running=False) - app._on_retune_result(ServiceCancelled()) + app._on_retune_result(ServiceCanceled()) app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(False) def test_keeps_the_dim_while_the_batch_is_running(self) -> None: app = _app_for_rate([], open_reconstruction=None, running=True) - app._on_retune_result(ServiceCancelled()) + app._on_retune_result(ServiceCanceled()) app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py index 1dfe4852b..f175443f9 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py @@ -312,7 +312,7 @@ def test_a_listening_cell_asks_for_the_press(self, harness: Harness) -> None: assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == CAPTURING_MESSAGE - def test_a_cancelled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None: + def test_a_canceled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None: harness.render(view_model(selected=SAVE_PROJECT)) harness.click_shortcut(SAVE_PROJECT) harness.press(dpg.mvKey_Escape) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py index 510e60bef..a770434a1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -83,11 +83,11 @@ def test_dropping_a_partial_entry_holds_the_selection(self) -> None: assert held.pending == "" def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: - cancelled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel() + canceled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel() - assert cancelled.region is None - assert cancelled.pending == "" - assert cancelled.cursor == _Cell(2, 1) + assert canceled.region is None + assert canceled.pending == "" + assert canceled.cursor == _Cell(2, 1) def test_a_committed_entry_leaves_the_cursor_alone(self) -> None: settled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3))._after_entry() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 9be209e76..549b19912 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -103,10 +103,10 @@ def test_typing_an_index_collapses_the_selection(self) -> None: assert committed.region is None def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: - cancelled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel() + canceled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel() - assert cancelled.region is None - assert cancelled.pending == "" + assert canceled.region is None + assert canceled.pending == "" class TestTarget: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index a6e7364cb..a30928472 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -119,10 +119,10 @@ def test_a_note_off_collapses_the_selection(self) -> None: assert typed.region is None def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: - cancelled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel() + canceled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel() - assert cancelled.region is None - assert cancelled.pending == "" + assert canceled.region is None + assert canceled.pending == "" def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: selected = _state(SubColumn.TRANSPOSE, row=4).extend_row(2, ROW_COUNT) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py index bd90b106b..e0aaae67e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py @@ -29,7 +29,7 @@ class VoicesPanelFixture: removed: List[str] = field(default_factory=list) moved: List[Move] = field(default_factory=list) renamed: List[str] = field(default_factory=list) - cancelled: List[None] = field(default_factory=list) + canceled: List[None] = field(default_factory=list) @pytest.fixture @@ -45,7 +45,7 @@ def voices(monkeypatch: pytest.MonkeyPatch) -> VoicesPanelFixture: panel.on_remove_requested = fixture.removed.append panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target)) monkeypatch.setattr(panel, "start_rename", fixture.renamed.append) - monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None)) + monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.canceled.append(None)) return fixture @@ -95,7 +95,7 @@ def test_the_cancel_key_drops_the_name_being_edited(self, voices: VoicesPanelFix voices.panel._editing_voice_id = SELECTED_ID assert voices.panel._on_key_pressed(_press("Esc")) is True - assert voices.cancelled == [None] + assert voices.canceled == [None] def test_every_other_key_stays_with_the_field(self, voices: VoicesPanelFixture) -> None: """A rename keeps the keyboard, so typing a name reaches the input rather than the list.""" diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py index 7dc40610e..4ad5efcc5 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py @@ -23,7 +23,7 @@ WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) CONFIRMED: Final[str] = "confirmed" -CANCELLED: Final[str] = "cancelled" +CANCELED: Final[str] = "canceled" OPTED_OUT: Final[str] = "opted_out" @@ -60,7 +60,7 @@ def render( path=path, opt_out_label=opt_out_label, on_opt_out=lambda: answers.append(OPTED_OUT) if answers is not None else None, - on_cancel=lambda: answers.append(CANCELLED) if answers is not None else None, + on_cancel=lambda: answers.append(CANCELED) if answers is not None else None, ) window.create_window() @@ -85,7 +85,7 @@ def test_cancel_runs_the_negative_answer_and_closes(self, window: GUIConfirmatio press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL)) - assert answers == [CANCELLED] + assert answers == [CANCELED] assert not dpg.does_item_exist(WINDOW_TAG) def test_a_ticked_opt_out_rides_the_confirmation(self, window: GUIConfirmationWindow) -> None: diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py index 55458c17a..d5d4b373f 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -59,7 +59,7 @@ def press(tag: str) -> None: class TestSaveConfirmationWindow: - def test_a_cancelled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None: + def test_a_canceled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None: answers: List[str] = [] render(window, save_succeeds=False, answers=answers) diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py index ca2ae9fca..d0b96015e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py @@ -37,10 +37,10 @@ class Harness: def __init__(self) -> None: self.router = KeyRouter() self.captured: List[KeyCombination] = [] - self.cancelled = 0 + self.canceled = 0 self.capture = KeyCapture(key_router=self.router, cancel=CANCEL) self.capture.on_captured = self.captured.append - self.capture.on_cancelled = self._on_cancelled + self.capture.on_canceled = self._on_canceled def press(self, key: int, modifiers: ModifierSet = NO_MODIFIERS) -> None: self.router.route(KeyEvent(key=key, modifiers=modifiers)) @@ -49,8 +49,8 @@ def press_all(self, events: Tuple[KeyEvent, ...]) -> None: for event in events: self.press(event.key, event.modifiers) - def _on_cancelled(self) -> None: - self.cancelled += 1 + def _on_canceled(self) -> None: + self.canceled += 1 @pytest.fixture(name="harness") @@ -234,16 +234,16 @@ def test_a_key_pressed_after_one_the_table_names_none_of_is_read(self, harness: assert harness.captured == [KeyCombination(dpg.mvKey_D, CTRL)] -class TestCancelledCapture: +class TestCanceledCapture: def test_the_cancel_combination_ends_the_capture_without_assigning(self, harness: Harness) -> None: harness.press(dpg.mvKey_Escape) assert harness.captured == [] - assert harness.cancelled == 1 + assert harness.canceled == 1 assert not harness.capture.is_listening def test_the_cancel_key_under_a_modifier_is_a_combination_like_any_other(self, harness: Harness) -> None: harness.press(dpg.mvKey_Escape, CTRL) assert harness.captured == [KeyCombination(dpg.mvKey_Escape, CTRL)] - assert harness.cancelled == 0 + assert harness.canceled == 0 diff --git a/tests/unit/sampletones_application/utils/parallelization/test_thread.py b/tests/unit/sampletones_application/utils/parallelization/test_thread.py index 018becfea..da62a4332 100644 --- a/tests/unit/sampletones_application/utils/parallelization/test_thread.py +++ b/tests/unit/sampletones_application/utils/parallelization/test_thread.py @@ -4,7 +4,7 @@ from unittest.mock import patch from sampletones_application.utils.parallelization.thread import ( - BackgroundWorkCancelled, + BackgroundWorkCanceled, SingleThreadExecutor, concurrent, ) @@ -100,11 +100,11 @@ def work(self) -> None: assert ran == [] - def test_cancelled_exception_unwinds_without_logging_an_error(self) -> None: + def test_canceled_exception_unwinds_without_logging_an_error(self) -> None: class Worker: @concurrent(wait=True) def work(self) -> None: - raise BackgroundWorkCancelled + raise BackgroundWorkCanceled with patch("sampletones_application.utils.parallelization.thread.logger") as logger: Worker().work() diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 453a2667c..b638a04e8 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -116,7 +116,7 @@ class TestPrimaryAction: (ConversionPhase.RUNNING, ConverterAction.CANCEL), (ConversionPhase.CANCELLING, ConverterAction.CANCEL), (ConversionPhase.COMPLETED, ConverterAction.CONVERT), - (ConversionPhase.CANCELLED, ConverterAction.CONVERT), + (ConversionPhase.CANCELED, ConverterAction.CONVERT), (ConversionPhase.FAILED, ConverterAction.CONVERT), ], ) @@ -141,7 +141,7 @@ def test_cancel_enablement(self, phase: ConversionPhase, enabled: bool) -> None: @pytest.mark.parametrize( "phase", - [ConversionPhase.COMPLETED, ConversionPhase.CANCELLED, ConversionPhase.FAILED], + [ConversionPhase.COMPLETED, ConversionPhase.CANCELED, ConversionPhase.FAILED], ) def test_convert_disabled_in_terminal_phases(self, phase: ConversionPhase) -> None: assert _view_model(phase=phase).primary_action_enabled is False diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py index 7772e1068..52ab51cb2 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_render.py +++ b/tests/unit/sampletones_application/view_model/shared/test_render.py @@ -154,5 +154,5 @@ def test_a_song_holding_no_rows_starts_no_render(self) -> None: assert not view.render_enabled def test_an_outcome_releases_the_application(self) -> None: - for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELLED, RenderPhase.FAILED): + for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELED, RenderPhase.FAILED): assert not view_model(wave_settings(), phase=phase).is_active diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index 722a259c6..eeb11889f 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -16,7 +16,7 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from tests.suite.progress import RecordingReporter @@ -205,7 +205,7 @@ def test_a_withdrawn_batch_leaves_the_slices_it_had_not_reached( build_instrument("lead", ENVELOPE_FRAMES), build_instrument("bass", ENVELOPE_FRAMES), ) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_sample(tmp_path / f"kit{EXT_FILE_INSTRUMENT}", sample, reporter) assert not (tmp_path / f"bass{EXT_FILE_INSTRUMENT}").exists() diff --git a/tests/unit/sampletones_core/exports/test_progress.py b/tests/unit/sampletones_core/exports/test_progress.py index a39838f64..a7ba6c1d0 100644 --- a/tests/unit/sampletones_core/exports/test_progress.py +++ b/tests/unit/sampletones_core/exports/test_progress.py @@ -8,7 +8,7 @@ announce, ) from sampletones_core.exports.stage import ExportStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.progress import FIRST_REPORT, RecordingReporter WRITTEN: Final[int] = 3 @@ -38,10 +38,10 @@ class TestWithdrawingARun: def test_a_withdrawn_run_unwinds_where_it_was_told(self) -> None: reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) def test_a_withdrawal_names_the_stage_it_landed_on(self) -> None: reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled, match=ExportStage.WRITING.value): + with pytest.raises(OperationCanceled, match=ExportStage.WRITING.value): announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py index dd75d8e9d..2be4f2a4b 100644 --- a/tests/unit/sampletones_core/performance/test_song.py +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -8,7 +8,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.timing import SongTiming -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.performance import ( make_pulse_reconstruction, place_instrument, @@ -115,5 +115,5 @@ def test_the_walk_counts_up_as_it_goes(self) -> None: assert counted == sorted(counted) def test_a_withdrawn_walk_stops_where_it_was_told(self) -> None: - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): song_instructions(_project(), lambda progress: False) diff --git a/tests/unit/sampletones_player/compression/progress/test_monitor.py b/tests/unit/sampletones_player/compression/progress/test_monitor.py index 10ab84170..0a95f470c 100644 --- a/tests/unit/sampletones_player/compression/progress/test_monitor.py +++ b/tests/unit/sampletones_player/compression/progress/test_monitor.py @@ -7,7 +7,7 @@ SILENT_REPORTER, CodecProgress, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.progress import FIRST_REPORT, RecordingReporter PHRASES_FOUND: Final[int] = 4 @@ -47,15 +47,15 @@ class TestWithdrawingARun: def test_a_withdrawn_reading_unwinds_the_run(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) def test_a_withdrawn_poll_unwinds_the_run(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): CodecMonitor(reporter).poll() def test_a_withdrawal_names_what_the_run_was_holding(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled, match=str(BYTES_LAID_DOWN)): + with pytest.raises(OperationCanceled, match=str(BYTES_LAID_DOWN)): CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index 3e1d8bc4d..2595c8a00 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -17,7 +17,7 @@ PHRASE_ID_ESCAPE, TokenTag, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.progress import FIRST_REPORT, RecordingReporter EVERY_LAYER: Final[CodecOptions] = CodecOptions( @@ -175,7 +175,7 @@ class TestWithdrawingAnEncoding: def test_a_withdrawn_run_unwinds(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): encode_planes( planes, (), @@ -187,7 +187,7 @@ def test_a_withdrawn_run_unwinds(self) -> None: def test_a_withdrawn_run_stops_where_it_was_told(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): encode_planes( planes, (), diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py index 6449987f3..3736dfb90 100644 --- a/tests/unit/sampletones_player/test_export.py +++ b/tests/unit/sampletones_player/test_export.py @@ -29,7 +29,7 @@ TITLE_OFFSET, ) from sampletones_player.specification.song import LOOP_TICK_OFFSET -from sampletones_shared.exceptions import OperationCancelled, SongTooLargeError +from sampletones_shared.exceptions import OperationCanceled, SongTooLargeError from sampletones_shared.paths.extensions import EXT_FILE_NSF from tests.suite.performance import ( make_pulse_reconstruction, @@ -341,7 +341,7 @@ def test_a_withdrawn_walk_leaves_no_file( ) -> None: destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_project(destination, ProjectExport(project=drum_project()), reporter) assert reporter.last.stage == ExportStage.WALKING @@ -394,7 +394,7 @@ def test_a_withdrawn_run_writes_nothing(self, backend: NSFBackend, tmp_path: Pat destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_sample(destination, request, reporter) assert not destination.exists() @@ -407,7 +407,7 @@ def test_a_run_withdrawn_mid_compression_writes_nothing( destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_COMPRESSING) request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_sample(destination, request, reporter) assert reporter.last.stage == ExportStage.COMPRESSING From aa77afbefed3ef69f7c28e71656b174847790f21 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 11:42:30 +0200 Subject: [PATCH 109/142] Renaming --- docs/formats/famitracker.md | 2 +- scripts/checks/palette_colors.py | 40 +++++++++---------- scripts/ci/checks/bundle.py | 2 +- .../coordinators/tabs/sequencer.py | 2 +- .../layout/tabs/sequencer/tracker/tracker.py | 2 +- .../playback/synthesizer/synthesizer.py | 2 +- .../ui/panels/dialogs/project_properties.py | 2 +- .../ui/panels/sequencer/rows.py | 2 +- .../ui/panels/sequencer/tracker.py | 2 +- .../view_model/shared/project_properties.py | 2 +- src/sampletones_config/README.md | 4 +- .../formats/bitphase/builder.py | 6 +-- src/sampletones_core/timing/__init__.py | 4 +- src/sampletones_core/timing/groove.py | 14 +++---- .../timing/{metre.py => meter.py} | 6 +-- src/sampletones_core/timing/rate.py | 2 +- src/sampletones_core/timing/song.py | 10 ++--- .../integration/bitphase/test_btp_pipeline.py | 4 +- .../sequencer/playback/test_synthesizer.py | 6 +-- .../sequencer/playback/test_tick_clock.py | 4 +- .../test_project_properties_history.py | 2 +- .../panels/dialogs/test_project_properties.py | 6 +-- .../ui/panels/sequencer/test_rows.py | 4 +- .../ui/panels/sequencer/test_tracker_rows.py | 4 +- .../sampletones_core/timing/test_groove.py | 14 +++---- .../timing/{test_metre.py => test_meter.py} | 18 ++++----- 26 files changed, 83 insertions(+), 83 deletions(-) rename src/sampletones_core/timing/{metre.py => meter.py} (96%) rename tests/unit/sampletones_core/timing/{test_metre.py => test_meter.py} (91%) diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 9740dceef..f14d7ce73 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -260,7 +260,7 @@ checklist. | Title / author | 32 bytes each | 64 characters | truncates to 32 bytes | | Comment | free text (COMMENTS block) | 65536 characters | carried in full | | Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings | -| DPCM samples | 64 | not modelled | always empty by design | +| DPCM samples | 64 | not modeled | always empty by design | The exporter also reserves a per-channel empty pattern index (`max used index + 1`) for order slots the song leaves unset; a channel that already fills indices up to diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index 002c9bf84..c3fa9e4cb 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """ -Checks that a colour stays a palette token until the moment it is drawn with. +Checks that a color stays a palette token until the moment it is drawn with. `BaseColor.rgba` answers with the palette active right now, so a consumer that holds the token follows a palette swap and one that stores the answer keeps the shade it read at construction. The check reports the three ways that contract is lost: an attribute assigned the -resolved value, a theme colour filled outside the palette bindings that record it, and a colour +resolved value, a theme color filled outside the palette bindings that record it, and a color written into the shipped configuration as a literal instead of a palette token. Usage: @@ -45,7 +45,7 @@ class ColorFinding(NamedTuple): - """One place a colour stops following the palette, and what to do about it.""" + """One place a color stops following the palette, and what to do about it.""" location: str message: str @@ -73,7 +73,7 @@ def _resolves_a_color(value: ast.expr) -> bool: def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: - """Every attribute a module assigns the resolved value of a palette colour. + """Every attribute a module assigns the resolved value of a palette color. Args: module: Module to read. @@ -91,13 +91,13 @@ def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: location=module.location(statement), message=( f"stores .{COLOR_PROPERTY}; hold the BaseColor and read " - f".{COLOR_PROPERTY} where the colour reaches DearPyGui" + f".{COLOR_PROPERTY} where the color reaches DearPyGui" ), ) def dpg_module_helper() -> Tuple[Path, str]: - """The module allowed to fill a theme colour, and the helper every other module calls. + """The module allowed to fill a theme color, and the helper every other module calls. Returns: Tuple[Path, str]: The resolved path of the bindings module, and the helper's name. @@ -113,7 +113,7 @@ def unregistered_theme_colors( bindings_module: Path, theme_color_helper: str, ) -> Iterator[ColorFinding]: - """Every theme colour a module fills without recording the token behind it. + """Every theme color a module fills without recording the token behind it. Args: module: Module to read. @@ -121,7 +121,7 @@ def unregistered_theme_colors( theme_color_helper: Name of the helper a report points at. Yields: - ColorFinding: One per call, naming the theme colour that stays at the shade it was + ColorFinding: One per call, naming the theme color that stays at the shade it was built with. """ if module.path.resolve() == bindings_module: @@ -131,12 +131,12 @@ def unregistered_theme_colors( if isinstance(node, ast.Call) and terminal_name(node.func) == THEME_COLOR_CALL: yield ColorFinding( location=module.location(node), - message=f"fills a theme colour directly; call {theme_color_helper} so a swap repaints it", + message=f"fills a theme color directly; call {theme_color_helper} so a swap repaints it", ) def literal_colors(path: Path) -> Iterator[ColorFinding]: - """Every hex colour a shipped configuration file writes out in place of a palette token. + """Every hex color a shipped configuration file writes out in place of a palette token. Args: path: Configuration file to read. @@ -151,7 +151,7 @@ def literal_colors(path: Path) -> Iterator[ColorFinding]: for match in HEX_COLOR.finditer(line): yield ColorFinding( location=f"{path}:{number}", - message=f"writes the colour {match.group()} directly; name a palette token instead", + message=f"writes the color {match.group()} directly; name a palette token instead", ) @@ -176,11 +176,11 @@ def find_detached_colors( def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: - """Every hex colour the shipped configuration writes out, outside the palettes that carry values. + """Every hex color the shipped configuration writes out, outside the palettes that carry values. Args: package: Configuration package to sweep. - palettes: Directory holding the palettes, where a colour value belongs. + palettes: Directory holding the palettes, where a color value belongs. Returns: List[ColorFinding]: One finding per literal, in file order. @@ -196,31 +196,31 @@ def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: def main(argv: Sequence[str]) -> int: - """Report every colour the application stores resolved or the configuration writes out.""" + """Report every color the application stores resolved or the configuration writes out.""" logger.set_level(level=logging.ERROR) bindings_module, theme_color_helper = dpg_module_helper() parser = argparse.ArgumentParser( - description="Check that a colour stays a palette token until it is drawn with.", + description="Check that a color stays a palette token until it is drawn with.", ) parser.add_argument( "--package", type=Path, default=APPLICATION_PACKAGE, - help="package whose colour reads to check", + help="package whose color reads to check", ) parser.add_argument( "--config", type=Path, default=CONFIG_DIRECTORY, - help="shipped configuration package whose colours must name palette tokens", + help="shipped configuration package whose colors must name palette tokens", ) parser.add_argument( "--palettes", type=Path, default=PALETTES_DIRECTORY, - help="directory holding the palettes, where colour values belong", + help="directory holding the palettes, where color values belong", ) arguments = parser.parse_args(list(argv)) @@ -240,14 +240,14 @@ def main(argv: Sequence[str]) -> int: return 0 print( - "Colour(s) that stop following the active palette:", + "Color(s) that stop following the active palette:", file=sys.stderr, ) for location, message in findings: print(f" {location}: {message}", file=sys.stderr) print( - f"\nFound {len(findings)} colour(s) detached from the palette.", + f"\nFound {len(findings)} color(s) detached from the palette.", file=sys.stderr, ) return 1 diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py index 08fd64dbb..acef01234 100644 --- a/scripts/ci/checks/bundle.py +++ b/scripts/ci/checks/bundle.py @@ -27,7 +27,7 @@ def launcher_path(bundle: Path, *, system: str) -> Path: def missing_notices(bundle: Path) -> List[str]: - """The licence and notice files a release bundle must ship that are absent from it.""" + """The license and notice files a release bundle must ship that are absent from it.""" return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()] diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index beb031410..acccc606f 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1017,7 +1017,7 @@ def _on_settings_changed( ) -> None: """Hands the project's song settings to the two panels that read them. - The module panel shows the timing fields themselves; the tracker reads the metre out of + The module panel shows the timing fields themselves; the tracker reads the meter out of the same view model, so a highlight edited in the project properties retints the grid as soon as the dialog commits. """ diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index 5ed92d673..46c6f3794 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -6,7 +6,7 @@ class TrackerLayout(BaseModel, extra="forbid", frozen=True): """The tracker's row counts, cell sizes and tint strengths. - The grouping the rows are tinted by is the project's own metre, read from its highlights, + The grouping the rows are tinted by is the project's own meter, read from its highlights, so this model carries the geometry alone. A row states its height rather than growing to the text in it, because the grid's tints are diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index a924b6326..5f2f51063 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -147,7 +147,7 @@ def _current_rates(self) -> EngineRates: ) def _ensure_groove(self, project: Project) -> None: - """Rebuilds the groove when the row rate or the metre it is spread over changes. + """Rebuilds the groove when the row rate or the meter it is spread over changes. An engine that holds a row for a whole number of ticks reaches a fractional row rate by varying that number from row to row, and the groove is where those counts are decided. diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index 1254a6666..1b8e9d188 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -44,7 +44,7 @@ class GUIProjectPropertiesWindow(GUIDialogWindow): - """Modal form to view and edit the project's title, author, comment, and metre. + """Modal form to view and edit the project's title, author, comment, and meter. Each appearance renders the view model handed to :meth:`open`, and the edited values reach the ``on_commit`` hook on confirmation, so the owner applies diff --git a/src/sampletones_application/ui/panels/sequencer/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py index 03e2fe1a8..a989d5967 100644 --- a/src/sampletones_application/ui/panels/sequencer/rows.py +++ b/src/sampletones_application/ui/panels/sequencer/rows.py @@ -22,7 +22,7 @@ def group_color( settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> Optional[BaseColor]: - """The emphasis a row takes from the group the project's metre opens on it. + """The emphasis a row takes from the group the project's meter opens on it. The second highlight marks the bar and the first the beat, so a row opening a bar takes the stronger of the two shades even where a beat opens there as well. A row inside a beat diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 7a035a9ee..bf87ae574 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -670,7 +670,7 @@ def repaint(self) -> None: self._update_cursor() def update_settings(self, view_model: SequencerSettingsViewModel) -> None: - """Takes the metre the project states, retinting the rows its highlights now open.""" + """Takes the meter the project states, retinting the rows its highlights now open.""" self._settings = view_model self._apply_row_backgrounds() diff --git a/src/sampletones_application/view_model/shared/project_properties.py b/src/sampletones_application/view_model/shared/project_properties.py index a223175fb..80b16403b 100644 --- a/src/sampletones_application/view_model/shared/project_properties.py +++ b/src/sampletones_application/view_model/shared/project_properties.py @@ -7,7 +7,7 @@ class ProjectPropertiesViewModel(BaseModel, frozen=True): - """The project info and metre the properties dialog renders and offers for editing.""" + """The project info and meter the properties dialog renders and offers for editing.""" title: str author: str diff --git a/src/sampletones_config/README.md b/src/sampletones_config/README.md index 37addfdcd..767fb77f6 100644 --- a/src/sampletones_config/README.md +++ b/src/sampletones_config/README.md @@ -24,8 +24,8 @@ The data package must not import a schema, and a schema package must not inline | `keybindings/` | The key combinations each named action answers | `ShortcutScheme` | | `lang/` | Interface strings (i18n) | `LanguageManager` | | `layout/` | UI geometry, dimensions, fonts | `LayoutConfig` | -| `palettes/` | The colour sets layout and theme resolve against | `Palette` | -| `theme/` | DearPyGui theme/colour styling | `ThemeSpec` | +| `palettes/` | The color sets layout and theme resolve against | `Palette` | +| `theme/` | DearPyGui theme/color styling | `ThemeSpec` | The rules for where a value belongs, how the directories nest, and how each domain is loaded are prescriptive and documented in diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index e039e7cf6..b4b4b5bd2 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -66,7 +66,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn -from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove +from sampletones_core.timing import Groove, Meter, RowRate, calculate_groove from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED PREVIEW_SPEED = DEFAULT_SPEED @@ -426,13 +426,13 @@ def _project_groove(project: Project) -> Groove: A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is carried by a groove: whole tick counts that vary from row to row and average out to the - rate, placed by the metre so the longer rows fall on the bar and the beat. The engine's + rate, placed by the meter so the longer rows fall on the bar and the beat. The engine's own speed range bounds them, and the groove's mean states the rate it reached. """ settings = project.settings return calculate_groove( RowRate.from_settings(settings), - Metre.from_settings(settings, rows=project.song.rows_per_pattern), + Meter.from_settings(settings, rows=project.song.rows_per_pattern), minimum_ticks=MIN_INITIAL_SPEED, maximum_ticks=MAX_INITIAL_SPEED, ) diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py index f705007c9..bd4a5a5b0 100644 --- a/src/sampletones_core/timing/__init__.py +++ b/src/sampletones_core/timing/__init__.py @@ -2,7 +2,7 @@ from .clock import TickClock from .distribution import distribute_by_halving, distribute_proportionally from .groove import Groove, calculate_groove -from .metre import Metre +from .meter import Meter from .rate import RowRate from .song import SongTiming @@ -10,7 +10,7 @@ "MAX_TICKS_PER_ROW", "MIN_TICKS_PER_ROW", "Groove", - "Metre", + "Meter", "RowRate", "SongTiming", "TickClock", diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py index 368ff36f7..541ff32bc 100644 --- a/src/sampletones_core/timing/groove.py +++ b/src/sampletones_core/timing/groove.py @@ -7,7 +7,7 @@ distribute_by_halving, distribute_proportionally, ) -from sampletones_core.timing.metre import Metre +from sampletones_core.timing.meter import Meter from sampletones_core.timing.rate import RowRate HALF: Final[Fraction] = Fraction(1, 2) @@ -19,7 +19,7 @@ class Groove: An engine that takes one speed value per row reaches a fractional row rate by varying that value from row to row, which is how a tempo its speed column alone cannot state - still comes out right on average. The variation is placed by metre, so the longer rows + still comes out right on average. The variation is placed by meter, so the longer rows land on the bar, then the beat, then the subdivisions inside a beat. Attributes: @@ -54,7 +54,7 @@ def _pattern_ticks( """Rounds a pattern's exact tick count to the nearest integer within the engine's speed range. Rounding once, on the pattern, is what makes the pattern's duration the closest the - engine reaches; the metre then decides which rows carry the difference. Bounding the + engine reaches; the meter then decides which rows carry the difference. Bounding the pattern total rather than each row keeps every row inside the range as a consequence, since a proportional split yields only the floor and the ceiling of the average. @@ -76,7 +76,7 @@ def _pattern_ticks( def calculate_groove( rate: RowRate, - metre: Metre, + meter: Meter, *, minimum_ticks: int, maximum_ticks: int, @@ -89,7 +89,7 @@ def calculate_groove( Args: rate: The exact ticks one row lasts. - metre: The pattern's length and its beat and bar grouping. + meter: The pattern's length and its beat and bar grouping. minimum_ticks: The fewest ticks the engine holds a row for. maximum_ticks: The most ticks the engine holds a row for. @@ -98,11 +98,11 @@ def calculate_groove( """ total = _pattern_ticks( rate, - metre.rows, + meter.rows, minimum_ticks=minimum_ticks, maximum_ticks=maximum_ticks, ) - bars = metre.spans + bars = meter.spans bar_lengths = tuple(sum(beats) for beats in bars) ticks: List[int] = [] diff --git a/src/sampletones_core/timing/metre.py b/src/sampletones_core/timing/meter.py similarity index 96% rename from src/sampletones_core/timing/metre.py rename to src/sampletones_core/timing/meter.py index 8b80eee91..b2e5a287c 100644 --- a/src/sampletones_core/timing/metre.py +++ b/src/sampletones_core/timing/meter.py @@ -7,7 +7,7 @@ @dataclass(frozen=True) -class Metre: +class Meter: """The row grouping a pattern is felt in: its length, its beat, and the bar above it. ``first_highlight`` is the beat, the unit an actual tempo is read from, and @@ -41,8 +41,8 @@ def __post_init__(self) -> None: raise ValueError(f"second_highlight must be at least 1, got {self.second_highlight}") @classmethod - def from_settings(cls, settings: ProjectSettings, *, rows: int) -> Metre: - """Reads the metre a project states, over a pattern of ``rows`` rows. + def from_settings(cls, settings: ProjectSettings, *, rows: int) -> Meter: + """Reads the meter a project states, over a pattern of ``rows`` rows. The project holds the two highlights while the song holds the pattern length, so the row count arrives beside the settings. diff --git a/src/sampletones_core/timing/rate.py b/src/sampletones_core/timing/rate.py index f02103858..a3c032418 100644 --- a/src/sampletones_core/timing/rate.py +++ b/src/sampletones_core/timing/rate.py @@ -21,7 +21,7 @@ class RowRate: The ratio is held exact, since a row rate is fractional for most tempi and the fraction is what a groove distributes across a pattern's rows. - A row rate reads as a tempo in beats per minute once a metre says how many rows one + A row rate reads as a tempo in beats per minute once a meter says how many rows one beat spans:: beats_per_minute = 60 * nes_frequency / (ticks_per_row * first_highlight) diff --git a/src/sampletones_core/timing/song.py b/src/sampletones_core/timing/song.py index 8579b52e2..fa3250c18 100644 --- a/src/sampletones_core/timing/song.py +++ b/src/sampletones_core/timing/song.py @@ -4,7 +4,7 @@ from sampletones_core.project import Project from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW from sampletones_core.timing.groove import Groove, calculate_groove -from sampletones_core.timing.metre import Metre +from sampletones_core.timing.meter import Meter from sampletones_core.timing.rate import RowRate @@ -14,18 +14,18 @@ class SongTiming: Attributes: rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. - metre: The pattern length and the beat and bar grouping the ticks are spread over. + meter: The pattern length and the beat and bar grouping the ticks are spread over. """ rate: RowRate - metre: Metre + meter: Meter @classmethod def from_project(cls, project: Project) -> Self: """Reads the timing a project plays at, taking the pattern length from its song.""" return cls( rate=RowRate.from_settings(project.settings), - metre=Metre.from_settings( + meter=Meter.from_settings( project.settings, rows=project.song.rows_per_pattern, ), @@ -40,7 +40,7 @@ def groove(self) -> Groove: """ return calculate_groove( self.rate, - self.metre, + self.meter, minimum_ticks=MIN_TICKS_PER_ROW, maximum_ticks=MAX_TICKS_PER_ROW, ) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index f1b8f7d41..85ad771ae 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -44,7 +44,7 @@ NoteName, ) from sampletones_core.project.project import Project -from sampletones_core.timing import Metre, RowRate, calculate_groove +from sampletones_core.timing import Meter, RowRate, calculate_groove from tests.suite.bitphase import ( BITPHASE_NO_EFFECTS, LoadedEffect, @@ -263,7 +263,7 @@ def test_the_table_holds_the_groove_the_project_plays( project = at_tempo(integration_project, GROOVE_TEMPO) groove = calculate_groove( RowRate.from_settings(project.settings), - Metre.from_settings(project.settings, rows=project.song.rows_per_pattern), + Meter.from_settings(project.settings, rows=project.song.rows_per_pattern), minimum_ticks=MIN_INITIAL_SPEED, maximum_ticks=MAX_INITIAL_SPEED, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 60b15f298..e0de7d133 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -17,7 +17,7 @@ from sampletones_core.timing import ( MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, - Metre, + Meter, RowRate, calculate_groove, ) @@ -98,7 +98,7 @@ def _groove_ticks(controller: ProjectController) -> Tuple[int, ...]: settings = controller.project.settings return calculate_groove( RowRate.from_settings(settings), - Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern), + Meter.from_settings(settings, rows=controller.project.song.rows_per_pattern), minimum_ticks=MIN_TICKS_PER_ROW, maximum_ticks=MAX_TICKS_PER_ROW, ).ticks @@ -723,7 +723,7 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None: class TestGroove: - def test_a_pattern_plays_the_groove_the_metre_yields( + def test_a_pattern_plays_the_groove_the_meter_yields( self, controller: ProjectController, synthesizer: RowSynthesizer, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index 50c237c17..10e0ad550 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -8,7 +8,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName -from sampletones_core.timing import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, Metre, RowRate, TickClock, calculate_groove +from sampletones_core.timing import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, Meter, RowRate, TickClock, calculate_groove from tests.suite.base import BaseTestSuite from tests.suite.performance import make_pulse_reconstruction from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( @@ -28,7 +28,7 @@ def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]: settings = controller.project.settings return calculate_groove( RowRate.from_settings(settings), - Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern), + Meter.from_settings(settings, rows=controller.project.song.rows_per_pattern), minimum_ticks=MIN_TICKS_PER_ROW, maximum_ticks=MAX_TICKS_PER_ROW, ).ticks diff --git a/tests/unit/sampletones_application/test_project_properties_history.py b/tests/unit/sampletones_application/test_project_properties_history.py index ce9ff781b..8d9ddf565 100644 --- a/tests/unit/sampletones_application/test_project_properties_history.py +++ b/tests/unit/sampletones_application/test_project_properties_history.py @@ -47,7 +47,7 @@ def test_changed_fields_group_into_one_entry(self) -> None: info = application.project_controller.project.info assert (info.title, info.author, info.comment) == ("Title", "Author", "Comment") - def test_the_metre_joins_the_same_entry_as_the_info(self) -> None: + def test_the_meter_joins_the_same_entry_as_the_info(self) -> None: """The highlights are project settings, and the dialog commits them beside the info.""" application = _application() diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py index 0dd8e62f5..0fb034636 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py @@ -85,7 +85,7 @@ def test_the_info_shows_the_project_s_own(self, window: GUIProjectPropertiesWind assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR) == "Composer" assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT) == "A note to self" - def test_the_metre_shows_the_project_s_highlights(self, window: GUIProjectPropertiesWindow) -> None: + def test_the_meter_shows_the_project_s_highlights(self, window: GUIProjectPropertiesWindow) -> None: render(window) assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT) == FIRST_HIGHLIGHT @@ -124,7 +124,7 @@ def committed_fixture(self, window: GUIProjectPropertiesWindow) -> List[Committe render(window) return committed - def test_the_edited_metre_reaches_the_owner( + def test_the_edited_meter_reaches_the_owner( self, window: GUIProjectPropertiesWindow, committed: List[Committed], @@ -149,7 +149,7 @@ def test_a_highlight_past_the_range_arrives_clamped( assert committed[-1][3:] == (MAX_HIGHLIGHT, MIN_HIGHLIGHT) - def test_the_metre_carries_the_info_with_it( + def test_the_meter_carries_the_info_with_it( self, window: GUIProjectPropertiesWindow, committed: List[Committed], diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py index 47cb8e2b8..6da0bbade 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py @@ -32,7 +32,7 @@ def _settings( first_highlight: int = BEAT_ROWS, second_highlight: int = BAR_ROWS, ) -> SequencerSettingsViewModel: - """The module settings the row tinting reads, carrying the metre under test.""" + """The module settings the row tinting reads, carrying the meter under test.""" return SequencerSettingsViewModel( nes_frequency=60, tempo=150, @@ -100,7 +100,7 @@ def test_the_bar_shade_outranks_the_beat_shade_where_they_meet( assert settings.second_highlight % settings.first_highlight == 0 assert group_color(settings.second_highlight, settings, colors) == colors.rows.bar - def test_a_metre_the_project_states_moves_the_shades( + def test_a_meter_the_project_states_moves_the_shades( self, colors: SequencerColors, ) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index 11372d787..26bc02145 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -78,7 +78,7 @@ def _settings( first_highlight: int = ROWS_PER_BEAT, second_highlight: int = ROWS_PER_BAR, ) -> SequencerSettingsViewModel: - """The module settings the panel reads its metre out of.""" + """The module settings the panel reads its meter out of.""" return SequencerSettingsViewModel( nes_frequency=60, tempo=150, @@ -190,7 +190,7 @@ def test_the_header_row_takes_no_row_background(self, recorder: _TableRecorder) assert HEADER_TABLE_ROW not in recorder.highlighted_rows assert HEADER_TABLE_ROW not in recorder.unhighlighted_rows - def test_an_edited_metre_retints_the_rows_at_once(self, recorder: _TableRecorder) -> None: + def test_an_edited_meter_retints_the_rows_at_once(self, recorder: _TableRecorder) -> None: """The highlights are the project's, so a change to them reaches the grid as a repaint.""" panel = _panel() panel._apply_row_backgrounds() diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py index 91de10bf0..5dc4840cf 100644 --- a/tests/unit/sampletones_core/timing/test_groove.py +++ b/tests/unit/sampletones_core/timing/test_groove.py @@ -7,7 +7,7 @@ import pytest from sampletones_core.timing.groove import Groove, calculate_groove -from sampletones_core.timing.metre import Metre +from sampletones_core.timing.meter import Meter from sampletones_core.timing.rate import RowRate from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO from tests.suite.base import BaseTestSuite @@ -43,8 +43,8 @@ def label(self) -> str: ) @property - def metre(self) -> Metre: - return Metre( + def meter(self) -> Meter: + return Meter( rows=self.rows, first_highlight=self.first_highlight, second_highlight=self.second_highlight, @@ -58,7 +58,7 @@ def groove(self) -> Groove: speed=self.speed, nes_frequency=self.nes_frequency, ), - self.metre, + self.meter, minimum_ticks=MINIMUM_TICKS, maximum_ticks=MAXIMUM_TICKS, ) @@ -775,7 +775,7 @@ def test_longer_rows_come_first(self, test_case: TestCase) -> None: def test_each_beat_opens_on_its_longest_row(self, test_case: TestCase) -> None: ticks = test_case.groove.ticks start = 0 - for beats in test_case.metre.spans: + for beats in test_case.meter.spans: for beat_rows in beats: beat = ticks[start : start + beat_rows] assert beat[0] == max(beat) @@ -794,7 +794,7 @@ def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None: speed=speed, nes_frequency=REFERENCE_NES_FREQUENCY, ), - Metre( + Meter( rows=rows, first_highlight=COMMON_TIME_BEAT, second_highlight=COMMON_TIME_BAR, @@ -817,7 +817,7 @@ def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) speed=REFERENCE_SPEED, nes_frequency=60, ), - Metre( + Meter( rows=rows, first_highlight=first_highlight, second_highlight=second_highlight, diff --git a/tests/unit/sampletones_core/timing/test_metre.py b/tests/unit/sampletones_core/timing/test_meter.py similarity index 91% rename from tests/unit/sampletones_core/timing/test_metre.py rename to tests/unit/sampletones_core/timing/test_meter.py index a9d7711ee..4b31d0eb4 100644 --- a/tests/unit/sampletones_core/timing/test_metre.py +++ b/tests/unit/sampletones_core/timing/test_meter.py @@ -4,7 +4,7 @@ import pytest from sampletones_core.project.settings import ProjectSettings -from sampletones_core.timing.metre import Metre +from sampletones_core.timing.meter import Meter from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -90,12 +90,12 @@ def label(self) -> str: ids=lambda test_case: test_case.label, ) def test_spans_match(self, test_case: TestCase) -> None: - metre = Metre( + meter = Meter( rows=test_case.rows, first_highlight=test_case.first_highlight, second_highlight=test_case.second_highlight, ) - assert metre.spans == test_case.expected + assert meter.spans == test_case.expected @pytest.mark.parametrize( "test_case", @@ -103,12 +103,12 @@ def test_spans_match(self, test_case: TestCase) -> None: ids=lambda test_case: test_case.label, ) def test_spans_cover_the_pattern(self, test_case: TestCase) -> None: - metre = Metre( + meter = Meter( rows=test_case.rows, first_highlight=test_case.first_highlight, second_highlight=test_case.second_highlight, ) - assert sum(sum(beats) for beats in metre.spans) == test_case.rows + assert sum(sum(beats) for beats in meter.spans) == test_case.rows class TestBounds(BaseTestSuite): @@ -144,18 +144,18 @@ def label(self) -> str: def test_field_below_one_is_rejected(self, test_case: TestCase) -> None: fields = {"rows": 16, "first_highlight": 4, "second_highlight": 16, test_case.field: 0} with pytest.raises(ValueError, match=test_case.expected): - Metre(**fields) + Meter(**fields) class TestProjectSettings: def test_settings_state_the_highlights(self) -> None: settings = ProjectSettings(first_highlight=3, second_highlight=12) - assert Metre.from_settings(settings, rows=24) == Metre( + assert Meter.from_settings(settings, rows=24) == Meter( rows=24, first_highlight=3, second_highlight=12, ) def test_the_default_settings_state_common_time(self) -> None: - metre = Metre.from_settings(ProjectSettings(), rows=16) - assert metre.spans == ((4, 4, 4, 4),) + meter = Meter.from_settings(ProjectSettings(), rows=16) + assert meter.spans == ((4, 4, 4, 4),) From 4c03f936627796057ac489a200f7adeba73c73b7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 12:05:48 +0200 Subject: [PATCH 110/142] Extracted: optional module availability into one helper --- conftest.py | 5 ++- .../utils/file_dialogs/selection.py | 8 ++--- .../utils/system/modules.py | 21 +++++++++++ .../utils/system/reveal/selection.py | 8 ++--- .../utils/file_dialogs/test_selection.py | 21 ++++------- .../utils/system/reveal/test_selection.py | 36 ++++++++++--------- 6 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 src/sampletones_shared/utils/system/modules.py diff --git a/conftest.py b/conftest.py index 273aab9ed..6b02405aa 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,7 @@ -import importlib.util from pathlib import Path from typing import Final, Optional, Tuple -JEEPNEY_MODULE: Final[str] = "jeepney" +from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, module_available JEEPNEY_PATHS: Final[Tuple[str, ...]] = ( "src/sampletones_application/utils/file_dialogs/backends/portal", @@ -12,7 +11,7 @@ "tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py", ) -JEEPNEY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None +JEEPNEY_INSTALLED: Final[bool] = module_available(JEEPNEY_MODULE) def pytest_ignore_collect(collection_path: Path) -> Optional[bool]: diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py index e5539a5bf..c05f1d446 100644 --- a/src/sampletones_application/utils/file_dialogs/selection.py +++ b/src/sampletones_application/utils/file_dialogs/selection.py @@ -1,4 +1,3 @@ -import importlib.util import os from typing import Final, Optional @@ -6,13 +5,12 @@ from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend from sampletones_application.utils.file_dialogs.protocol import FileDialogBackend from sampletones_shared.exceptions import FileDialogUnavailableError +from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, TKINTER_MODULE, module_available from sampletones_shared.utils.system.programs import locate_program from sampletones_shared.utils.system.system import System KDIALOG: Final[str] = "kdialog" ZENITY: Final[str] = "zenity" -TKINTER_MODULE: Final[str] = "tkinter" -JEEPNEY_MODULE: Final[str] = "jeepney" DESKTOP_ENVIRONMENT_VARIABLE: Final[str] = "XDG_CURRENT_DESKTOP" KDE_DESKTOP: Final[str] = "KDE" @@ -78,7 +76,7 @@ def _portal_backend() -> Optional[FileDialogBackend]: ``jeepney`` is declared for Linux alone, so its presence is probed before the portal module is imported, which leaves application startup on every other platform independent of it. """ - if importlib.util.find_spec(JEEPNEY_MODULE) is None: + if not module_available(JEEPNEY_MODULE): return None from sampletones_application.utils.file_dialogs.backends.portal.backend import portal_backend @@ -94,7 +92,7 @@ def _tkinter_backend() -> Optional[FileDialogBackend]: probed before the backend module is imported. Keeping the import inside this function leaves application startup independent of Tk. """ - if importlib.util.find_spec(TKINTER_MODULE) is None: + if not module_available(TKINTER_MODULE): return None from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend diff --git a/src/sampletones_shared/utils/system/modules.py b/src/sampletones_shared/utils/system/modules.py new file mode 100644 index 000000000..38ae8771c --- /dev/null +++ b/src/sampletones_shared/utils/system/modules.py @@ -0,0 +1,21 @@ +import importlib.util +from typing import Final + +JEEPNEY_MODULE: Final[str] = "jeepney" +TKINTER_MODULE: Final[str] = "tkinter" + + +def module_available(module: str) -> bool: + """Whether this interpreter imports a module, which is how an optional dependency is reached. + + A dependency declared for one platform, or shipped as a separate system package, is present on + some machines and absent on others. Probing the name keeps the import inside the branch that + needs it, so startup stands on what the machine actually carries. + + Args: + module: The module's importable name. + + Returns: + bool: ``True`` where this interpreter finds the module installed. + """ + return importlib.util.find_spec(module) is not None diff --git a/src/sampletones_shared/utils/system/reveal/selection.py b/src/sampletones_shared/utils/system/reveal/selection.py index 00f49721a..ed9abc224 100644 --- a/src/sampletones_shared/utils/system/reveal/selection.py +++ b/src/sampletones_shared/utils/system/reveal/selection.py @@ -1,15 +1,13 @@ -import importlib.util -from typing import Final, Sequence +from typing import Sequence from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, module_available from sampletones_shared.utils.system.paths import open_path_in_explorer, to_path from sampletones_shared.utils.system.system import System from .grouped import GroupedDirectoryBackend from .protocol import RevealBackend -JEEPNEY_MODULE: Final[str] = "jeepney" - def open_paths_in_explorer(paths: Sequence[Pathlike]) -> None: """ @@ -46,7 +44,7 @@ def select_reveal_backend() -> RevealBackend: directory holding the paths. The service is probed at selection time, so the grouped backend serves sessions where it is absent. """ - if System.current() == System.LINUX and importlib.util.find_spec(JEEPNEY_MODULE) is not None: + if System.current() == System.LINUX and module_available(JEEPNEY_MODULE): from .file_manager1 import FileManager1Backend if FileManager1Backend.answers(): diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py index e39ad0409..24eec0e81 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py @@ -39,9 +39,9 @@ def _portal(backend: Optional[PortalBackend]) -> AbstractContextManager[MagicMoc return patch(f"{PORTAL_MODULE}.portal_backend", return_value=backend) -def _find_spec(available: bool) -> Callable[[str], Optional[object]]: - def resolver(module: str) -> Optional[object]: - return object() if available else None +def _available(installed: bool) -> Callable[[str], bool]: + def resolver(module: str) -> bool: + return installed return resolver @@ -109,10 +109,7 @@ def test_linux_tools_win_over_missing_tkinter(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)), - patch( - f"{MODULE}.importlib.util.find_spec", - side_effect=_find_spec(available=False), - ), + patch(f"{MODULE}.module_available", side_effect=_available(installed=False)), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False), ): assert isinstance(select_file_dialog_backend(), KDialogBackend) @@ -124,10 +121,7 @@ def test_no_linux_tools_without_tkinter_raises(self) -> None: f"{MODULE}.locate_program", side_effect=_located(kdialog=False, zenity=False), ), - patch( - f"{MODULE}.importlib.util.find_spec", - side_effect=_find_spec(available=False), - ), + patch(f"{MODULE}.module_available", side_effect=_available(installed=False)), patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False), pytest.raises(FileDialogUnavailableError), ): @@ -136,10 +130,7 @@ def test_no_linux_tools_without_tkinter_raises(self) -> None: def test_windows_without_tkinter_raises(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.WINDOWS), - patch( - f"{MODULE}.importlib.util.find_spec", - side_effect=_find_spec(available=False), - ), + patch(f"{MODULE}.module_available", side_effect=_available(installed=False)), pytest.raises(FileDialogUnavailableError), ): select_file_dialog_backend() diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py b/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py index 38c623eca..5c8f3179d 100644 --- a/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py +++ b/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py @@ -1,10 +1,10 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Final from unittest.mock import patch import pytest -from sampletones_shared.utils.system.reveal.file_manager1 import FileManager1Backend +from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, module_available from sampletones_shared.utils.system.reveal.grouped import GroupedDirectoryBackend from sampletones_shared.utils.system.reveal.selection import ( open_paths_in_explorer, @@ -15,10 +15,17 @@ MODULE = "sampletones_shared.utils.system.reveal.selection" BACKEND_MODULE = "sampletones_shared.utils.system.reveal.file_manager1" +JEEPNEY_INSTALLED: Final[bool] = module_available(JEEPNEY_MODULE) + +requires_jeepney = pytest.mark.skipif( + not JEEPNEY_INSTALLED, + reason="The FileManager1 backend stands on jeepney, which ships on Linux", +) -def _find_spec(available: bool) -> Callable[[str], Optional[object]]: - def resolver(module: str) -> Optional[object]: - return object() if available else None + +def _available(installed: bool) -> Callable[[str], bool]: + def resolver(module: str) -> bool: + return installed return resolver @@ -54,31 +61,26 @@ def test_grouped_on_non_linux_systems(self) -> None: def test_grouped_when_jeepney_is_absent(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch( - f"{MODULE}.importlib.util.find_spec", - side_effect=_find_spec(available=False), - ), + patch(f"{MODULE}.module_available", side_effect=_available(installed=False)), ): assert isinstance(select_reveal_backend(), GroupedDirectoryBackend) + @requires_jeepney def test_file_manager1_when_the_service_answers(self) -> None: + from sampletones_shared.utils.system.reveal.file_manager1 import FileManager1Backend + with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch( - f"{MODULE}.importlib.util.find_spec", - side_effect=_find_spec(available=True), - ), + patch(f"{MODULE}.module_available", side_effect=_available(installed=True)), patch(f"{BACKEND_MODULE}.FileManager1Backend.answers", return_value=True), ): assert isinstance(select_reveal_backend(), FileManager1Backend) + @requires_jeepney def test_grouped_when_the_service_stays_silent(self) -> None: with ( patch(f"{MODULE}.System.current", return_value=System.LINUX), - patch( - f"{MODULE}.importlib.util.find_spec", - side_effect=_find_spec(available=True), - ), + patch(f"{MODULE}.module_available", side_effect=_available(installed=True)), patch(f"{BACKEND_MODULE}.FileManager1Backend.answers", return_value=False), ): assert isinstance(select_reveal_backend(), GroupedDirectoryBackend) From 32f00903fce4a6596240866c75eaa70d930f9840 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 13:02:54 +0200 Subject: [PATCH 111/142] Fixed: platform-dependent logging and path assertions --- tests/unit/sampletones_core/exporters/test_lengths.py | 11 ++++++----- .../meta/import_boundary/test_violation.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py index bbc067dd4..86973a2f5 100644 --- a/tests/unit/sampletones_core/exporters/test_lengths.py +++ b/tests/unit/sampletones_core/exporters/test_lengths.py @@ -4,6 +4,7 @@ import pytest from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths +from sampletones_shared.application import SAMPLETONES_NAME VOLUME: Final[str] = "volume" ARPEGGIO: Final[str] = "arpeggio" @@ -60,13 +61,13 @@ def test_an_over_long_envelope_keeps_its_opening_items(self) -> None: assert len(limited[ARPEGGIO]) == ITEM_LIMIT def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG): + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), limit=ITEM_LIMIT) assert str(ITEM_LIMIT) in caplog.text def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG): + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): limit_lengths(volume_and_arpeggio(ITEM_LIMIT), limit=ITEM_LIMIT) assert caplog.text == "" @@ -83,13 +84,13 @@ def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None assert len(equalized[ARPEGGIO]) == ITEM_LIMIT def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG): + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False, limit=ITEM_LIMIT) assert str(ITEM_LIMIT) in caplog.text def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG): + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): equalize_lengths(volume_and_arpeggio(ITEM_LIMIT), loop=False, limit=ITEM_LIMIT) assert caplog.text == "" @@ -105,7 +106,7 @@ def test_an_absent_limit_keeps_every_item(self) -> None: assert len(equalized[ARPEGGIO]) == length def test_an_absent_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG): + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False) assert caplog.text == "" diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py b/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py index c4a608627..af8e8a0f5 100644 --- a/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py +++ b/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py @@ -7,9 +7,10 @@ class TestViolationLocation: """Where a report sends a reader to see what a rule caught.""" def test_the_location_reads_as_path_and_line(self) -> None: + """The location states the module, the line number and the line, in the platform's own separator.""" violation = Violation.at("other_package", Path("package/logic/direct.py"), 2, "import other_package") - assert violation.location == "package/logic/direct.py:2: import other_package" + assert violation.location.replace("\\", "/") == "package/logic/direct.py:2: import other_package" def test_the_quoted_line_stands_clear_of_its_indentation(self) -> None: violation = Violation.at("other_package", Path("direct.py"), 1, " import other_package") From 5bbf717579a795277caa0c281bad87fbff9077c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 13:32:40 +0200 Subject: [PATCH 112/142] Added: separator between voice menu items --- src/sampletones_application/ui/menu.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 1ae4f4b1f..56eb3c446 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -8,7 +8,9 @@ ContextElements, MenuElements, ) -from sampletones_application.categories.elements.sequencer import SequencerVoicesElements +from sampletones_application.categories.elements.sequencer import ( + SequencerVoicesElements, +) from sampletones_application.categories.exports import ( EXPORT_PROJECT_MENU_LABELS, EXPORT_SAMPLE_MENU_LABELS, @@ -434,6 +436,7 @@ def _create_voice_menu(self, state: MenuBarViewModel) -> None: label=self._voices_label(SequencerVoicesElements.IMPORT_INSTRUMENT), enabled=state.project_open, ) + dpg.add_separator() self._shortcut_manager.add_menu_item( ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, tag=TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, From 1ab8b4923dba129135542919a4e63bfc7859a3c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 14:25:40 +0200 Subject: [PATCH 113/142] Added: conversion separator --- src/sampletones_application/ui/panels/main/converter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 8b5cdeb1c..97463f148 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -163,6 +163,7 @@ def create_panel(self, parent: str) -> None: self._create_controls() self._create_stems_list() self._create_summary() + dpg.add_separator() self._create_conversion_status() @property From 162e0d5ddd326efe029edc4a2dbba6cfbcd7c19a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 17:01:05 +0200 Subject: [PATCH 114/142] Paired: every envelope with the item it repeats from --- src/sampletones_application/application.py | 11 +- .../categories/elements/sequencer.py | 1 - .../categories/instrument.py | 1 - .../coordinators/reconstruction.py | 7 +- .../coordinators/tabs/reconstruction.py | 6 - .../logic/export/instrument/source.py | 1 - .../logic/history/fingerprint.py | 2 +- .../logic/project/controller.py | 30 ++- .../logic/reconstruction/editing.py | 19 +- .../logic/reconstruction/editor.py | 37 +--- .../logic/reconstruction/feature.py | 36 ++-- .../logic/reconstruction/instruments.py | 126 ++++------- .../logic/reconstruction/reconstruction.py | 6 +- .../logic/sequencer/voices.py | 30 +-- .../services/regeneration.py | 12 +- .../tags/reconstructions.py | 18 -- .../reconstruction/instruments/instruments.py | 92 +------- .../view_model/reconstruction/instruments.py | 14 +- .../view_model/reconstruction/update.py | 4 +- src/sampletones_config/lang/en.yaml | 3 - src/sampletones_core/exporters/exporter.py | 134 ++++++------ src/sampletones_core/exporters/feature.py | 176 +++++++++------- .../exporters/implementation/noise.py | 15 +- .../exporters/implementation/pulse.py | 15 +- .../exporters/implementation/triangle.py | 13 +- src/sampletones_core/exporters/lengths.py | 100 --------- .../exporters/slices/instrument.py | 6 +- .../exports/implementation/famitracker.py | 1 - src/sampletones_core/exports/request.py | 11 +- src/sampletones_core/features/envelope.py | 127 +++++++++++ .../formats/bitphase/builder.py | 2 - .../formats/bitphase/envelopes.py | 62 +++--- .../formats/bitphase/preset.py | 1 - .../formats/famitracker/builder.py | 14 +- .../formats/famitracker/footprint.py | 30 +-- .../formats/famitracker/sequences/features.py | 94 +++------ .../formats/famitracker/voice.py | 64 ++---- src/sampletones_core/performance/voice.py | 35 +++- .../project/voices/creation.py | 46 ++-- .../project/voices/envelopes.py | 29 +-- .../project/voices/instrument.py | 119 +++++------ src/sampletones_core/types/feature.py | 8 - src/sampletones_player/builder.py | 9 +- tests/integration/nsf/test_backend.py | 1 - .../services/test_export.py | 1 - .../services/test_regeneration.py | 65 +++--- tests/suite/player.py | 28 ++- .../coordinators/tabs/test_sequencer.py | 6 +- .../logic/export/instrument/test_logic.py | 3 +- .../logic/project/test_controller.py | 15 +- .../logic/reconstruction/test_editor.py | 24 ++- .../logic/reconstruction/test_feature.py | 2 +- .../logic/reconstruction/test_instruments.py | 40 ++-- .../reconstruction/test_reconstruction.py | 2 +- .../logic/sequencer/test_voices.py | 37 ++-- .../sequencer/tracker/test_pitch_faces.py | 3 +- .../sequencer/tracker/test_write_note.py | 3 +- .../services/export/test_service.py | 6 +- .../services/test_regeneration.py | 131 ++++-------- .../exporters/implementation/test_noise.py | 34 +-- .../exporters/implementation/test_pulse.py | 37 ++-- .../exporters/implementation/test_triangle.py | 31 +-- .../exporters/test_exporter.py | 47 +++-- .../exporters/test_feature.py | 22 +- .../exporters/test_lengths.py | 112 ---------- .../sampletones_core/exporters/test_slices.py | 5 +- .../sampletones_core/exports/test_bitphase.py | 6 +- .../exports/test_famitracker.py | 6 +- .../sampletones_core/features/__init__.py | 0 .../features/test_envelope.py | 133 ++++++++++++ .../formats/bitphase/conftest.py | 25 ++- .../formats/bitphase/test_envelopes.py | 37 +--- .../bitphase/test_instrument_document.py | 8 +- .../famitracker/sequences/test_features.py | 197 ++++++------------ .../formats/famitracker/test_footprint.py | 59 +++--- .../formats/famitracker/test_fti.py | 25 ++- .../famitracker/test_instrument_module.py | 37 ++-- .../formats/famitracker/test_voice.py | 120 ++++++----- .../performance/test_instrument_walk.py | 31 ++- .../project/test_container.py | 20 +- .../project/voices/test_creation.py | 53 ++--- .../project/voices/test_instrument.py | 32 +-- .../reconstruction/test_reconstruction.py | 12 +- .../test_instrument_song.py | 5 +- 84 files changed, 1339 insertions(+), 1689 deletions(-) delete mode 100644 src/sampletones_core/exporters/lengths.py create mode 100644 src/sampletones_core/features/envelope.py delete mode 100644 src/sampletones_core/types/feature.py delete mode 100644 tests/unit/sampletones_core/exporters/test_lengths.py create mode 100644 tests/unit/sampletones_core/features/__init__.py create mode 100644 tests/unit/sampletones_core/features/test_envelope.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ee2c1059e..c03a15b60 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -166,7 +166,6 @@ from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode -from sampletones_core.types.feature import FeatureValue from sampletones_shared.application import ( SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, @@ -1091,16 +1090,10 @@ def _rebind_replaced_sample( def _regenerate_instrument( self, channel_name: ChannelName, - features: Features, feature_key: FeatureKey, - feature_value: FeatureValue, + features: Features, ) -> None: - self._reconstruction_coordinator.regenerate_instrument( - channel_name, - features, - feature_key, - feature_value, - ) + self._reconstruction_coordinator.regenerate_instrument(channel_name, feature_key, features) def _on_reconstruction_updated( self, diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 07cb43b4e..98ed3d0b4 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -93,7 +93,6 @@ class SequencerVoicesElements(AbstractElement): OMISSION_HI_PITCH = "omission_hi_pitch" OMISSION_RELEASE_POINT = "omission_release_point" OMISSION_ARPEGGIO_MODE = "omission_arpeggio_mode" - OMISSION_SEQUENCE_LOOP_POINTS = "omission_sequence_loop_points" class SequencerHistoryElements(AbstractElement): diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py index 9de28e4d8..a125d3253 100644 --- a/src/sampletones_application/categories/instrument.py +++ b/src/sampletones_application/categories/instrument.py @@ -13,7 +13,6 @@ InstrumentOmission.HI_PITCH: SequencerVoicesElements.OMISSION_HI_PITCH, InstrumentOmission.RELEASE_POINT: SequencerVoicesElements.OMISSION_RELEASE_POINT, InstrumentOmission.ARPEGGIO_MODE: SequencerVoicesElements.OMISSION_ARPEGGIO_MODE, - InstrumentOmission.SEQUENCE_LOOP_POINTS: SequencerVoicesElements.OMISSION_SEQUENCE_LOOP_POINTS, } OMISSION_BULLET: Final[str] = " - " diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index bde8f0b94..e3e003fe7 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -34,7 +34,6 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features -from sampletones_core.types.feature import FeatureValue from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @@ -271,9 +270,8 @@ def _close(self) -> None: def regenerate_instrument( self, channel_name: ChannelName, - features: Features, feature_key: FeatureKey, - data: FeatureValue, + features: Features, ) -> None: reconstruction_data = self._reconstruction_manager.current_reconstruction if reconstruction_data is None: @@ -282,9 +280,8 @@ def regenerate_instrument( accepted = self._regeneration_service.start( reconstruction_data.reconstruction, channel_name, - features, feature_key, - data, + features, ) if accepted: self._set_reconstruction_dimmed(True) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index a4fc59e95..e78b5ede9 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -298,12 +298,6 @@ def __init__( self._reconstruction_instruments_panel.on_raw_data_changed = ( self._reconstruction_instruments_logic.handle_raw_data_changed ) - self._reconstruction_instruments_panel.on_instrument_root_period_changed = ( - self._reconstruction_instruments_logic.handle_instrument_root_period_changed - ) - self._reconstruction_instruments_panel.on_instrument_loop_point_changed = ( - self._reconstruction_instruments_logic.handle_instrument_loop_point_changed - ) def _on_export_result(self, result: ExportResult) -> None: """Reports a finished export in the words of the artefact it produced. diff --git a/src/sampletones_application/logic/export/instrument/source.py b/src/sampletones_application/logic/export/instrument/source.py index 9fa337888..5c710914b 100644 --- a/src/sampletones_application/logic/export/instrument/source.py +++ b/src/sampletones_application/logic/export/instrument/source.py @@ -87,7 +87,6 @@ def instrument_source( return InstrumentSource( channel=sounding_channel(entry), features=entry.features, - loop_point=entry.loop_point, nes_frequency=project.settings.nes_frequency, tuning=tuning_from_project(project), ) diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index b1a12c2b5..7e4b24d6b 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -31,9 +31,9 @@ def fingerprint_project( for voice in project.voices: parts.append(voice.id) parts.append(voice.name) - parts.append(str(voice.loop_point)) match voice: case Sample(): + parts.append(str(voice.loop_point)) parts.append(reconstruction_hash(voice.reconstruction)) case Instrument(): parts.append(voice.model_dump_json()) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 42fb68d93..91c180bb6 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -1,10 +1,11 @@ from contextlib import contextmanager from pathlib import Path -from typing import Iterator, Optional, Tuple +from typing import Iterator, Optional from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE from sampletones_core.exports.request import ProjectExport +from sampletones_core.features.envelope import Envelope from sampletones_core.project import Project from sampletones_core.project.patterns.row import NoteCommand, Row from sampletones_core.project.song import Song @@ -218,7 +219,7 @@ def set_instrument_envelope( self, voice_id: str, feature_key: FeatureKey, - items: Tuple[int, ...], + envelope: Envelope[int], ) -> None: """Writes one dimension of an instrument's envelopes, emptying it to leave it to the channel. @@ -226,7 +227,7 @@ def set_instrument_envelope( TypeError: If ``voice_id`` names a voice that writes no envelopes of its own. """ instrument = self._instrument(voice_id) - instrument.envelopes = instrument.envelopes.with_envelope(feature_key, items) + instrument.envelopes = instrument.envelopes.with_envelope(feature_key, envelope) instrument.invalidate() self._touch() self._announce(self.on_voices_changed) @@ -239,14 +240,14 @@ def set_instrument_root( pitch: int, period: int, ) -> None: - """Moves the roots an instrument's arpeggio is measured against, on the tonal channels and on noise. + """Moves the pitch an instrument's arpeggio is measured against, on the tonal channels and on noise. Raises: - TypeError: If ``voice_id`` names a voice that states no root of its own. + TypeError: If ``voice_id`` names a voice that states no pitch of its own. """ instrument = self._instrument(voice_id) - instrument.root_pitch = pitch - instrument.root_period = period + instrument.initial_pitch = pitch + instrument.initial_period = period instrument.invalidate() self._touch() self._announce(self.on_voices_changed) @@ -259,6 +260,13 @@ def _instrument(self, voice_id: str) -> Instrument: return voice + def _sample(self, voice_id: str) -> Sample: + voice = self.project.voices[voice_id] + if not isinstance(voice, Sample): + raise TypeError(f"Voice '{voice_id}' is no sample") + + return voice + def replace_sample_reconstruction(self, voice_id: str, reconstruction: Reconstruction) -> None: """Substitutes a sample's reconstruction, detaching its local source-audio origin. @@ -283,8 +291,12 @@ def rename_voice(self, voice_id: str, name: str) -> None: self._announce(self.on_song_changed) def set_voice_loop_point(self, voice_id: str, loop_point: Optional[int]) -> None: - """Sets the tick a voice's instructions repeat from, or ``None`` where it plays once.""" - self.project.voices[voice_id].loop_point = loop_point + """Sets the tick a recording's instructions repeat from, or ``None`` where it plays once. + + Raises: + TypeError: If ``voice_id`` names a voice that is no recording. + """ + self._sample(voice_id).loop_point = loop_point self._touch() self._announce(self.on_voices_changed) diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py index f31c8d628..8c87503b7 100644 --- a/src/sampletones_application/logic/reconstruction/editing.py +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -3,7 +3,7 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features -from sampletones_core.types.feature import FeatureValue +from sampletones_core.features.envelope import Envelope @dataclass(frozen=True) @@ -15,23 +15,21 @@ class ReconstructionEdit: @dataclass(frozen=True) class InstrumentEdit: - """The one envelope set an instrument carries, with the roots and the loop point it states. + """The one envelope set an instrument carries, with the pitch its arpeggio is measured from. Attributes: voice_id: The instrument an edit is written back into. name: The name the panel titles it by. features: The envelopes, read as the channel offering every dimension an instrument writes. - root_pitch: The note the tonal channels measure the arpeggio against. - root_period: The period the noise channel measures the arpeggio against. - loop_point: The tick the envelopes repeat from, or ``None`` where they play once. + initial_pitch: The note the tonal channels measure the arpeggio against. + initial_period: The period the noise channel measures the arpeggio against. """ voice_id: str name: str features: Features - root_pitch: int - root_period: int - loop_point: Optional[int] + initial_pitch: int + initial_period: int EditedVoice = Union[ReconstructionEdit, InstrumentEdit] @@ -49,11 +47,8 @@ class InstrumentEditingProtocol(Protocol): def edited_instrument(self) -> Optional[EditedVoice]: """What the panel is editing, or ``None`` while it holds nothing.""" - def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: + def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: """Writes one dimension of the instrument in front of the panel.""" def write_roots(self, *, pitch: int, period: int) -> None: """Moves the roots the instrument in front of the panel is measured against.""" - - def write_loop_point(self, loop_point: Optional[int]) -> None: - """Sets the tick the instrument in front of the panel repeats from.""" diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index 94f38d0f9..3f1c77367 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Optional from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.editing import ( @@ -8,8 +8,8 @@ ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.types.feature import FeatureValue class InstrumentEditor: @@ -55,15 +55,14 @@ def edited_instrument(self) -> Optional[EditedVoice]: voice_id=instrument.id, name=instrument.name, features=instrument.instrument_features(), - root_pitch=instrument.root_pitch, - root_period=instrument.root_period, - loop_point=instrument.loop_point, + initial_pitch=instrument.initial_pitch, + initial_period=instrument.initial_period, ) feature_data = self._reconstruction_manager.current_features return None if feature_data is None else ReconstructionEdit(channels=feature_data.channels) - def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: + def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: """Writes one dimension of the instrument in front of the tab. Raises: @@ -73,36 +72,16 @@ def write_envelope(self, feature_key: FeatureKey, data: FeatureValue) -> None: if instrument is None: raise TypeError("The tab holds no instrument to write an envelope into") - self._controller.set_instrument_envelope(instrument.id, feature_key, _items(data)) + self._controller.set_instrument_envelope(instrument.id, feature_key, envelope) def write_roots(self, *, pitch: int, period: int) -> None: - """Moves the roots the instrument in front of the tab is measured against. + """Moves the pitch the instrument in front of the tab is measured against. Raises: TypeError: If the tab holds no instrument to write into. """ instrument = self.instrument if instrument is None: - raise TypeError("The tab holds no instrument to move the roots of") + raise TypeError("The tab holds no instrument to move the pitch of") self._controller.set_instrument_root(instrument.id, pitch=pitch, period=period) - - def write_loop_point(self, loop_point: Optional[int]) -> None: - """Sets the tick the instrument in front of the tab repeats from. - - Raises: - TypeError: If the tab holds no instrument to write into. - """ - instrument = self.instrument - if instrument is None: - raise TypeError("The tab holds no instrument to set a loop point on") - - self._controller.set_voice_loop_point(instrument.id, loop_point) - - -def _items(data: FeatureValue) -> Tuple[int, ...]: - """The items an envelope edit carries, as the plain tuple an instrument stores.""" - if isinstance(data, int): - return (data,) - - return tuple(int(value) for value in data) diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py index 08d35bd8d..8497fa32b 100644 --- a/src/sampletones_application/logic/reconstruction/feature.py +++ b/src/sampletones_application/logic/reconstruction/feature.py @@ -1,11 +1,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, Optional, cast +from typing import Dict -import numpy as np - -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features from sampletones_core.reconstructions import Reconstruction @@ -25,20 +23,16 @@ def __getitem__(self, channel_name: ChannelName) -> Features: @classmethod def load(cls, reconstruction: Reconstruction) -> FeatureData: - exported_features = reconstruction.export() - - channels = {} - for generator_name_str, features in exported_features.items(): - channel_name = ChannelName(generator_name_str) - feature = Features( - initial_pitch=cast(int, features.get(FeatureKey.INITIAL_PITCH)), - volume=cast(np.ndarray, features.get(FeatureKey.VOLUME)), - arpeggio=cast(np.ndarray, features.get(FeatureKey.ARPEGGIO)), - pitch=cast(Optional[np.ndarray], features.get(FeatureKey.PITCH)), - hi_pitch=cast(Optional[np.ndarray], features.get(FeatureKey.HI_PITCH)), - duty_cycle=cast(Optional[np.ndarray], features.get(FeatureKey.DUTY_CYCLE)), - ) - - channels[channel_name] = feature - - return cls(channels=channels) + """The envelopes each of a reconstruction's channels plays, keyed by channel. + + Args: + reconstruction: The reconstruction being read. + + Returns: + FeatureData: One entry per channel the reconstruction exports. + """ + return cls( + channels={ + ChannelName(generator_name): features for generator_name, features in reconstruction.export().items() + } + ) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 48ada58a1..0b4088bff 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -22,12 +22,12 @@ from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features, playing_channels +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import features_footprint -from sampletones_core.types.feature import FeatureValue from sampletones_shared.utils.callbacks import CallbackMixin OnReconstructionInstrumentUpdatedCallback = Callable[ - [ChannelName, Features, FeatureKey, FeatureValue], + [ChannelName, FeatureKey, Features], None, ] @@ -135,18 +135,8 @@ def _instrument_view_model( return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, playing_channels=playing_channels(self._instrument_channels(instrument)), - footprint=SampleFootprintViewModel.from_instrument( - features_footprint( - instrument.features, - loop_point=instrument.loop_point, - ) - ), - instrument=InstrumentViewModel( - name=instrument.name, - root_pitch=instrument.root_pitch, - root_period=instrument.root_period, - loop_point=instrument.loop_point, - ), + footprint=SampleFootprintViewModel.from_instrument(features_footprint(instrument.features)), + instrument=InstrumentViewModel(name=instrument.name), ) def _build_footprint( @@ -155,14 +145,13 @@ def _build_footprint( ) -> SampleFootprintViewModel: """Measures each playing channel's instrument as the size its own export writes. - A reconstruction has no loop point of its own — that belongs to a voice placed in a - project — so each instrument is measured playing its envelopes once, matching what + Each instrument is measured at the lengths its own envelopes state, matching what **Export instrument...** produces. A channel standing by is written nowhere, so it is measured nowhere and the sample's total names what the export costs. """ return SampleFootprintViewModel.from_footprints( { - channel_name: features_footprint(features, loop_point=None) + channel_name: features_footprint(features) for channel_name, features in channels.items() if features.has_frames } @@ -177,16 +166,17 @@ def handle_pitch_value_changed( if instrument is not None: self._editor.write_roots( pitch=value, - period=instrument.root_period, + period=instrument.initial_period, ) self.update_display() return + features = self._get_features(channel_name) self._schedule_reconstruction_update( ReconstructionUpdate( channel_name, FeatureKey.INITIAL_PITCH, - value, + features.model_copy(update={"initial_pitch": value}), ) ) @@ -196,17 +186,13 @@ def handle_bar_point_clicked( feature_key: FeatureKey, data: np.ndarray, ) -> None: - if self._write_instrument_envelope(feature_key, data): + envelope = self._edited_envelope(channel_name, feature_key, data) + if self._write_instrument_envelope(feature_key, envelope): return - self._report_edited_size(channel_name, feature_key, data) - self._schedule_reconstruction_update( - ReconstructionUpdate( - channel_name, - feature_key, - data, - ) - ) + features = self._get_features(channel_name).with_envelope(feature_key, envelope) + self._report_edited_size(channel_name, features) + self._schedule_reconstruction_update(ReconstructionUpdate(channel_name, feature_key, features)) def handle_raw_data_changed( self, @@ -214,42 +200,34 @@ def handle_raw_data_changed( feature_key: FeatureKey, data: np.ndarray, ) -> None: - if self._write_instrument_envelope(feature_key, data): + envelope = self._edited_envelope(channel_name, feature_key, data) + if self._write_instrument_envelope(feature_key, envelope): return - self._report_edited_size(channel_name, feature_key, data) - self._schedule_reconstruction_update( - ReconstructionUpdate( - channel_name, - feature_key, - data, - ) - ) + features = self._get_features(channel_name).with_envelope(feature_key, envelope) + self._report_edited_size(channel_name, features) + self._schedule_reconstruction_update(ReconstructionUpdate(channel_name, feature_key, features)) - def handle_instrument_root_period_changed(self, value: int) -> None: - """Moves the period the instrument in front of the panel rests at on the noise channel.""" - instrument = self.instrument_edit - if instrument is None: - return - - self._editor.write_roots(pitch=instrument.root_pitch, period=value) - self.update_display() - - def handle_instrument_loop_point_changed( + def _edited_envelope( self, - loop_point: Optional[int], - ) -> None: - """Sets the tick the instrument in front of the panel repeats from.""" - if self.instrument_edit is None: - return - - self._editor.write_loop_point(loop_point) - self.update_display() + channel_name: ChannelName, + feature_key: FeatureKey, + data: np.ndarray, + ) -> Envelope[int]: + """The dimension as the edit leaves it, repeating from the point it already held.""" + items = tuple(int(value) for value in data) + instrument = self.instrument_edit + standing = ( + instrument.features.envelopes.get(feature_key) + if instrument is not None + else self._get_features(channel_name).envelopes.get(feature_key) + ) + return standing.with_items(items) if standing is not None else Envelope[int](items=items) def _write_instrument_envelope( self, feature_key: FeatureKey, - data: np.ndarray, + envelope: Envelope[int], ) -> bool: """Writes one dimension of the instrument in front of the panel, reporting whether it did. @@ -259,15 +237,14 @@ def _write_instrument_envelope( if self.instrument_edit is None: return False - self._editor.write_envelope(feature_key, data) + self._editor.write_envelope(feature_key, envelope) self.update_display() return True def _report_edited_size( self, channel_name: ChannelName, - feature_key: FeatureKey, - data: np.ndarray, + features: Features, ) -> None: """Reports what the edited envelope costs as the edit arrives, ahead of its regeneration. @@ -281,28 +258,9 @@ def _report_edited_size( self.call( self.on_view_changed, - self._build_view_model( - self._with_edit( - channels, - channel_name, - feature_key, - data, - ) - ), + self._build_view_model({**channels, channel_name: features}), ) - def _with_edit( - self, - channels: Dict[ChannelName, Features], - channel_name: ChannelName, - feature_key: FeatureKey, - data: np.ndarray, - ) -> Dict[ChannelName, Features]: - """The loaded channels with one envelope replaced, leaving the loaded ones as they are.""" - edited = channels[channel_name].model_copy(deep=True) - edited[feature_key] = data - return {**channels, channel_name: edited} - def _schedule_reconstruction_update( self, update: ReconstructionUpdate, @@ -325,15 +283,9 @@ def _on_reconstruction_update_scheduled(self) -> None: if self._pending_reconstruction_update is None: return - channel_name, feature_key, data = self._pending_reconstruction_update + channel_name, feature_key, features = self._pending_reconstruction_update self._pending_reconstruction_update = None - self.call( - self.on_reconstruction_instrument_updated, - channel_name, - self._get_features(channel_name), - feature_key, - data, - ) + self.call(self.on_reconstruction_instrument_updated, channel_name, feature_key, features) def _get_features(self, channel_name: ChannelName) -> Features: channels = self._current_generators() diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 7a4731269..910b23939 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -420,7 +420,6 @@ def exportable_instrument( source=InstrumentSource( channel=channel_name, features=reconstruction_data.feature_data[channel_name], - loop_point=None, nes_frequency=self._nes_frequency(), tuning=self._tuning(), ), @@ -517,14 +516,13 @@ def _instrument_export( ) -> InstrumentExport: """Packages one channel slice under ``name`` for an export backend. - A reconstruction has no loop flag of its own — that belongs to a sample placed in - a project — so the instrument plays its envelopes once. + A reconstruction's envelopes state no repeat of their own, so each dimension holds its + final value once it runs out and the trailing silence releases the note. """ return InstrumentExport( name=name, channel=channel_name, features=feature, - loop_point=None, nes_frequency=self._nes_frequency(), tuning=self._tuning(), ) diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index a87987d1a..748302885 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -38,6 +38,7 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import display_voice from sampletones_shared.exceptions import PlaybackError @@ -85,7 +86,7 @@ def build_voices(self) -> SequencerVoicesViewModel: voice_id=voice.id, name=voice.name, kind=voice_kind(voice), - loop=voice.loops, + loop=_loops(voice), ) for voice in self._controller.project.voices ) @@ -172,7 +173,6 @@ def instrument_from_channel( voice_slice.instrument_name, voice_slice.features, channel_name, - loop_point=voice_slice.voice.loop_point, ) def _channel_slices(self, voice_id: str) -> Tuple[VoiceSlice, ...]: @@ -210,19 +210,9 @@ def build_voice_footprint( """ match self._controller.project.voices.get(voice_id): case Sample() as sample: - return SampleFootprintViewModel.from_footprints( - reconstruction_footprints( - sample.reconstruction, - loop_point=sample.loop_point, - ) - ) + return SampleFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) case Instrument() as instrument: - return SampleFootprintViewModel.from_instrument( - features_footprint( - instrument.instrument_features(), - loop_point=instrument.loop_point, - ) - ) + return SampleFootprintViewModel.from_instrument(features_footprint(instrument.instrument_features())) case _: return None @@ -359,3 +349,15 @@ def _play_voice( f"Failed to preview sample: {voice_id}", ) self.call(self.on_autoplay_error, exception) + + +def _loops(voice: VoiceUnion) -> bool: + """Whether the voice list marks this voice as repeating. + + A recording states one point for the whole of it, while a hand-written voice repeats wherever + any of its dimensions circles. + """ + if isinstance(voice, Sample): + return voice.loops + + return any(envelope.loops for envelope in voice.envelopes.envelope_map.values()) diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 1909be867..c13e185c1 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -15,7 +15,6 @@ from sampletones_core.generators import GeneratorUnion from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction -from sampletones_core.types.feature import FeatureValue @dataclass(frozen=True) @@ -57,9 +56,8 @@ def start( self, reconstruction: Reconstruction, channel_name: ChannelName, - features: Features, feature_key: FeatureKey, - value: FeatureValue, + features: Features, ) -> bool: if self._canceled: return False @@ -68,9 +66,8 @@ def start( lambda: self._run( reconstruction, channel_name, - features, feature_key, - value, + features, ) ) @@ -84,9 +81,8 @@ def _run( self, reconstruction: Reconstruction, channel_name: ChannelName, - features: Features, feature_key: FeatureKey, - value: FeatureValue, + features: Features, ) -> None: if self._canceled: self._emit(ServiceCanceled()) @@ -94,8 +90,6 @@ def _run( try: exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] generator_class = exporter_class.get_generator_type() - features[feature_key] = value - instructions = cast( List[InstructionUnion], exporter_class.from_features(features), diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 0453008ba..d654aed38 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -158,24 +158,6 @@ Widget.BUTTON, "export_instrument", ) -TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_FIELDS = TagName( - Page.RECONSTRUCTIONS, - Panel.INSTRUMENTS, - Widget.GROUP, - "fields", -) -TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS = TagName( - Page.RECONSTRUCTIONS, - Panel.INSTRUMENTS, - Widget.CHECKBOX, - "loops", -) -TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT = TagName( - Page.RECONSTRUCTIONS, - Panel.INSTRUMENTS, - Widget.INPUT, - "loop_point", -) TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE = TagName( Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 0bfe86222..536ac1ed2 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Any, Callable, Dict, List, Optional, Tuple, cast +from typing import Any, Callable, Dict, List, Optional, Tuple import dearpygui.dearpygui as dpg import numpy as np @@ -35,9 +35,6 @@ SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, - TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS, - TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_FIELDS, - TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE, @@ -69,7 +66,6 @@ from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.instruments import ( - InstrumentViewModel, ReconstructionInstrumentsViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel @@ -81,7 +77,6 @@ from sampletones_core.exporters import Features from sampletones_core.features import ( CHANNEL_GENERATOR_KIND, - RESTING_REFERENCE_PERIOD, resting_reference, supported_features, ) @@ -119,7 +114,6 @@ def __init__( self.channel_plots: Dict[ChannelName, Dict[FeatureKey, GUIBarGraph]] = {} self._pitch_steppers: Dict[ChannelName, GUIPitchStepper] = {} - self._instrument_root_period: Optional[GUIPitchStepper] = None self._export_buttons: Dict[ChannelName, GUIButton] = {} self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR @@ -127,9 +121,6 @@ def __init__( self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY) self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) - self.instrument_fields_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_GROUP_FIELDS - self.instrument_loops_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_CHECKBOX_LOOPS - self.instrument_loop_point_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_INPUT_LOOP_POINT self._graphs: Dict[str, GUIBarGraph] = {} self._sequence_lengths: Dict[Tuple[ChannelName, FeatureKey], int] = {} @@ -151,8 +142,6 @@ def __init__( self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None self.on_bar_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None self.on_raw_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None - self.on_instrument_root_period_changed: Optional[Callable[[int], None]] = None - self.on_instrument_loop_point_changed: Optional[Callable[[Optional[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) @@ -363,74 +352,8 @@ def _create_generator_content( window_tag, ) self._create_pitch_stepper(channel_name, initial_pitch, window_tag) - if channel_name is INSTRUMENT_CHANNEL: - self._create_instrument_fields(window_tag) - self._create_generator_feature_displays(channel_name, window_tag) - def _create_instrument_fields(self, window_tag: str) -> None: - """Draws what an instrument states beyond its envelopes: its noise root and its loop point. - - An instrument sounds on every channel, so it states a root for the tonal channels — the stepper - above these — and one for the noise channel's periods. The loop point is the tick its - envelopes repeat from while a note is held. - """ - with dpg.group(tag=self.instrument_fields_tag, parent=window_tag, show=False): - self._instrument_root_period = GUIPitchStepper( - tag=self.instrument_fields_tag, - parent=self.instrument_fields_tag, - kind=PERIOD_VALUE_KIND, - initial_value=RESTING_REFERENCE_PERIOD, - label=self._language_manager["reconstructions.instruments.label.root_period"], - tooltip=self._pitch_tooltips.for_kind(PERIOD_VALUE_KIND), - status_message=self._language_manager["reconstructions.instruments.message.status_input_period"], - status_bar=self._status_bar, - layout=self._pitch_stepper_style.dimensions, - plus_minus_layout=self._pitch_stepper_style.plus_minus, - value_color=self._pitch_stepper_style.value_color, - ) - self._instrument_root_period.on_value_changed = self._on_instrument_root_period_changed - - with labeled_field( - self._language_manager["reconstructions.instruments.label.loop_point"], - self._pitch_stepper_style.dimensions.label_width, - parent=self.instrument_fields_tag, - ): - dpg.add_checkbox( - tag=self.instrument_loops_tag, - default_value=False, - callback=self._on_instrument_loops_toggled, - ) - dpg.add_input_int( - tag=self.instrument_loop_point_tag, - default_value=0, - min_value=0, - min_clamped=True, - width=self._pitch_stepper_style.dimensions.value_width, - step=1, - callback=self._on_instrument_loop_point_typed, - ) - - def _on_instrument_root_period_changed(self, value: int) -> None: - self.call(self.on_instrument_root_period_changed, value) - - def _on_instrument_loops_toggled(self, _sender: Sender, app_data: bool) -> None: - point = dpg.get_value(self.instrument_loop_point_tag) if app_data else None - self.call(self.on_instrument_loop_point_changed, point) - - def _on_instrument_loop_point_typed(self, _sender: Sender, app_data: int) -> None: - if dpg.get_value(self.instrument_loops_tag): - self.call(self.on_instrument_loop_point_changed, max(0, app_data)) - - def _apply_instrument_fields(self, instrument: InstrumentViewModel) -> None: - """Writes what an instrument states into the fields that show it.""" - if self._instrument_root_period is not None: - self._instrument_root_period.set_value(instrument.root_period) - - dpg_set_value(self.instrument_loops_tag, instrument.loops) - dpg_set_value(self.instrument_loop_point_tag, instrument.loop_point if instrument.loop_point is not None else 0) - dpg_configure_item(self.instrument_loop_point_tag, enabled=instrument.loops) - def _default_initial_pitch(self, channel_name: ChannelName) -> int: return resting_reference(channel_name) @@ -534,7 +457,6 @@ def update_view( dpg_configure_item(self.no_data_message_tag, show=not is_open) dpg_configure_item(self.tab_bar_tag, show=is_open) dpg_configure_item(self.sample_size_group_tag, show=is_open) - dpg_configure_item(self.instrument_fields_tag, show=instrument is not None) self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None) for channel_name in ChannelName.items(): @@ -551,9 +473,6 @@ def update_view( channel_name in view_model.playing_channels, ) - if instrument is not None: - self._apply_instrument_fields(instrument) - def _apply_playing_state( self, channel_name: ChannelName, @@ -622,7 +541,7 @@ def _update_generator_feature_data( channel_name: ChannelName, generator_features: Features, ) -> None: - initial_pitch = cast(int, generator_features[FeatureKey.INITIAL_PITCH]) + initial_pitch = generator_features.initial_pitch self._apply_pitch_display(channel_name, initial_pitch) for feature_key in self._generator_features(channel_name): @@ -647,10 +566,9 @@ def _feature_array( generator_features: Features, feature_key: FeatureKey, ) -> np.ndarray: - feature = cast(Optional[np.ndarray], generator_features.get(feature_key)) - if feature is None: - return np.array([], dtype=np.int8) - return feature + envelope = generator_features.envelopes.get(feature_key) + items = envelope.items if envelope is not None else () + return np.array(items, dtype=np.int8) def _pitch_kind(self, channel_name: ChannelName) -> PitchValueKind: return PERIOD_VALUE_KIND if channel_name == ChannelName.NOISE else PITCH_VALUE_KIND diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 1334da46b..5e4b1b3a2 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -7,21 +7,13 @@ class InstrumentViewModel(BaseModel, frozen=True): - """What the instruments panel shows of an instrument: its name and the values it states. + """What the instruments panel shows of an instrument, which is the name it is titled by. - An instrument is its envelopes and the roots they are measured against, so the panel renders one - instrument rather than a tab per channel. + An instrument is one set of envelopes every channel reads, so the panel renders one instrument + rather than a tab per channel, and each dimension states the item it repeats from itself. """ name: str - root_pitch: int - root_period: int - loop_point: Optional[int] - - @property - def loops(self) -> bool: - """Whether the instrument repeats its envelopes rather than playing them once.""" - return self.loop_point is not None class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): diff --git a/src/sampletones_application/view_model/reconstruction/update.py b/src/sampletones_application/view_model/reconstruction/update.py index 58242ebd9..867c0fe63 100644 --- a/src/sampletones_application/view_model/reconstruction/update.py +++ b/src/sampletones_application/view_model/reconstruction/update.py @@ -1,10 +1,10 @@ from typing import NamedTuple from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.types.feature import FeatureValue +from sampletones_core.exporters import Features class ReconstructionUpdate(NamedTuple): channel_name: ChannelName feature_key: FeatureKey - data: FeatureValue + features: Features diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index e27482be3..3d0f941bf 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -484,8 +484,6 @@ reconstructions.instruments.label.volume_label: "Volume" reconstructions.instruments.label.arpeggio_label: "Arpeggio" reconstructions.instruments.label.duty_cycle_label: "Duty cycle" reconstructions.instruments.label.initial_period: "Initial period:" -reconstructions.instruments.label.root_period: "Root period" -reconstructions.instruments.label.loop_point: "Loop point" reconstructions.instruments.label.initial_pitch: "Initial pitch: " reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type value. Enter note name (e.g. \"C-4\") or MIDI value (72)." reconstructions.instruments.message.status_input_period: "Ctrl + click to type value. Enter period name (e.g. \"4-#\") or integer value (4)." @@ -620,7 +618,6 @@ sequencer.voices.label.omission_pitch: "a pitch envelope" sequencer.voices.label.omission_hi_pitch: "a hi-pitch envelope" sequencer.voices.label.omission_release_point: "a release point" sequencer.voices.label.omission_arpeggio_mode: "an arpeggio in fixed, relative or scheme mode" -sequencer.voices.label.omission_sequence_loop_points: "a repeat point per envelope, which the voice takes as one" sequencer.voices.tooltip.new_instrument: "Add an instrument written by hand, playable on any channel" sequencer.voices.tooltip.kind_sample: "Sample" sequencer.voices.tooltip.kind_instrument: "Instrument" diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index 154e0d61c..283d4c241 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -1,22 +1,58 @@ from abc import ABC, abstractmethod -from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Union, cast - -import numpy as np +from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Tuple, Union from sampletones_core.constants.enums import FeatureKey from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.features.envelope import Envelope from sampletones_core.generators import GeneratorTypeUnion from sampletones_core.instructions import ( InstructionFields, InstructionT, InstructionTypeUnion, ) -from sampletones_core.types.feature import FeatureMap -from sampletones_shared.utils.arrays import hold, trim from .feature import Features +def _sounding_length(volume: Tuple[int, ...]) -> Optional[int]: + """How far every dimension is kept: one frame past the last the volume sounds at. + + The frame past the last audible one is what releases a note, so a reconstruction that runs + out while still loud is kept together with the silence its generator wrote after it. + + Args: + volume: The per-tick volume the channel wrote. + + Returns: + Optional[int]: The item count to keep, or ``None`` where the channel never sounds and + every dimension stands as written. + """ + audible = [index for index, level in enumerate(volume) if level] + if not audible: + return None + + return audible[-1] + 2 + + +def _trimmed(items: Tuple[int, ...]) -> Tuple[int, ...]: + """One dimension with its repeated tail dropped, keeping one instance of its final value. + + Args: + items: The values the dimension wrote. + + Returns: + Tuple[int, ...]: The values up to and including the last one that changes. + """ + if not items: + return items + + length = len(items) + while length > 1 and items[length - 1] == items[length - 2]: + length -= 1 + + return items[:length] + + class Exporter(ABC, Generic[InstructionT]): """ Converts between a channel's instruction sequence and FamiTracker features. @@ -33,8 +69,9 @@ class Exporter(ABC, Generic[InstructionT]): _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] + @classmethod def to_features( - self, + cls, instructions: List[InstructionT], initial_pitch: int, held_features: Iterable[FeatureKey], @@ -43,7 +80,9 @@ def to_features( An instruction states every dimension of its frame, so the dimensions the instrument leaves to the channel are named alongside the sequence and come back with empty - envelopes: what the frames carry for them is the value the channel held. + envelopes: what the frames carry for them is the value the channel held. Every dimension + is trimmed to the span ending just after the last audible frame, which is what leaves a + reconstruction resting at silence once its volume runs out. Args: instructions: The channel's per-frame instructions. @@ -53,50 +92,28 @@ def to_features( Returns: Features: The envelope representation of the sequence. """ - feature_map = self.get_feature_map(instructions, initial_pitch) - features = self.from_feature_map_to_features(feature_map) - features.leave_to_channel(held_features) - return features - - @staticmethod - def from_feature_map_to_features(feature_map: FeatureMap) -> Features: - """Builds trimmed :class:`Features` from a raw feature map. - - Trims each envelope to the span ending just after the last audible frame, so - trailing silence is dropped from every dimension together. - - Args: - feature_map: The raw per-dimension arrays. - - Returns: - Features: The trimmed features. - """ - features = Features.from_feature_map(feature_map) - last_nonzero_volume_index: Optional[int] = None - try: - last_nonzero_volume_index = features.volume.nonzero()[0][-1] + 2 - except IndexError: - pass - - for key, value in features.items(): - if isinstance(value, np.ndarray): - array = value[:last_nonzero_volume_index] - trimmed_value = trim(array) - features[key] = trimmed_value - - return features + written = cls.read_envelopes(instructions, initial_pitch) + sounding = _sounding_length(written.get(FeatureKey.VOLUME, ())) + envelopes = { + feature_key: Envelope[int](items=_trimmed(items[:sounding])) for feature_key, items in written.items() + } + return Features.of(initial_pitch, envelopes).leave_to_channel(held_features) @classmethod @abstractmethod - def get_feature_map(cls, instructions: List[InstructionT], initial_pitch: int) -> FeatureMap: - """Extracts the raw per-dimension feature arrays from an instruction sequence. + def read_envelopes( + cls, + instructions: List[InstructionT], + initial_pitch: int, + ) -> Dict[FeatureKey, Tuple[int, ...]]: + """The per-tick values each dimension this channel reads carries. Args: instructions: The channel's per-frame instructions. - initial_pitch: Reference pitch the arpeggio envelope is measured against. + initial_pitch: Reference pitch the arpeggio values are measured against. Returns: - FeatureMap: The per-dimension arrays for this channel. + Dict[FeatureKey, Tuple[int, ...]]: The values per dimension the generator offers. """ @classmethod @@ -134,28 +151,18 @@ def from_features(cls, features: Features) -> List[InstructionT]: List[InstructionT]: The reconstructed per-frame instructions. """ initial_pitch = features.initial_pitch - envelopes: Dict[FeatureKey, np.ndarray] = { - key: cast(np.ndarray, value) - for key, value in features.feature_map.items() - if key != FeatureKey.INITIAL_PITCH and value is not None - } - max_length = max((len(array) for array in envelopes.values()), default=0) + envelopes = features.envelopes instructions: List[InstructionT] = [] - for index in range(max_length): + for index in range(features.frame_count): instruction_dictionary: Dict[str, Union[bool, int]] = {} - for key, array in envelopes.items(): - attribute = cls._remap_feature_key(key) + for feature_key, envelope in envelopes.items(): + attribute = cls._remap_feature_key(feature_key) if not attribute: continue - instruction_dictionary[attribute] = int( - hold( - array, - index, - default=CHANNEL_FEATURE_DEFAULTS[key], - ) - ) + item = envelope.at(index) + instruction_dictionary[attribute] = item if item is not None else CHANNEL_FEATURE_DEFAULTS[feature_key] instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch)) @@ -186,10 +193,8 @@ def feature_values( if not instruction.on: return {FeatureKey.VOLUME: 0} - feature_map = cls.get_feature_map([instruction], initial_pitch) - return { - key: int(value[0]) for key, value in feature_map.items() if isinstance(value, np.ndarray) and value.size - } + written = cls.read_envelopes([instruction], initial_pitch) + return {feature_key: items[0] for feature_key, items in written.items() if items} @classmethod def instruction_from_values( @@ -248,9 +253,6 @@ def _infer_instruction_on(dictionary: Dict[str, Union[bool, int]]) -> bool: @classmethod def _remap_feature_key(cls, feature_key: FeatureKey) -> Optional[InstructionFields]: - if not hasattr(cls, "_ATTRIBUTE_MAP"): - raise NotImplementedError("Subclasses must define _ATTRIBUTE_MAP") - return cls._ATTRIBUTE_MAP.get(feature_key) @classmethod diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 67459c718..0399decdf 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -1,113 +1,156 @@ from __future__ import annotations -from typing import Any, Dict, FrozenSet, Iterable, List, Mapping, Optional, Tuple, cast +from typing import Dict, FrozenSet, Iterable, Mapping, Optional, Tuple -import numpy as np from pydantic import BaseModel, ConfigDict from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.types.feature import FeatureMap, FeatureValue +from sampletones_core.features.envelope import Envelope + +FeatureEnvelopes = Mapping[FeatureKey, Envelope[int]] class Features(BaseModel): - """ - The per-dimension envelopes describing one FamiTracker instrument. + """The per-dimension envelopes describing one FamiTracker instrument. - Each field is the frame-by-frame envelope for one dimension — volume, arpeggio, - pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio - envelope is relative to. A dimension the channel offers is an array, ``None`` for - one it lacks; an array of no items marks a dimension the instrument leaves to the - channel, which keeps the value it holds. The mapping interface (subscript, ``get``, - ``keys``/``items``/``values``, ``in``) exposes the envelopes keyed by - :class:`FeatureKey`, listing the dimensions the channel offers. + Each field is one dimension the channel reads — volume, arpeggio, pitch, hi-pitch and duty + cycle — carrying the values it writes per tick together with the item they repeat from, beside + the ``initial_pitch`` the arpeggio is measured against. A dimension the generator offers is an + envelope, ``None`` for one it lacks; an envelope of no items marks a dimension the instrument + leaves to the channel, which keeps the value it holds. Attributes: initial_pitch: Reference pitch the arpeggio envelope is measured against. volume: Volume envelope. arpeggio: Arpeggio (relative pitch) envelope. - pitch: Pitch envelope, or ``None`` when unused. - hi_pitch: Fine-pitch envelope, or ``None`` when unused. - duty_cycle: Duty-cycle envelope, or ``None`` when unused. + pitch: Pitch envelope, or ``None`` where the generator lacks the dimension. + hi_pitch: Fine-pitch envelope, or ``None`` where the generator lacks the dimension. + duty_cycle: Duty-cycle envelope, or ``None`` where the generator lacks the dimension. """ - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict(frozen=True) initial_pitch: int - volume: np.ndarray - arpeggio: np.ndarray - pitch: Optional[np.ndarray] - hi_pitch: Optional[np.ndarray] - duty_cycle: Optional[np.ndarray] + volume: Envelope[int] + arpeggio: Envelope[int] + pitch: Optional[Envelope[int]] + hi_pitch: Optional[Envelope[int]] + duty_cycle: Optional[Envelope[int]] @classmethod - def from_feature_map( + def of( cls, - feature_map: FeatureMap, + initial_pitch: int, + envelopes: FeatureEnvelopes, ) -> Features: - """Builds features from a raw feature map. + """The features a channel's dimensions describe, leaving out the ones it lacks. Args: - feature_map: The per-dimension arrays keyed by :class:`FeatureKey`. + initial_pitch: Reference pitch the arpeggio envelope is measured against. + envelopes: The dimensions the generator offers, keyed by the feature they carry. Returns: - Features: The features carrying those envelopes. + Features: Those dimensions, with the generator's missing ones absent. """ return cls( - initial_pitch=cast(int, feature_map[FeatureKey.INITIAL_PITCH]), - volume=cast(np.ndarray, feature_map[FeatureKey.VOLUME]), - arpeggio=cast(np.ndarray, feature_map[FeatureKey.ARPEGGIO]), - pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.PITCH)), - hi_pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.HI_PITCH)), - duty_cycle=cast(Optional[np.ndarray], feature_map.get(FeatureKey.DUTY_CYCLE)), + initial_pitch=initial_pitch, + volume=envelopes.get(FeatureKey.VOLUME, Envelope[int]()), + arpeggio=envelopes.get(FeatureKey.ARPEGGIO, Envelope[int]()), + pitch=envelopes.get(FeatureKey.PITCH), + hi_pitch=envelopes.get(FeatureKey.HI_PITCH), + duty_cycle=envelopes.get(FeatureKey.DUTY_CYCLE), ) @property - def feature_map(self) -> Dict[FeatureKey, Optional[FeatureValue]]: - return { - FeatureKey.INITIAL_PITCH: self.initial_pitch, + def envelopes(self) -> Dict[FeatureKey, Envelope[int]]: + """The dimensions this channel offers, keyed by the feature each carries.""" + offered = { FeatureKey.VOLUME: self.volume, FeatureKey.ARPEGGIO: self.arpeggio, FeatureKey.PITCH: self.pitch, FeatureKey.HI_PITCH: self.hi_pitch, FeatureKey.DUTY_CYCLE: self.duty_cycle, } + return {feature_key: envelope for feature_key, envelope in offered.items() if envelope is not None} + + def envelope(self, feature_key: FeatureKey) -> Envelope[int]: + """The dimension one feature names. + + Args: + feature_key: The dimension read. + + Returns: + Envelope[int]: Its values and the item they repeat from. + + Raises: + KeyError: If the channel's generator lacks that dimension. + """ + return self.envelopes[feature_key] - def __getitem__(self, feature_key: FeatureKey) -> FeatureValue: - value = self.feature_map.get(feature_key) - if value is None: + def offers(self, feature_key: FeatureKey) -> bool: + """Whether the channel's generator reads this dimension at all.""" + return feature_key in self.envelopes + + def with_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> Features: + """These features with one dimension replaced. + + Args: + feature_key: The dimension written. + envelope: What that dimension now carries; empty items leave it to the channel. + + Returns: + Features: The features carrying ``envelope`` for ``feature_key``. + + Raises: + KeyError: If the channel's generator lacks that dimension. + """ + if not self.offers(feature_key): raise KeyError(feature_key) - return value - def __setitem__(self, feature_key: FeatureKey, value: FeatureValue) -> None: - if feature_key == FeatureKey.INITIAL_PITCH: - if not isinstance(value, int): - raise TypeError(f"Expected int for {feature_key}, got {type(value)}") - else: - if not isinstance(value, np.ndarray): - raise TypeError(f"Expected np.ndarray for {feature_key}, got {type(value)}") + return self.model_copy(update={feature_key.value: envelope}) - setattr(self, feature_key.name.lower(), value) + def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> Features: + """These features with each named dimension emptied, so the channel governs it. - def __contains__(self, feature_key: FeatureKey) -> bool: - return feature_key in self.feature_map and self.feature_map[feature_key] is not None + The dimensions a channel offers are the ones it can hold a value for, so this acts on + those and leaves the shape of the features as the channel defines it. - def get(self, feature_key: FeatureKey, default: Optional[Any] = None) -> Optional[FeatureValue]: - return self.feature_map.get(feature_key, default) + Args: + feature_keys: The dimensions the instrument leaves to the channel. + + Returns: + Features: The features with those dimensions carrying no item. + """ + emptied = {feature_key.value: Envelope[int]() for feature_key in feature_keys if self.offers(feature_key)} + return self.model_copy(update=emptied) + + def repeating_from(self, loop_point: Optional[int]) -> Features: + """These features with every dimension they write circling from one item. + + A recording states one point for the whole of it, so an export of one gives every + dimension the same point; a point past what a dimension writes moves to its last item. + + Args: + loop_point: The item to repeat from, or ``None`` to leave every dimension halting. - def keys(self) -> List[FeatureKey]: - return [key for key, value in self.feature_map.items() if value is not None] + Returns: + Features: The features with those dimensions circling. + """ + if loop_point is None: + return self - def items(self) -> List[Tuple[FeatureKey, FeatureValue]]: - return [(key, value) for key, value in self.feature_map.items() if value is not None] + circling = self + for feature_key, envelope in self.envelopes.items(): + if envelope.written: + point = min(loop_point, len(envelope.items) - 1) + circling = circling.with_envelope(feature_key, envelope.model_copy(update={"loop_point": point})) - def values(self) -> List[FeatureValue]: - return [value for value in self.feature_map.values() if value is not None] + return circling @property def frame_count(self) -> int: """The frame count the envelopes describe, taken from the longest populated dimension.""" - arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) - return max((len(array) for array in arrays if array is not None), default=0) + return max((len(envelope.items) for envelope in self.envelopes.values()), default=0) @property def has_frames(self) -> bool: @@ -127,20 +170,7 @@ def held_features(self) -> Tuple[FeatureKey, ...]: which keeps the value it already holds for as long as the instrument sounds. These are the dimensions it leaves, listed in the order the model declares them. """ - return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) - - def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None: - """Empties the envelope of each named dimension the channel offers, so the channel governs it. - - The dimensions a channel offers are the ones it can hold a value for, so the record acts - on those and leaves the shape of the features as the channel defines it. - - Args: - feature_keys: The dimensions the instrument leaves to the channel. - """ - for feature_key in feature_keys: - if feature_key in self: - self[feature_key] = np.array([], dtype=np.int8) + return tuple(feature_key for feature_key, envelope in self.envelopes.items() if not envelope.written) def playing_channels(channels: Mapping[ChannelName, Features]) -> FrozenSet[ChannelName]: diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py index 6beec1535..2d2b2b7f3 100644 --- a/src/sampletones_core/exporters/implementation/noise.py +++ b/src/sampletones_core/exporters/implementation/noise.py @@ -1,7 +1,5 @@ from typing import ClassVar, Dict, List, Tuple, Union -import numpy as np - from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import NUM_PERIODS from sampletones_core.generators import GeneratorTypeUnion, NoiseGenerator @@ -10,7 +8,6 @@ InstructionTypeUnion, NoiseInstruction, ) -from sampletones_core.types.feature import FeatureMap from ..exporter import Exporter @@ -65,19 +62,17 @@ def derive_initial_pitch( return initial_period @classmethod - def get_feature_map( + def read_envelopes( cls, instructions: List[NoiseInstruction], initial_pitch: int, - ) -> FeatureMap: + ) -> Dict[FeatureKey, Tuple[int, ...]]: _, periods, volumes, duty_cycles = cls.extract_data(instructions) - arpeggio = (np.array(periods) - initial_pitch) % NUM_PERIODS return { - FeatureKey.INITIAL_PITCH: initial_pitch, - FeatureKey.VOLUME: np.array(volumes).astype(np.int8), - FeatureKey.ARPEGGIO: arpeggio.astype(np.int8), - FeatureKey.DUTY_CYCLE: np.array(duty_cycles).astype(np.int8), + FeatureKey.VOLUME: tuple(volumes), + FeatureKey.ARPEGGIO: tuple((period - initial_pitch) % NUM_PERIODS for period in periods), + FeatureKey.DUTY_CYCLE: tuple(duty_cycles), } @classmethod diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py index 987810263..c85abee12 100644 --- a/src/sampletones_core/exporters/implementation/pulse.py +++ b/src/sampletones_core/exporters/implementation/pulse.py @@ -1,7 +1,5 @@ from typing import ClassVar, Dict, List, Tuple, Union -import numpy as np - from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MIN_PITCH from sampletones_core.exporters.implementation.utils import center_pitch @@ -11,7 +9,6 @@ InstructionTypeUnion, PulseInstruction, ) -from sampletones_core.types.feature import FeatureMap from sampletones_core.utils.frequencies import is_pitch_valid from ..exporter import Exporter @@ -64,19 +61,17 @@ def derive_initial_pitch(cls, instructions: List[PulseInstruction]) -> int: return center_pitch(first_pitch, pitches) @classmethod - def get_feature_map( + def read_envelopes( cls, instructions: List[PulseInstruction], initial_pitch: int, - ) -> FeatureMap: + ) -> Dict[FeatureKey, Tuple[int, ...]]: _, pitches, volumes, duty_cycles = cls.extract_data(instructions) - arpeggio = np.array(pitches) - initial_pitch return { - FeatureKey.INITIAL_PITCH: initial_pitch, - FeatureKey.VOLUME: np.array(volumes).astype(np.int8), - FeatureKey.ARPEGGIO: arpeggio.astype(np.int8), - FeatureKey.DUTY_CYCLE: np.array(duty_cycles).astype(np.int8), + FeatureKey.VOLUME: tuple(volumes), + FeatureKey.ARPEGGIO: tuple(pitch - initial_pitch for pitch in pitches), + FeatureKey.DUTY_CYCLE: tuple(duty_cycles), } @classmethod diff --git a/src/sampletones_core/exporters/implementation/triangle.py b/src/sampletones_core/exporters/implementation/triangle.py index 1c7eb7d4e..ed388e083 100644 --- a/src/sampletones_core/exporters/implementation/triangle.py +++ b/src/sampletones_core/exporters/implementation/triangle.py @@ -1,7 +1,5 @@ from typing import ClassVar, Dict, List, Tuple, Union -import numpy as np - from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH from sampletones_core.exporters.implementation.utils import center_pitch @@ -11,7 +9,6 @@ InstructionTypeUnion, TriangleInstruction, ) -from sampletones_core.types.feature import FeatureMap from sampletones_core.utils.frequencies import is_pitch_valid from ..exporter import Exporter @@ -62,18 +59,16 @@ def derive_initial_pitch( return center_pitch(first_pitch, pitches) @classmethod - def get_feature_map( + def read_envelopes( cls, instructions: List[TriangleInstruction], initial_pitch: int, - ) -> FeatureMap: + ) -> Dict[FeatureKey, Tuple[int, ...]]: _, pitches, volumes = cls.extract_data(instructions) - arpeggio = np.array(pitches) - initial_pitch return { - FeatureKey.INITIAL_PITCH: initial_pitch, - FeatureKey.VOLUME: np.array(volumes).astype(np.int8), - FeatureKey.ARPEGGIO: arpeggio.astype(np.int8), + FeatureKey.VOLUME: tuple(volumes), + FeatureKey.ARPEGGIO: tuple(pitch - initial_pitch for pitch in pitches), } @classmethod diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py deleted file mode 100644 index 998bef4dc..000000000 --- a/src/sampletones_core/exporters/lengths.py +++ /dev/null @@ -1,100 +0,0 @@ -from collections.abc import Hashable -from typing import Dict, List, Optional, Tuple, TypeVar - -from sampletones_shared.logger import logger - -EnvelopeKey = TypeVar("EnvelopeKey", bound=Hashable) - - -def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: - """Brings a sequence to a length, repeating its final value when it falls short.""" - return items[:length] + items[-1:] * (length - len(items)) - - -def _limited_length(length: int, limit: Optional[int]) -> int: - """Brings a length within what the target format stores, reporting what that drops.""" - if limit is None or length <= limit: - return length - - logger.debug(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") - return limit - - -def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int: - """Chooses the length every populated dimension of an instrument shares. - - A looping instrument takes the shortest length, which drops the trailing note-off - volume item the loop would otherwise sound once per cycle; a one-shot takes the - longest, so each shorter dimension holds its final value to the end. A ``limit`` - caps the result, so an envelope longer than the target format stores keeps its - opening items and the rest is reported as dropped. - - Args: - lengths: The item counts of the populated dimensions. - loop: Whether the instrument loops while its note is held. - limit: The most items the target format stores, or ``None`` when it is unbounded. - - Returns: - int: The shared item count, at most ``limit`` where one applies. - """ - return _limited_length(min(lengths) if loop else max(lengths), limit) - - -def limit_lengths( - items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]], - *, - limit: int, -) -> Dict[EnvelopeKey, Tuple[int, ...]]: - """Keeps each dimension's opening items, as many as the target format stores. - - Every dimension stands at its own length, which is what a player that sustains an - exhausted envelope's final value reads: the envelope describes the frames it covers - and the last value it wrote governs the rest. - - Args: - items_by_kind: The per-dimension item tuples, empty for a dimension the channel - leaves unused. - limit: The most items the target format stores. - - Returns: - Dict[EnvelopeKey, Tuple[int, ...]]: The items with every dimension within the limit. - """ - return { - kind: items[ - : _limited_length( - len(items), - limit, - ) - ] - for kind, items in items_by_kind.items() - } - - -def equalize_lengths( - items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]], - loop: bool, - *, - limit: Optional[int] = None, -) -> Dict[EnvelopeKey, Tuple[int, ...]]: - """Brings every populated dimension of an instrument to one common length. - - A tracker advances each dimension on its own per-tick counter, so dimensions of - unequal length pull apart: a looping instrument's envelopes slip by a tick per - cycle, and a one-shot's shorter dimensions expire while its volume still sounds. - - Args: - items_by_kind: The per-dimension item tuples, empty for a dimension the channel - leaves unused. - loop: Whether the instrument loops while its note is held. - limit: The most items the target format stores, or ``None`` when it is unbounded. - - Returns: - Dict[EnvelopeKey, Tuple[int, ...]]: The items with every populated dimension at - one length, leaving unused dimensions empty. - """ - lengths = [len(items) for items in items_by_kind.values() if items] - if not lengths: - return items_by_kind - - length = _common_length(lengths, loop, limit) - return {kind: _resize(items, length) if items else items for kind, items in items_by_kind.items()} diff --git a/src/sampletones_core/exporters/slices/instrument.py b/src/sampletones_core/exporters/slices/instrument.py index 963689d39..ef15b1857 100644 --- a/src/sampletones_core/exporters/slices/instrument.py +++ b/src/sampletones_core/exporters/slices/instrument.py @@ -39,7 +39,6 @@ class InstrumentEntry: features: The envelopes written into it. channel: The channel ``features`` are stated for, or ``None`` where they are the one set every channel reads and belong to no channel in particular. - loop_point: The tick its envelopes repeat from, or ``None`` where they play once. slots: Per channel it answers for, the table position and the reference that channel reads. """ @@ -48,7 +47,6 @@ class InstrumentEntry: name: str features: Features channel: Optional[ChannelName] - loop_point: Optional[int] slots: Dict[ChannelName, InstrumentSlot] @@ -80,9 +78,8 @@ def sample_instrument_entries( index=index, voice_id=sample.id, name=instrument_slice_name(sample.name, channel), - features=features, + features=features.repeating_from(sample.loop_point), channel=channel, - loop_point=sample.loop_point, slots={ channel: InstrumentSlot( index=index, @@ -120,7 +117,6 @@ def instrument_entries( name=instrument.name, features=instrument.instrument_features(), channel=None, - loop_point=instrument.loop_point, slots={ channel: InstrumentSlot( index=start_index, diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index 59b74914a..56d0443c2 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -60,7 +60,6 @@ def write_instrument( STANDALONE_INSTRUMENT_INDEX, request.name, request.features, - loop_point=request.loop_point, ) write_fti(destination, instrument) announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) diff --git a/src/sampletones_core/exports/request.py b/src/sampletones_core/exports/request.py index 88b510886..559847d98 100644 --- a/src/sampletones_core/exports/request.py +++ b/src/sampletones_core/exports/request.py @@ -1,7 +1,9 @@ +# TODO: split into a subpackage - divide into logical units + from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Tuple +from typing import Tuple from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features @@ -17,8 +19,6 @@ class InstrumentExport: name: Name the written instrument carries. channel: The NES channel the slice was reconstructed for. features: The per-dimension envelopes describing the slice. - loop_point: The tick the instrument repeats from while its note is held, or ``None`` - where it plays its envelopes once. nes_frequency: Rate in Hz the envelopes advance at, one item per tick. tuning: Where concert pitch sat for the reconstruction the slice came from. """ @@ -26,7 +26,6 @@ class InstrumentExport: name: str channel: ChannelName features: Features - loop_point: Optional[int] nes_frequency: int tuning: Tuning @@ -44,15 +43,12 @@ class InstrumentSource: channel: The NES channel the envelopes are read for, which a backend sounding them on its own plays them through. features: The per-dimension envelopes describing the instrument. - loop_point: The tick the instrument repeats from while its note is held, or ``None`` - where it plays its envelopes once. nes_frequency: Rate in Hz the envelopes advance at, one item per tick. tuning: Where concert pitch sits for the envelopes. """ channel: ChannelName features: Features - loop_point: Optional[int] nes_frequency: int tuning: Tuning @@ -69,7 +65,6 @@ def named(self, name: str) -> InstrumentExport: name=name, channel=self.channel, features=self.features, - loop_point=self.loop_point, nes_frequency=self.nes_frequency, tuning=self.tuning, ) diff --git a/src/sampletones_core/features/envelope.py b/src/sampletones_core/features/envelope.py new file mode 100644 index 000000000..f90c2f311 --- /dev/null +++ b/src/sampletones_core/features/envelope.py @@ -0,0 +1,127 @@ +from typing import Generic, Optional, Tuple, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_shared.logger import logger + +ItemT = TypeVar("ItemT") + + +class Envelope(BaseModel, Generic[ItemT]): + """One dimension read per tick: the values it writes, and the value they repeat from. + + Each dimension advances on a counter of its own, which is what lets an attack on the volume + sit beside a duty cycle that circles every other tick. A dimension reaching its last item + holds that value for as long as the note sounds, so a trailing zero on the volume is what + releases a note; one stating a loop point circles from that item instead. + + The point travels with the items it indexes, so every operation that reshapes a dimension + reshapes both together and one can never be read against a stale other. + + Attributes: + items: The value this dimension writes per tick, empty where the channel governs it. + loop_point: The item index the dimension repeats from, or ``None`` where it holds its last. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + items: Tuple[ItemT, ...] = () + loop_point: Optional[int] = Field( + default=None, + ge=0, + description="Item index the dimension repeats from, or None where it holds its last item.", + ) + + @model_validator(mode="after") + def _check_loop_point(self) -> "Envelope[ItemT]": + if self.loop_point is not None and self.loop_point >= len(self.items): + raise ValueError(f"loop point {self.loop_point} stands past the {len(self.items)} items written") + + return self + + @property + def loops(self) -> bool: + """Whether the dimension circles from a point rather than holding its last item.""" + return self.loop_point is not None + + @property + def written(self) -> bool: + """Whether the instrument writes this dimension, which takes it out of the channel's own.""" + return bool(self.items) + + def at(self, tick: int) -> Optional[ItemT]: + """The value this dimension holds at a tick of a sounding note. + + Args: + tick: Ticks since the note started. + + Returns: + Optional[ItemT]: The value written, or ``None`` where the channel governs this dimension. + """ + if not self.items: + return None + + if tick < len(self.items): + return self.items[tick] + + if self.loop_point is None: + return self.items[-1] + + cycle = len(self.items) - self.loop_point + return self.items[self.loop_point + (tick - self.loop_point) % cycle] + + def limited(self, limit: int) -> "Envelope[ItemT]": + """This dimension's opening items, as many as a target format stores. + + A point standing past what survives moves to the last item kept, which is the value the + dimension would hold there anyway. + + Args: + limit: The most items the target format stores. + + Returns: + Envelope[ItemT]: The dimension within that limit, with its point kept inside it. + """ + if len(self.items) <= limit: + return self + + logger.debug( + f"Instrument envelope of {len(self.items)} items keeps its first {limit}, the most the format holds" + ) + return self._holding(self.items[:limit]) + + def resized(self, length: int) -> "Envelope[ItemT]": + """This dimension brought to a length, holding its final value where it falls short. + + A format storing one row per tick reads every dimension out of the same row, so a + dimension shorter than its siblings holds the value it ended on for the rest of them. + + Args: + length: The item count to reach. + + Returns: + Envelope[ItemT]: The dimension at that length, with its point kept inside it. + """ + if not self.items or len(self.items) == length: + return self + + return self._holding(self.items[:length] + self.items[-1:] * (length - len(self.items))) + + def with_items(self, items: Tuple[ItemT, ...]) -> "Envelope[ItemT]": + """This dimension carrying different values, repeating from a point inside them. + + A reader redrawing a dimension states the values alone, so the point it already repeats + from survives the edit and moves only far enough to stay inside what is written. + + Args: + items: The values the dimension now writes. + + Returns: + Envelope[ItemT]: The dimension carrying ``items``. + """ + return self._holding(items) + + def _holding(self, items: Tuple[ItemT, ...]) -> "Envelope[ItemT]": + """This dimension carrying ``items``, with the loop point held inside them.""" + loop_point = min(self.loop_point, len(items) - 1) if self.loop_point is not None and items else None + return type(self)(items=items, loop_point=loop_point) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index b4b4b5bd2..ba8bc48c4 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -279,7 +279,6 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: features_to_envelopes( instrument.features, instrument.channel, - loop_point=instrument.loop_point, ), maximum_table_id=MAX_TABLE_ID, ) @@ -336,7 +335,6 @@ def _build_voice_table( envelopes = features_to_envelopes( voice_slice.features, voice_slice.channel, - loop_point=voice_slice.voice.loop_point, ) voice = _build_slice_voice( index, diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index d6c9ebd4b..0cf4308c0 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -1,11 +1,9 @@ from dataclasses import dataclass -from typing import Dict, Final, Optional, Tuple - -import numpy as np +from typing import Final, Iterable, Tuple from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.feature import Features -from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset from sampletones_core.formats.bitphase.specification.instruments import ( @@ -43,12 +41,6 @@ class ChannelEnvelopes: loop: int -def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: - if array is None: - return () - return tuple(int(value) for value in array) - - def _pulse_width(channel: ChannelName, duty_cycle: int) -> int: """Reads a duty-cycle item as the field the channel uses it for. @@ -90,18 +82,16 @@ def _held_volume(frames: int) -> Tuple[int, ...]: def features_to_envelopes( features: Features, channel: ChannelName, - *, - loop_point: Optional[int], ) -> ChannelEnvelopes: """Converts one channel slice's envelopes into Bitphase instrument and table rows. Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's waveform field, and the arpeggio becomes the table contour that moves the note. A slice that leaves its volume to the channel takes a full level for every frame it - describes, so the channel governs how loud it sounds. A slice with a loop point returns to - that row so it sustains for as long as the note is held; a one-shot returns to its last row, - resting on the level its volume envelope ends with — silence where the slice writes its own, - the channel's level where it holds one. + describes, so the channel governs how loud it sounds. Bitphase reads every dimension out of + one row, so the instrument returns to the earliest row any dimension repeats from; one whose + dimensions all halt returns to its last row, resting on the level its volume envelope ends + with — silence where the slice writes its own, the channel's level where it holds one. A slice describing no frame comes back as the one silent row that is the smallest instrument Bitphase plays. @@ -109,19 +99,15 @@ def features_to_envelopes( Args: features: The per-dimension envelopes describing the slice. channel: The NES channel the slice was reconstructed for. - loop_point: The row the instrument repeats from while its note is held, or ``None`` - where it plays its rows once. Returns: ChannelEnvelopes: The rows, contour, and loop point describing the slice. """ - arrays: Dict[FeatureKey, Optional[np.ndarray]] = { - FeatureKey.VOLUME: features.volume, - FeatureKey.ARPEGGIO: features.arpeggio, - FeatureKey.DUTY_CYCLE: features.duty_cycle, + frames = features.frame_count + envelopes = { + feature_key: features.envelopes.get(feature_key, Envelope[int]()).resized(frames) + for feature_key in (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) } - items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop_point is not None) - frames = max(len(values) for values in items.values()) if not frames: return ChannelEnvelopes( @@ -130,9 +116,9 @@ def features_to_envelopes( loop=LOOP_FROM_START, ) - volumes = items[FeatureKey.VOLUME] or _held_volume(frames) - arpeggios = items[FeatureKey.ARPEGGIO] - duty_cycles = items[FeatureKey.DUTY_CYCLE] + volumes = envelopes[FeatureKey.VOLUME].items or _held_volume(frames) + arpeggios = envelopes[FeatureKey.ARPEGGIO].items + duty_cycles = envelopes[FeatureKey.DUTY_CYCLE].items rows = tuple( NesInstrumentRow( @@ -147,5 +133,25 @@ def features_to_envelopes( return ChannelEnvelopes( rows=rows, table_rows=table_rows, - loop=(min(loop_point, len(rows) - 1) if loop_point is not None else len(rows) - 1), + loop=_loop_row(envelopes.values(), len(rows)), ) + + +def _loop_row(envelopes: Iterable[Envelope[int]], rows: int) -> int: + """The row a Bitphase instrument returns to, which is the earliest any dimension repeats from. + + Bitphase reads every dimension out of one row, so one point serves them all and the earliest + keeps each dimension sounding what it would have sounded. + + Args: + envelopes: The dimensions the instrument writes. + rows: How many rows the instrument holds. + + Returns: + int: The row to return to, the last one where every dimension halts. + """ + points = [envelope.loop_point for envelope in envelopes if envelope.loop_point is not None] + if not points: + return rows - 1 + + return min(*points, rows - 1) diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index 3c86a399c..1d2275450 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -64,7 +64,6 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset: envelopes = features_to_envelopes( request.features, request.channel, - loop_point=request.loop_point, ) offsets = _tone_offsets( request.channel, diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 8da304ef0..9a1396768 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -65,8 +65,6 @@ def build_instrument( index: int, name: str, features: Features, - *, - loop_point: Optional[int], ) -> Instrument2A03: """Builds one FamiTracker instrument from a set of envelopes. @@ -77,20 +75,11 @@ def build_instrument( index: The slot the instrument is numbered under. name: The name FamiTracker lists the instrument by. features: The per-dimension envelopes the sequences are read from. - loop_point: The item every populated sequence repeats from, sustaining a held note, or - ``None`` where the instrument plays its envelopes once. Returns: The instrument the envelopes describe. """ - sequences = features_to_instrument_sequences( - volume=features.volume, - arpeggio=features.arpeggio, - pitch=features.pitch, - hi_pitch=features.hi_pitch, - duty_cycle=features.duty_cycle, - loop_point=loop_point, - ) + sequences = features_to_instrument_sequences(features) return Instrument2A03( index=index, @@ -121,7 +110,6 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst entry.index, entry.name, entry.features, - loop_point=entry.loop_point, ) ) for channel, slot in entry.slots.items(): diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index e3e4f4faa..4fbe3a6a1 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Iterable, Optional +from typing import Dict, Iterable from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features @@ -66,41 +66,24 @@ def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint: return sequences_footprint(instrument.sequences.values()) -def features_footprint( - features: Features, - *, - loop_point: Optional[int], -) -> InstrumentFootprint: +def features_footprint(features: Features) -> InstrumentFootprint: """Measures the instrument a channel slice's envelopes export to. The envelopes pass through the same builder an export uses, so the measured item counts are - the ones a file carries: brought to one shared length and capped at what a FamiTracker + the ones a file carries: each at the length it was written, capped at what a FamiTracker sequence holds. Args: features: The per-dimension envelopes describing the slice. - loop_point: The tick the instrument repeats from, which decides the shared length, or - ``None`` where it plays its envelopes once. Returns: InstrumentFootprint: The footprint of the instrument those envelopes describe. """ - sequences = features_to_instrument_sequences( - volume=features.volume, - arpeggio=features.arpeggio, - pitch=features.pitch, - hi_pitch=features.hi_pitch, - duty_cycle=features.duty_cycle, - loop_point=loop_point, - ) + sequences = features_to_instrument_sequences(features) return sequences_footprint(sequences.values()) -def reconstruction_footprints( - reconstruction: Reconstruction, - *, - loop_point: Optional[int], -) -> Dict[ChannelName, InstrumentFootprint]: +def reconstruction_footprints(reconstruction: Reconstruction) -> Dict[ChannelName, InstrumentFootprint]: """Measures one instrument per channel a reconstruction plays. An export writes an instrument for each channel that plays, so the result holds an entry @@ -109,13 +92,12 @@ def reconstruction_footprints( Args: reconstruction: The reconstruction whose channels are measured. - loop_point: The tick the sample carrying it repeats from, or ``None`` where it plays once. Returns: Dict[ChannelName, InstrumentFootprint]: The footprint of each playing channel's instrument. """ return { - channel_name: features_footprint(features, loop_point=loop_point) + channel_name: features_footprint(features) for channel_name, features in reconstruction.export().items() if features.has_frames } diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index 683655268..c40fd2fee 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -1,84 +1,44 @@ -from typing import Dict, Optional, Tuple +from typing import Dict -import numpy as np - -from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths +from sampletones_core.exporters.feature import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( - LOOP_FROM_START, + FEATURE_KEY_TO_SEQUENCE_KIND, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, SequenceKind, ) -def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: - if array is None: - return () - return tuple(int(value) for value in array) +def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, InstrumentSequence]: + """Builds the five 2A03 sequences from a channel slice's envelopes. + Each dimension becomes an :class:`InstrumentSequence`; one the generator lacks, or one left + to the channel, becomes a disabled sequence the instrument stores nothing for. Item counts + stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer reconstruction + exports its opening frames and the shortening is logged. Every dimension keeps the length it + was written at and the item it repeats from, which is how FamiTracker advances each sequence + on a counter of its own. -def _sequence_items( - arrays: Dict[SequenceKind, Optional[np.ndarray]], - loops: bool, -) -> Dict[SequenceKind, Tuple[int, ...]]: - """Reads the dimensions as the item tuples an instrument stores. + Args: + features: The per-dimension envelopes describing the slice. - A looping instrument brings every populated dimension to one length, so its envelopes - repeat in step cycle after cycle. A one-shot carries each dimension at the length it - was written: a FamiTracker sequence that runs out halts and leaves its final value - applied for as long as the note sounds, so the shorter dimensions govern the whole - instrument on their own. + Returns: + Dict[SequenceKind, InstrumentSequence]: The sequences, one per dimension FamiTracker holds. """ - items_by_kind = {kind: _to_items(array) for kind, array in arrays.items()} - if loops: - return equalize_lengths(items_by_kind, loops, limit=MAX_SEQUENCE_ITEMS) - - return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS) - - -def features_to_instrument_sequences( - *, - volume: np.ndarray, - arpeggio: np.ndarray, - pitch: Optional[np.ndarray], - hi_pitch: Optional[np.ndarray], - duty_cycle: Optional[np.ndarray], - loop_point: Optional[int], -) -> Dict[SequenceKind, InstrumentSequence]: - """Builds the five 2A03 sequences from per-dimension envelope arrays. - - Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as ``None`` - or as an empty envelope becomes a disabled sequence the instrument stores nothing for. - Item counts stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer - reconstruction exports its opening frames and the shortening is logged. A ``loop_point`` - sets every populated sequence to repeat from that item so the instrument sustains on a held - note, and the populated dimensions share one length to repeat in step; a point beyond a - sequence's own items repeats its final item, which is the value it would hold anyway. - """ - arrays: Dict[SequenceKind, Optional[np.ndarray]] = { - SequenceKind.VOLUME: volume, - SequenceKind.ARPEGGIO: arpeggio, - SequenceKind.PITCH: pitch, - SequenceKind.HI_PITCH: hi_pitch, - SequenceKind.DUTY: duty_cycle, + written = { + FEATURE_KEY_TO_SEQUENCE_KIND[feature_key]: envelope.limited(MAX_SEQUENCE_ITEMS) + for feature_key, envelope in features.envelopes.items() } - items_by_kind = _sequence_items(arrays, loop_point is not None) - - sequences: Dict[SequenceKind, InstrumentSequence] = {} - for kind, items in items_by_kind.items(): - sequences[kind] = InstrumentSequence( - kind=kind, - items=items, - loop_point=_loop_item(loop_point, len(items)), - ) - - return sequences - + return {kind: _sequence(kind, written.get(kind, Envelope[int]())) for kind in SequenceKind} -def _loop_item(loop_point: Optional[int], length: int) -> int: - if loop_point is None or not length: - return NO_LOOP_POINT - return max(LOOP_FROM_START, min(loop_point, length - 1)) +def _sequence(kind: SequenceKind, envelope: Envelope[int]) -> InstrumentSequence: + """One sequence as the file states it, with the item the dimension repeats from.""" + return InstrumentSequence( + kind=kind, + items=envelope.items, + loop_point=envelope.loop_point if envelope.loop_point is not None else NO_LOOP_POINT, + ) diff --git a/src/sampletones_core/formats/famitracker/voice.py b/src/sampletones_core/formats/famitracker/voice.py index c629e3b7e..315107c83 100644 --- a/src/sampletones_core/formats/famitracker/voice.py +++ b/src/sampletones_core/formats/famitracker/voice.py @@ -4,6 +4,7 @@ from pydantic import ValidationError +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( @@ -20,13 +21,12 @@ class InstrumentOmission(StrEnum): - """What a tracker instrument states beyond the envelopes and the one loop point a voice holds.""" + """What a tracker instrument states beyond the envelopes and loop points a voice holds.""" PITCH = "pitch" HI_PITCH = "hi_pitch" RELEASE_POINT = "release_point" ARPEGGIO_MODE = "arpeggio_mode" - SEQUENCE_LOOP_POINTS = "sequence_loop_points" @dataclass(frozen=True) @@ -64,24 +64,22 @@ def instrument_to_voice(instrument: Instrument2A03) -> ImportedVoice: dimension it feeds holds. """ sequences = instrument.sequences - governing = _governing_sequence(sequences) return ImportedVoice( - voice=_voice(instrument.name, sequences, _loop_point(governing)), - omissions=_omissions(sequences, governing), + voice=_voice(instrument.name, sequences), + omissions=_omissions(sequences), ) def _voice( name: str, sequences: Sequences, - loop_point: Optional[int], ) -> Instrument: try: envelopes = InstrumentEnvelopes( - volume=sequences[SequenceKind.VOLUME].items, - arpeggio=sequences[SequenceKind.ARPEGGIO].items, - duty_cycle=sequences[SequenceKind.DUTY].items, + volume=_envelope(sequences[SequenceKind.VOLUME]), + arpeggio=_envelope(sequences[SequenceKind.ARPEGGIO]), + duty_cycle=_envelope(sequences[SequenceKind.DUTY]), ) except ValidationError as exception: raise InvalidInstrumentValuesError( @@ -92,48 +90,38 @@ def _voice( return Instrument( name=name, envelopes=envelopes, - loop_point=loop_point, ) -def _governing_sequence(sequences: Sequences) -> Optional[InstrumentSequence]: - """The sequence whose loop point the whole voice adopts. +def _envelope(sequence: InstrumentSequence) -> Envelope[int]: + """One dimension as the voice holds it, carrying the point that sequence repeats from. - A voice repeats every dimension from one tick, so one sequence states the point the rest - follow. The volume sequence governs wherever it is written, since it is the one that shapes - a held note; otherwise the first sequence the instrument writes does. - """ - volume = sequences[SequenceKind.VOLUME] - if volume.enabled: - return volume + Args: + sequence: The sequence the file states for this dimension. - return next( - (sequence for sequence in sequences.values() if sequence.enabled), - None, + Returns: + Envelope[int]: Its items and its own loop point, empty where the file writes nothing. + """ + return Envelope[int]( + items=sequence.items, + loop_point=_loop_point(sequence), ) -def _loop_point(governing: Optional[InstrumentSequence]) -> Optional[int]: - if governing is None or governing.loop_point < LOOP_FROM_START: +def _loop_point(sequence: InstrumentSequence) -> Optional[int]: + if sequence.loop_point < LOOP_FROM_START or sequence.loop_point >= len(sequence.items): return None - return governing.loop_point + return sequence.loop_point -def _omissions( - sequences: Sequences, - governing: Optional[InstrumentSequence], -) -> Tuple[InstrumentOmission, ...]: +def _omissions(sequences: Sequences) -> Tuple[InstrumentOmission, ...]: arpeggio = sequences[SequenceKind.ARPEGGIO] held = { InstrumentOmission.PITCH: sequences[SequenceKind.PITCH].enabled, InstrumentOmission.HI_PITCH: sequences[SequenceKind.HI_PITCH].enabled, InstrumentOmission.RELEASE_POINT: _holds_release_point(sequences), InstrumentOmission.ARPEGGIO_MODE: arpeggio.enabled and arpeggio.setting != DEFAULT_SEQUENCE_SETTING, - InstrumentOmission.SEQUENCE_LOOP_POINTS: _holds_separate_loop_points( - sequences, - governing, - ), } return tuple(omission for omission, stated in held.items() if stated) @@ -141,13 +129,3 @@ def _omissions( def _holds_release_point(sequences: Sequences) -> bool: return any(sequence.enabled and sequence.release_point != NO_RELEASE_POINT for sequence in sequences.values()) - - -def _holds_separate_loop_points( - sequences: Sequences, - governing: Optional[InstrumentSequence], -) -> bool: - if governing is None: - return False - - return any(sequence.enabled and sequence.loop_point != governing.loop_point for sequence in sequences.values()) diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index 54d5b2521..2b488694f 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -21,21 +21,26 @@ class VoiceReading: envelope in the instruments panel means once the voice is played in a song. Reading a voice on a channel answers all a channel needs of it — the frames, the reference its - arpeggio is measured against, the dimensions it leaves behind, and where it repeats from — so - the song walk and the sequencer's renderer read one voice the same way. + arpeggio is measured against, the dimensions it leaves behind, and what it sounds once the + written frames run out — so the song walk and the sequencer's renderer read one voice the same + way. Attributes: exporter: The reading that turns this channel's frames into envelope values and back. instructions: The frames the channel plays, one per tick. reference: The pitch the arpeggio values are measured against. held_features: The dimensions the voice leaves to the channel. - loop_point: The tick the frames repeat from, or ``None`` where they play once. + channel_name: The channel doing the reading. + sustaining: The instrument whose envelopes go on past the written frames, where one does. + loop_point: The frame a recording circles back to, or ``None`` where it plays through once. """ exporter: ExporterTypeUnion instructions: Sequence[InstructionUnion] reference: int held_features: Tuple[FeatureKey, ...] + channel_name: ChannelName + sustaining: Optional[Instrument] loop_point: Optional[int] @classmethod @@ -47,8 +52,8 @@ def read( """The reading one channel plays ``voice`` through. A sample answers with the frames its reconstruction found for this channel and the - reference they were measured against; an instrument answers with the frames its envelopes make - of this channel and the root it states. Both kinds therefore reach a channel as one + reference they were measured against; an instrument answers with the frames its envelopes + make of this channel and the pitch it states. Both kinds therefore reach a channel as one reading. Args: @@ -59,13 +64,17 @@ def read( Optional[VoiceReading]: The reading of that channel's frames, or ``None`` where the voice describes no frame there and the channel rests. """ + sustaining: Optional[Instrument] = None + loop_point: Optional[int] = None match voice: case Sample(): instructions: Sequence[InstructionUnion] = voice.reconstruction.instructions[channel_name] held_features = voice.reconstruction.held_features[channel_name] + loop_point = voice.loop_point case Instrument(): instructions = voice.instructions(channel_name) held_features = voice.held_features(channel_name) + sustaining = voice if not instructions: return None @@ -75,16 +84,19 @@ def read( instructions=instructions, reference=voice_reference(voice, channel_name), held_features=held_features, - loop_point=voice.loop_point, + channel_name=channel_name, + sustaining=sustaining, + loop_point=loop_point, ) def at(self, tick_index: int) -> Optional[InstructionUnion]: """The frame standing at ``tick_index`` of a sounding note. - A voice repeating from a loop point plays its opening once and then circles the frames from - that point on, so it sustains for as long as rows keep it sounding; one playing its frames - once falls silent past the last. A point beyond the frames this channel holds circles its - final frame, which is the value the channel would hold anyway. + An instrument goes on past its written frames: each dimension circles from its own loop + point or holds its last item, so a note sounds for as long as rows keep it sounding and a + volume envelope ending at silence is what releases it. A recording circles the frames its + conversion found from the point it states, and the channel rests past the last of them + where it states none. Args: tick_index: How many ticks of the voice the channel has played. @@ -96,6 +108,9 @@ def at(self, tick_index: int) -> Optional[InstructionUnion]: if tick_index < len(self.instructions): return self.instructions[tick_index] + if self.sustaining is not None: + return self.sustaining.instruction_at(self.channel_name, tick_index) + if self.loop_point is None: return None diff --git a/src/sampletones_core/project/voices/creation.py b/src/sampletones_core/project/voices/creation.py index cd34b6170..77253cbb3 100644 --- a/src/sampletones_core/project/voices/creation.py +++ b/src/sampletones_core/project/voices/creation.py @@ -1,6 +1,4 @@ -from typing import Final, Optional, Tuple - -import numpy as np +from typing import Final from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME @@ -10,18 +8,20 @@ RESTING_REFERENCE_PITCH, speaks_in_periods, ) +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT -SUSTAINING_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes(volume=(MAX_VOLUME,)) +SUSTAINING_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes( + volume=Envelope(items=(MAX_VOLUME,), loop_point=0), +) def new_instrument(name: str) -> Instrument: """An instrument a reader can place and hear straight away, before writing an envelope of its own. An instrument sounds the frames its envelopes describe, so one holding a single full-volume tick - that repeats holds a note for as long as a row asks for it, at the roots a channel added by + that repeats holds a note for as long as a row asks for it, at the pitch a channel added by hand rests on. Arpeggio and duty cycle stay the channel's until the reader writes them. Args: @@ -33,7 +33,6 @@ def new_instrument(name: str) -> Instrument: return Instrument( name=name, envelopes=SUSTAINING_ENVELOPES, - loop_point=WHOLE_LOOP_POINT, ) @@ -41,22 +40,20 @@ def instrument_from_features( name: str, features: Features, channel_name: ChannelName, - *, - loop_point: Optional[int], ) -> Instrument: """An instrument carrying what one channel plays, in envelopes the reader can edit. The envelopes come across as they stand, so the voice sounds on the channel it came from what that channel sounded; a dimension the channel governs stays governed wherever the voice is - placed next. The channel's own reference becomes the root it was measured against — a period on - noise and a note elsewhere — and the root the other channels read rests where a voice added by - hand rests, so the voice stands somewhere sensible on all of them. + placed next. Each dimension holds its final value once it runs out, the way a recorded channel + rests on the value it last played. The channel's own reference becomes the initial pitch it was + measured against — a period on noise and a note elsewhere — and the one the other channels read + rests where a voice added by hand rests, so the voice stands somewhere sensible on all of them. Args: name: The name the voice list shows. features: The envelopes the channel plays. channel_name: The channel those envelopes were measured for. - loop_point: The tick the envelopes repeat from, or ``None`` where they play once. Returns: Instrument: The voice those envelopes describe. @@ -64,17 +61,16 @@ def instrument_from_features( return Instrument( name=name, envelopes=InstrumentEnvelopes( - volume=_envelope(features.volume), - arpeggio=_envelope(features.arpeggio), - duty_cycle=_envelope(features.duty_cycle), + volume=features.volume, + arpeggio=features.arpeggio, + duty_cycle=features.duty_cycle if features.duty_cycle is not None else Envelope[int](), ), - root_pitch=_root_pitch(channel_name, features.initial_pitch), - root_period=_root_period(channel_name, features.initial_pitch), - loop_point=loop_point, + initial_pitch=_initial_pitch(channel_name, features.initial_pitch), + initial_period=_initial_period(channel_name, features.initial_pitch), ) -def _root_pitch(channel_name: ChannelName, reference: int) -> int: +def _initial_pitch(channel_name: ChannelName, reference: int) -> int: """The note the tonal channels measure the arpeggio against, taken from ``reference`` where it is one.""" if speaks_in_periods(channel_name): return RESTING_REFERENCE_PITCH @@ -82,17 +78,9 @@ def _root_pitch(channel_name: ChannelName, reference: int) -> int: return reference -def _root_period(channel_name: ChannelName, reference: int) -> int: +def _initial_period(channel_name: ChannelName, reference: int) -> int: """The period the noise channel measures the arpeggio against, taken from ``reference`` where it is one.""" if speaks_in_periods(channel_name): return reference return RESTING_REFERENCE_PERIOD - - -def _envelope(items: Optional[np.ndarray]) -> Tuple[int, ...]: - """One dimension as a voice states it, empty where the channel governs it.""" - if items is None: - return () - - return tuple(int(item) for item in items) diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py index 47bc206f3..b463541c9 100644 --- a/src/sampletones_core/project/voices/envelopes.py +++ b/src/sampletones_core/project/voices/envelopes.py @@ -1,4 +1,4 @@ -from typing import Annotated, Dict, Tuple +from typing import Annotated, Dict from pydantic import BaseModel, ConfigDict, Field @@ -10,6 +10,7 @@ MAX_VOLUME, SILENT_VOLUME, ) +from sampletones_core.features.envelope import Envelope VolumeItem = Annotated[int, Field(ge=SILENT_VOLUME, le=MAX_VOLUME)] ArpeggioItem = Annotated[int, Field(ge=ARPEGGIO_MIN, le=ARPEGGIO_MAX)] @@ -27,47 +28,47 @@ class InstrumentEnvelopes(BaseModel): Attributes: volume: Output level per tick. - arpeggio: Offset from the instrument's root per tick. + arpeggio: Offset from the instrument's initial pitch per tick. duty_cycle: Pulse waveform, or noise mode, per tick. """ model_config = ConfigDict(frozen=True, extra="forbid") - volume: Tuple[VolumeItem, ...] = () - arpeggio: Tuple[ArpeggioItem, ...] = () - duty_cycle: Tuple[DutyCycleItem, ...] = () + volume: Envelope[VolumeItem] = Envelope[VolumeItem]() + arpeggio: Envelope[ArpeggioItem] = Envelope[ArpeggioItem]() + duty_cycle: Envelope[DutyCycleItem] = Envelope[DutyCycleItem]() @property - def envelope_map(self) -> Dict[FeatureKey, Tuple[int, ...]]: + def envelope_map(self) -> Dict[FeatureKey, Envelope[int]]: return { FeatureKey.VOLUME: self.volume, FeatureKey.ARPEGGIO: self.arpeggio, FeatureKey.DUTY_CYCLE: self.duty_cycle, } - def envelope(self, feature_key: FeatureKey) -> Tuple[int, ...]: - """The items one dimension carries, empty where the instrument leaves it to the channel. + def envelope(self, feature_key: FeatureKey) -> Envelope[int]: + """The dimension one feature names, empty where the instrument leaves it to the channel. Args: feature_key: The dimension read. Returns: - Tuple[int, ...]: That dimension's items. + Envelope[int]: That dimension's items and loop point. Raises: KeyError: If ``feature_key`` names a dimension an instrument does not write. """ return self.envelope_map[feature_key] - def with_envelope(self, feature_key: FeatureKey, items: Tuple[int, ...]) -> "InstrumentEnvelopes": + def with_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> "InstrumentEnvelopes": """The envelopes with one dimension replaced. Args: feature_key: The dimension written. - items: What that dimension now carries; empty leaves it to the channel. + envelope: What that dimension now carries; empty items leave it to the channel. Returns: - InstrumentEnvelopes: The envelopes carrying ``items`` for ``feature_key``. + InstrumentEnvelopes: The envelopes carrying ``envelope`` for ``feature_key``. Raises: KeyError: If ``feature_key`` names a dimension an instrument does not write. @@ -75,9 +76,9 @@ def with_envelope(self, feature_key: FeatureKey, items: Tuple[int, ...]) -> "Ins if feature_key not in self.envelope_map: raise KeyError(feature_key) - return self.model_copy(update={feature_key.value: items}) + return self.model_copy(update={feature_key.value: envelope}) @property def frame_count(self) -> int: """The ticks the envelopes describe, taken from the longest dimension.""" - return max((len(items) for items in self.envelope_map.values()), default=0) + return max((len(envelope.items) for envelope in self.envelope_map.values()), default=0) diff --git a/src/sampletones_core/project/voices/instrument.py b/src/sampletones_core/project/voices/instrument.py index 24599fe00..95a221f30 100644 --- a/src/sampletones_core/project/voices/instrument.py +++ b/src/sampletones_core/project/voices/instrument.py @@ -1,8 +1,7 @@ from functools import cached_property -from typing import Dict, List, Literal, Optional, Self, Tuple +from typing import Dict, List, Literal, Self, Tuple from uuid import uuid4 -import numpy as np from pydantic import BaseModel, ConfigDict, Field from sampletones_core.constants.enums import ChannelName, FeatureKey @@ -21,6 +20,7 @@ supported_features, supports, ) +from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import InstructionUnion from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -44,10 +44,9 @@ class Instrument(BaseModel): Attributes: id: Stable id the tracker rows reference. name: The name the voice list shows. - envelopes: The per-tick values every channel reads. - root_pitch: The note a tonal channel measures the arpeggio against. - root_period: The period the noise channel measures the arpeggio against. - loop_point: The tick the envelopes repeat from, or ``None`` where they play once. + envelopes: The per-tick values every channel reads, each with its own loop point. + initial_pitch: The note a tonal channel measures the arpeggio against. + initial_period: The period the noise channel measures the arpeggio against. """ model_config = ConfigDict(extra="forbid") @@ -56,51 +55,40 @@ class Instrument(BaseModel): id: str = Field(default_factory=_new_instrument_id, description="Stable instrument id.") name: str = Field(..., description="Instrument name.") envelopes: InstrumentEnvelopes = Field(default_factory=InstrumentEnvelopes) - root_pitch: int = Field( + initial_pitch: int = Field( default=RESTING_REFERENCE_PITCH, ge=MIN_PITCH, le=MAX_PITCH, description="Note a tonal channel measures the arpeggio envelope against.", ) - root_period: int = Field( + initial_period: int = Field( default=RESTING_REFERENCE_PERIOD, ge=0, le=MAX_PERIOD, description="Period the noise channel measures the arpeggio envelope against.", ) - loop_point: Optional[int] = Field( - default=None, - ge=0, - description="Tick the envelopes repeat from, or None where they play once.", - ) - - @property - def loops(self) -> bool: - """Whether the instrument repeats its envelopes rather than playing them once.""" - return self.loop_point is not None def reference(self, channel_name: ChannelName) -> int: """The value this channel measures the arpeggio envelope against.""" return channel_reference( channel_name, - pitch=self.root_pitch, - period=self.root_period, + pitch=self.initial_pitch, + period=self.initial_period, ) def held_features(self, channel_name: ChannelName) -> Tuple[FeatureKey, ...]: """The dimensions this channel governs: those it offers and the instrument leaves empty.""" kind = CHANNEL_GENERATOR_KIND[channel_name] return tuple( - feature_key - for feature_key in supported_features(kind) - if not self.envelopes.envelope_map.get(feature_key, ()) + feature_key for feature_key in supported_features(kind) if not self.envelopes.envelope(feature_key).written ) def features(self, channel_name: ChannelName) -> Features: - """The envelopes as this channel reads them, measured against the instrument's root. + """The envelopes as this channel reads them, measured against the instrument's pitch. A channel takes the dimensions its generator offers and leaves the rest absent, which is - what makes one set of envelopes serve every channel. + what makes one set of envelopes serve every channel. Each dimension travels with the item + it repeats from, so a channel reads a loop the way the instrument wrote it. Args: channel_name: The channel reading the instrument. @@ -108,16 +96,7 @@ def features(self, channel_name: ChannelName) -> Features: Returns: Features: The per-dimension envelopes for that channel. """ - kind = CHANNEL_GENERATOR_KIND[channel_name] - length = self.envelopes.frame_count - return Features( - initial_pitch=self.reference(channel_name), - volume=_items(self.envelopes.volume, length), - arpeggio=_items(self.envelopes.arpeggio, length), - pitch=None, - hi_pitch=None, - duty_cycle=(_items(self.envelopes.duty_cycle, length) if supports(kind, FeatureKey.DUTY_CYCLE) else None), - ) + return Features.of(self.reference(channel_name), self._offered(channel_name)) def instrument_features(self) -> Features: """The envelopes as a tracker instrument holds them: every dimension the instrument writes. @@ -126,17 +105,39 @@ def instrument_features(self) -> Features: reads what it can of them, so this is the whole of what a tracker export writes. Returns: - Features: The envelopes, measured against the instrument's tonal root. + Features: The envelopes, measured against the instrument's tonal pitch. """ - length = self.envelopes.frame_count - return Features( - initial_pitch=self.root_pitch, - volume=_items(self.envelopes.volume, length), - arpeggio=_items(self.envelopes.arpeggio, length), - pitch=None, - hi_pitch=None, - duty_cycle=_items(self.envelopes.duty_cycle, length), - ) + return Features.of(self.initial_pitch, self.envelopes.envelope_map) + + def instruction_at(self, channel_name: ChannelName, tick: int) -> InstructionUnion: + """The frame this channel sounds at any tick of a held note. + + Every dimension is defined at every tick — it circles from its loop point or holds its + last item — so a note goes on sounding for as long as a row asks for it, and a volume + envelope ending at silence is what releases it. + + Args: + channel_name: The channel sounding the instrument. + tick: Ticks since the note started. + + Returns: + InstructionUnion: The frame standing at that tick. + """ + standing = { + feature_key: Envelope[int](items=(item,)) if (item := envelope.at(tick)) is not None else Envelope[int]() + for feature_key, envelope in self._offered(channel_name).items() + } + features = Features.of(self.reference(channel_name), standing) + return CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)[0] + + def _offered(self, channel_name: ChannelName) -> Dict[FeatureKey, Envelope[int]]: + """The dimensions this channel's generator reads, as the instrument writes them.""" + kind = CHANNEL_GENERATOR_KIND[channel_name] + return { + feature_key: envelope + for feature_key, envelope in self.envelopes.envelope_map.items() + if supports(kind, feature_key) + } @cached_property def _instructions(self) -> Dict[ChannelName, List[InstructionUnion]]: @@ -161,13 +162,12 @@ def invalidate(self) -> None: self.__dict__.pop("_instructions", None) def clone(self) -> Self: - """Return an independent copy with a fresh id, carrying the name, root and envelopes.""" + """Return an independent copy with a fresh id, carrying the name, pitch and envelopes.""" return type(self)( name=self.name, envelopes=self.envelopes, - root_pitch=self.root_pitch, - root_period=self.root_period, - loop_point=self.loop_point, + initial_pitch=self.initial_pitch, + initial_period=self.initial_period, ) def __hash__(self) -> int: @@ -178,24 +178,3 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return f"Instrument(id={self.id!r}, name={self.name!r})" - - -def _items(envelope: Tuple[int, ...], length: int) -> np.ndarray: - """One dimension brought to the length the instrument's longest runs, holding its final value. - - A tracker advances each sequence on a counter of its own, so a dimension shorter than the rest - would circle at its own pace once the instrument repeats. Running every written dimension the same - length keeps a tracker sounding the instrument the way the engine here plays it, where a dimension - holds its final value for as long as the note lasts. - - Args: - envelope: The items the dimension states, empty where the channel governs it. - length: The ticks the instrument's longest dimension runs. - - Returns: - np.ndarray: The dimension's items, empty where the channel governs it. - """ - if not envelope: - return np.array([], dtype=np.int8) - - return np.array(envelope + (envelope[-1],) * (length - len(envelope)), dtype=np.int8) diff --git a/src/sampletones_core/types/feature.py b/src/sampletones_core/types/feature.py deleted file mode 100644 index 3e257f4d2..000000000 --- a/src/sampletones_core/types/feature.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Dict, Union - -import numpy as np - -from sampletones_core.constants.enums import FeatureKey - -FeatureValue = Union[int, np.ndarray] -FeatureMap = Dict[FeatureKey, FeatureValue] diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index 41c59d9ba..9854d39bf 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -128,7 +128,7 @@ def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optio """The tick a request's song returns to once it ends. A song repeats from its first tick where every slice it carries repeats, and ends at its - last tick where any slice plays its envelopes once. + last tick where any slice holds its envelopes out instead. Args: instruments: The slices the song carries. @@ -136,12 +136,17 @@ def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optio Returns: Optional[int]: The tick to return to, or ``None`` where the song stops at its end. """ - if instruments and all(instrument.loop_point is not None for instrument in instruments): + if instruments and all(_repeats(instrument) for instrument in instruments): return SONG_START return None +def _repeats(instrument: InstrumentExport) -> bool: + """Whether a slice circles rather than holding its envelopes out.""" + return any(envelope.loops for envelope in instrument.features.envelopes.values()) + + def song_from_sample( request: SampleExport, report: CodecReporter = SILENT_REPORTER, diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 1d002dcbe..674491476 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -71,7 +71,6 @@ def sample_request(sample: Sample) -> SampleExport: name=instrument_slice_name(sample.name, channel), channel=channel, features=features, - loop_point=sample.loop_point, nes_frequency=config.nes_frequency, tuning=config.tuning, ) diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py index afb4620d8..ffd7856a5 100644 --- a/tests/integration/sampletones_application/services/test_export.py +++ b/tests/integration/sampletones_application/services/test_export.py @@ -61,7 +61,6 @@ def instrument_export(name: str, features: Features) -> InstrumentExport: name=name, channel=ChannelName.PULSE1, features=features, - loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index 90ea39be5..d6f492546 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -11,6 +11,7 @@ from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction from tests.suite.scenario import BaseTestScenario, ScenarioStep @@ -43,9 +44,8 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - pulse_features.volume, + pulse_features, ) assert len(results) == 1 @@ -59,9 +59,8 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - pulse_features.volume, + pulse_features, ) emitted = results[0].value @@ -76,9 +75,8 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - pulse_features.volume, + pulse_features, ) approximation = reconstruction_data.reconstruction.approximations.get( @@ -92,27 +90,29 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - pulse_features.volume, + pulse_features, ) instructions = reconstruction_data.reconstruction.get_channel_instructions(ChannelName.PULSE1) assert len(instructions) > 0 - def test_run_feature_mutation_is_applied_before_synthesis(self, reconstruction_data, pulse_features) -> None: - new_volume = np.zeros(len(pulse_features.volume), dtype=np.int8) - service = RegenerationService() - - service._run( - reconstruction_data.reconstruction, - ChannelName.PULSE1, - pulse_features, + def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_data, pulse_features) -> None: + """The caller writes the edit into the envelopes, so the service renders what it is given.""" + silenced = pulse_features.with_envelope( FeatureKey.VOLUME, - new_volume, + Envelope(items=(0,) * len(pulse_features.volume.items)), ) + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) - assert (pulse_features.volume == new_volume).all() + service._run(reconstruction_data.reconstruction, ChannelName.PULSE1, FeatureKey.VOLUME, silenced) + + assert isinstance(results[0], ServiceSuccess) + assert all( + not instruction.on for instruction in results[0].value.reconstruction.instructions[ChannelName.PULSE1] + ) def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_data) -> None: service = RegenerationService() @@ -122,9 +122,8 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - {}, FeatureKey.VOLUME, - np.zeros(4, dtype=np.int8), + {}, ) assert len(results) == 1 @@ -138,9 +137,8 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ service.start( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - pulse_features.volume, + pulse_features, ) assert len(results) == 1 @@ -164,12 +162,15 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None: results: List[Any] = [] service.subscribe(results.append) + edited = context.features.with_envelope( + FeatureKey.ARPEGGIO, + Envelope(items=tuple(int(offset) for offset in arpeggio)), + ) service._run( context.reconstruction, ChannelName.PULSE1, - context.features, FeatureKey.ARPEGGIO, - arpeggio, + edited, ) assert len(results) == 1 @@ -201,7 +202,7 @@ def build() -> ArpeggioEditContext: def check_the_starting_reference(context: ArpeggioEditContext) -> None: assert context.features.initial_pitch == BASE_PITCH - assert context.features.arpeggio.tolist() == [0] + assert list(context.features.arpeggio.items) == [0] def raise_the_first_frame_an_octave(context: ArpeggioEditContext) -> None: _edit_arpeggio(context, np.array([OCTAVE, 0, 0, 0], dtype=np.int8)) @@ -210,16 +211,16 @@ def raise_the_first_frame_an_octave(context: ArpeggioEditContext) -> None: def reload_the_edited_features(context: ArpeggioEditContext) -> None: context.features = FeatureData.load(context.reconstruction)[ChannelName.PULSE1] assert context.features.initial_pitch == BASE_PITCH - assert context.features.arpeggio.tolist() == [OCTAVE, 0] + assert list(context.features.arpeggio.items) == [OCTAVE, 0] def clear_the_envelope(context: ArpeggioEditContext) -> None: - _edit_arpeggio(context, np.zeros(len(context.features.arpeggio), dtype=np.int8)) + _edit_arpeggio(context, np.zeros(len(context.features.arpeggio.items), dtype=np.int8)) assert _pitches(context) == [BASE_PITCH] * 4 def check_the_reference_held(context: ArpeggioEditContext) -> None: reloaded = FeatureData.load(context.reconstruction)[ChannelName.PULSE1] assert reloaded.initial_pitch == BASE_PITCH - assert reloaded.arpeggio.tolist() == [0] + assert list(reloaded.arpeggio.items) == [0] scenario = BaseTestScenario( label="arpeggio_edit_keeps_the_sample_pitch", @@ -288,9 +289,8 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - pulse_features.volume, + pulse_features, ) # The real queue defers delivery until a frame is pumped; nothing has run yet. This also fails @@ -315,13 +315,12 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, CallbackQueue.add(lambda: None, priority=SETTLE_PRIORITY, delay=SETTLE_DELAY_FRAMES) - new_volume = np.zeros(len(pulse_features.volume), dtype=np.int8) + new_volume = np.zeros(len(pulse_features.volume.items), dtype=np.int8) service._run( reconstruction_data.reconstruction, ChannelName.PULSE1, - pulse_features, FeatureKey.VOLUME, - new_volume, + pulse_features, ) for _ in range(DELIVERY_BUDGET_FRAMES): diff --git a/tests/suite/player.py b/tests/suite/player.py index 1ca99a7b9..424081503 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -9,6 +9,7 @@ from sampletones_core.constants.general import DUTY_CYCLES from sampletones_core.exporters import Features from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction @@ -212,11 +213,11 @@ def player_features( """Envelopes sounding one pitch at full volume for ``frames`` ticks.""" return Features( initial_pitch=pitch, - volume=np.full(frames, PLAYER_FULL_VOLUME, dtype=int), - arpeggio=np.zeros(frames, dtype=int), + volume=Envelope(items=(PLAYER_FULL_VOLUME,) * frames), + arpeggio=Envelope(items=(0,) * frames), pitch=None, hi_pitch=None, - duty_cycle=np.zeros(frames, dtype=int) if duty_cycle else None, + duty_cycle=Envelope(items=(0,) * frames) if duty_cycle else None, ) @@ -234,11 +235,13 @@ def varied_features( generator = np.random.default_rng(PLAYER_VARIED_SEED) return Features( initial_pitch=pitch, - volume=generator.integers(0, PLAYER_FULL_VOLUME + 1, frames), - arpeggio=generator.integers(-OCTAVE_SEMITONES, OCTAVE_SEMITONES + 1, frames), + volume=Envelope(items=tuple(generator.integers(0, PLAYER_FULL_VOLUME + 1, frames).tolist())), + arpeggio=Envelope(items=tuple(generator.integers(-OCTAVE_SEMITONES, OCTAVE_SEMITONES + 1, frames).tolist())), pitch=None, hi_pitch=None, - duty_cycle=generator.integers(0, len(DUTY_CYCLES), frames) if duty_cycle else None, + duty_cycle=( + Envelope(items=tuple(generator.integers(0, len(DUTY_CYCLES), frames).tolist())) if duty_cycle else None + ), ) @@ -255,13 +258,22 @@ def player_instrument( return InstrumentExport( name=name, channel=channel, - features=features, - loop_point=WHOLE_LOOP_POINT if loop else None, + features=looping_features(features) if loop else features, nes_frequency=nes_frequency, tuning=tuning, ) +def looping_features(features: Features) -> Features: + """The envelopes with every dimension they write circling from its first item.""" + looping = features + for feature_key, envelope in features.envelopes.items(): + if envelope.written: + looping = looping.with_envelope(feature_key, envelope.model_copy(update={"loop_point": WHOLE_LOOP_POINT})) + + return looping + + def player_sample( name: str, instruments: Sequence[InstrumentExport], diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index bbba94a13..35bd4cfa3 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -69,6 +69,7 @@ HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.voice import ImportedVoice, InstrumentOmission from sampletones_core.project.song_position import SongPosition from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -118,8 +119,7 @@ def coordinator() -> SequencerTabCoordinator: IMPORTED_VOICE: Final[Instrument] = Instrument( name="Lead", - envelopes=InstrumentEnvelopes(volume=(15, 8, 0)), - loop_point=0, + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 8, 0), loop_point=0)), ) @@ -343,7 +343,7 @@ class TestTakingAChannelAsAnInstrument: @staticmethod def _taken(samples_coordinator: SequencerTabCoordinator) -> Instrument: - instrument = Instrument(name="Bass (triangle)", envelopes=InstrumentEnvelopes(volume=(15,))) + instrument = Instrument(name="Bass (triangle)", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,)))) samples_coordinator._sequencer_voices_logic.instrument_from_channel.return_value = instrument return instrument diff --git a/tests/unit/sampletones_application/logic/export/instrument/test_logic.py b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py index 442db1d82..15e4b20a4 100644 --- a/tests/unit/sampletones_application/logic/export/instrument/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py @@ -144,10 +144,9 @@ def test_everything_the_source_states_reaches_the_request( logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", source) request = export_service.export_instrument.call_args.args[2] - assert (request.channel, request.features, request.loop_point) == ( + assert (request.channel, request.features) == ( source.channel, source.features, - source.loop_point, ) assert (request.nes_frequency, request.tuning) == (source.nes_frequency, source.tuning) diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 6e9d18d14..cf0d159e0 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -8,6 +8,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer from sampletones_core.project.voices.creation import new_instrument @@ -517,24 +518,24 @@ def test_writing_an_envelope_reaches_the_frames_the_instrument_sounds(self) -> N controller = _controller() instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 10)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 10))) - assert instrument.envelopes.volume == (15, 10) + assert instrument.envelopes.volume.items == (15, 10) assert len(instrument.instructions(ChannelName.PULSE1)) == 2 def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None: controller = _controller() instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15,)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15,))) - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, ()) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=())) assert FeatureKey.VOLUME in instrument.held_features(ChannelName.PULSE1) def test_moving_the_roots_reaches_the_frames(self) -> None: controller = _controller() instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15,)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15,))) controller.set_instrument_root(instrument.id, pitch=48, period=3) @@ -552,7 +553,7 @@ def test_a_sample_takes_no_instrument_edit( sample = controller.add_sample(reconstruction_factory(), name="bass") with pytest.raises(TypeError): - controller.set_instrument_envelope(sample.id, FeatureKey.VOLUME, (15,)) + controller.set_instrument_envelope(sample.id, FeatureKey.VOLUME, Envelope(items=(15,))) def test_an_instrument_takes_no_reconstruction(self) -> None: controller = _controller() @@ -564,7 +565,7 @@ def test_an_instrument_takes_no_reconstruction(self) -> None: def test_an_instrument_duplicates_into_a_voice_of_its_own(self) -> None: controller = _controller() instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15,)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15,))) clone = controller.duplicate_voice(instrument.id) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index 833fce232..fdd4672f2 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -1,7 +1,6 @@ from typing import Final, Tuple from unittest.mock import MagicMock -import numpy as np import pytest from sampletones_application.logic.project.controller import ProjectController @@ -11,6 +10,7 @@ from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT @@ -22,8 +22,8 @@ def _features() -> Features: return Features( initial_pitch=ROOT_PITCH, - volume=np.array([15], dtype=np.int8), - arpeggio=np.array([0], dtype=np.int8), + volume=Envelope(items=(15,)), + arpeggio=Envelope(items=(0,)), pitch=None, hi_pitch=None, duty_cycle=None, @@ -76,7 +76,7 @@ def test_an_instrument_answers_with_what_it_states( edit = editor.edited_instrument() assert isinstance(edit, InstrumentEdit) assert (edit.voice_id, edit.name) == (instrument.id, "lead") - assert (edit.root_pitch, edit.root_period) == (ROOT_PITCH, ROOT_PERIOD) + assert (edit.initial_pitch, edit.initial_period) == (ROOT_PITCH, ROOT_PERIOD) def test_opening_an_instrument_closes_the_reconstruction_the_tab_held( self, @@ -127,9 +127,9 @@ def test_an_envelope_reaches_the_instrument( instrument = controller.add_instrument(new_instrument("lead")) editor.edit_instrument(instrument.id) - editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) + editor.write_envelope(FeatureKey.VOLUME, Envelope(items=VOLUME)) - assert instrument.envelopes.volume == VOLUME + assert instrument.envelopes.volume.items == VOLUME def test_the_roots_reach_the_instrument( self, @@ -141,20 +141,22 @@ def test_the_roots_reach_the_instrument( editor.write_roots(pitch=ROOT_PITCH, period=ROOT_PERIOD) - assert (instrument.root_pitch, instrument.root_period) == (ROOT_PITCH, ROOT_PERIOD) + assert (instrument.initial_pitch, instrument.initial_period) == (ROOT_PITCH, ROOT_PERIOD) - def test_the_loop_point_reaches_the_instrument( + def test_a_point_written_on_an_envelope_reaches_the_instrument( self, editor: InstrumentEditor, controller: ProjectController, ) -> None: + """A dimension carries the item it repeats from, so an edit writes both at once.""" instrument = controller.add_instrument(new_instrument("lead")) editor.edit_instrument(instrument.id) - editor.write_loop_point(WHOLE_LOOP_POINT) + editor.write_envelope(FeatureKey.VOLUME, Envelope(items=(15, 8), loop_point=WHOLE_LOOP_POINT)) - assert instrument.loop_point == WHOLE_LOOP_POINT + assert instrument.envelopes.volume.items == (15, 8) + assert instrument.envelopes.volume.loop_point == WHOLE_LOOP_POINT def test_a_write_with_no_instrument_in_front_is_refused(self, editor: InstrumentEditor) -> None: with pytest.raises(TypeError): - editor.write_envelope(FeatureKey.VOLUME, np.array(VOLUME, dtype=np.int8)) + editor.write_envelope(FeatureKey.VOLUME, Envelope(items=VOLUME)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py index 81eba80f7..698659b9a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py @@ -44,7 +44,7 @@ def test_loaded_features_include_initial_pitch( feature_data: FeatureData, ) -> None: for features in feature_data.channels.values(): - assert features.get(FeatureKey.INITIAL_PITCH) is not None + assert features.initial_pitch is not None class TestFeatureDataQueries: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 1e618a82e..061f2c5f4 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -19,6 +19,7 @@ ) from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import ( features_footprint, total_footprint, @@ -158,9 +159,7 @@ def test_the_size_is_the_one_a_one_shot_export_writes( footprint = received[0].footprint assert footprint is not None expected = total_footprint( - features_footprint(features, loop_point=None) - for features in feature_data.channels.values() - if features.has_frames + features_footprint(features) for features in feature_data.channels.values() if features.has_frames ) assert footprint.total_bytes == expected.total_bytes @@ -184,10 +183,10 @@ def test_an_envelope_edit_is_measured_as_it_arrives( ) edited = feature_data.channels[ChannelName.PULSE1].model_copy(deep=True) - edited[FeatureKey.VOLUME] = volume + edited = edited.with_envelope(FeatureKey.VOLUME, Envelope(items=tuple(int(item) for item in volume))) footprint = received[0].footprint assert footprint is not None - assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited, loop_point=None).total_bytes + assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited).total_bytes def test_a_bar_edit_is_measured_as_it_arrives( self, @@ -266,10 +265,12 @@ def test_forwards_generator_pitch_feature_and_value( callback = MagicMock() instruments_logic.on_reconstruction_instrument_updated = callback instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, 61) - channel_name, _features, feature_key, value = callback.call_args.args + channel_name, feature_key, _ = callback.call_args.args + channel_features = mock_reconstruction_manager.current_features.channels[ChannelName.PULSE1] + assert channel_name == ChannelName.PULSE1 assert feature_key == FeatureKey.INITIAL_PITCH - assert value == 61 + channel_features.model_copy.assert_called_once_with(update={"initial_pitch": 61}) class TestReconstructionInstrumentsLogicHandleBarPoint: @@ -333,7 +334,7 @@ def instrument_logic( mock_reconstruction_manager.current_features = None editor = InstrumentEditor(mock_reconstruction_manager, project_controller) instrument = project_controller.add_instrument(new_instrument("lead")) - project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12)) + project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12))) editor.edit_instrument(instrument.id) return ReconstructionInstrumentsLogic(editor, scheduling=scheduling) @@ -369,7 +370,7 @@ def test_an_instrument_with_nothing_written_stands_by( ) -> None: """An export writes what has frames, so a voice holding none is offered no export.""" instrument = project_controller.project.voices[0] - project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, ()) + project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=())) received: List[ReconstructionInstrumentsViewModel] = [] instrument_logic.on_view_changed = received.append @@ -404,7 +405,7 @@ def test_an_envelope_edit_reaches_the_instrument_without_a_regeneration( ) instrument = project_controller.project.voices[project_controller.project.voices[0].id] - assert instrument.envelopes.arpeggio == (0, 7) + assert instrument.envelopes.arpeggio.items == (0, 7) assert regenerated == [] def test_the_pitch_stepper_moves_the_instruments_tonal_root( @@ -414,16 +415,7 @@ def test_the_pitch_stepper_moves_the_instruments_tonal_root( ) -> None: instrument_logic.handle_pitch_value_changed(INSTRUMENT_CHANNEL, 48) - assert project_controller.project.voices[0].root_pitch == 48 - - def test_the_loop_point_reaches_the_instrument( - self, - instrument_logic: ReconstructionInstrumentsLogic, - project_controller: ProjectController, - ) -> None: - instrument_logic.handle_instrument_loop_point_changed(WHOLE_LOOP_POINT) - - assert project_controller.project.voices[0].loop_point == WHOLE_LOOP_POINT + assert project_controller.project.voices[0].initial_pitch == 48 def test_the_figure_measures_the_one_instrument_it_exports( self, @@ -437,10 +429,4 @@ def test_the_figure_measures_the_one_instrument_it_exports( instrument = project_controller.project.voices[0] assert received[-1].footprint is not None - assert ( - received[-1].footprint.total_bytes - == features_footprint( - instrument.instrument_features(), - loop_point=instrument.loop_point, - ).total_bytes - ) + assert received[-1].footprint.total_bytes == features_footprint(instrument.instrument_features()).total_bytes diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index de4bdd7ff..a5e1a0065 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -673,7 +673,7 @@ def test_a_reconstruction_slice_plays_its_envelopes_once( exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) assert exportable is not None - assert exportable.source.loop_point is None + assert all(not envelope.loops for envelope in exportable.source.features.envelopes.values()) def test_the_slice_carries_the_reconstructions_tuning( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index b61c65b1a..69af8bf66 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -13,6 +13,7 @@ from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import ( features_footprint, reconstruction_footprints, @@ -229,15 +230,13 @@ def test_it_measures_the_sample_under_its_own_loop_flag( footprint = logic.build_voice_footprint(sample.id) - assert footprint == SampleFootprintViewModel.from_footprints( - reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT) - ) + assert footprint == SampleFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) - def test_a_looping_sample_costs_less_than_a_one_shot( + def test_a_looping_sample_costs_what_a_one_shot_costs( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - """A looping instrument shares the shortest dimension's length, so it stores fewer items.""" + """Each dimension keeps the length it was written at, so circling costs a sample nothing.""" controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") one_shot = logic.build_voice_footprint(sample.id) @@ -246,7 +245,7 @@ def test_a_looping_sample_costs_less_than_a_one_shot( looping = logic.build_voice_footprint(sample.id) assert one_shot is not None and looping is not None - assert looping.total_bytes < one_shot.total_bytes + assert looping.total_bytes == one_shot.total_bytes def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: """A channel's figure is the cost of its own instrument, and the channels differ. @@ -299,8 +298,8 @@ def test_the_envelopes_come_across_as_the_channel_played_them(self) -> None: instrument = logic.instrument_from_channel(sample.id, ChannelName.TRIANGLE) assert instrument is not None - assert instrument.envelopes.volume == tuple(int(item) for item in played.volume) - assert instrument.envelopes.arpeggio == tuple(int(item) for item in played.arpeggio) + assert instrument.envelopes.volume == played.volume + assert instrument.envelopes.arpeggio == played.arpeggio def test_the_instrument_is_measured_against_the_reference_that_channel_read(self) -> None: controller, logic = _logic() @@ -322,15 +321,15 @@ def test_the_instrument_is_named_after_the_channel_it_came_from(self) -> None: assert instrument is not None assert instrument.name == instrument_slice_name("bell", ChannelName.NOISE) - def test_the_instrument_repeats_the_way_the_sample_does(self) -> None: + def test_the_instrument_holds_each_dimension_out_past_its_items(self) -> None: + """A recorded channel rests on the value it last played, which is what a halt states.""" controller, logic = _logic() sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell") - controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) instrument = logic.instrument_from_channel(sample.id, ChannelName.PULSE1) assert instrument is not None - assert instrument.loop_point == WHOLE_LOOP_POINT + assert all(envelope.loop_point is None for envelope in instrument.envelopes.envelope_map.values()) def test_taking_a_channel_leaves_the_pool_as_it_stands(self) -> None: """The instrument is written here and added by whoever asked, inside a history entry.""" @@ -454,24 +453,18 @@ def test_an_instrument_is_listed_beside_the_samples_that_were_added( def test_an_instrument_is_measured_as_the_one_export_it_writes(self) -> None: controller, logic = _logic() instrument = logic.add_new_instrument("lead") - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12, 9)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12, 9))) footprint = logic.build_voice_footprint(instrument.id) assert footprint is not None - assert ( - footprint.total_bytes - == features_footprint( - instrument.instrument_features(), - loop_point=instrument.loop_point, - ).total_bytes - ) + assert footprint.total_bytes == features_footprint(instrument.instrument_features()).total_bytes assert [instrument.channel for instrument in footprint.instruments] == [None] def test_an_instrument_previews_through_the_pulse_channel(self) -> None: controller, logic, session_manager, audio_device_manager = _logic_with_mocks() instrument = logic.add_new_instrument("lead") - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, (15, 12)) + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12))) logic.play_voice(instrument.id) @@ -529,8 +522,8 @@ def test_the_envelopes_the_file_states_reach_the_voice(self, tmp_path: Path) -> voice = logic.read_instrument(filepath).voice - assert voice.envelopes.volume == (15, 8, 0) - assert voice.envelopes.arpeggio == (0, 3, 7) + assert voice.envelopes.volume.items == (15, 8, 0) + assert voice.envelopes.arpeggio.items == (0, 3, 7) def test_the_file_names_the_voice(self, tmp_path: Path) -> None: filepath = _instrument_file( diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py index 93bc188b8..69e34be80 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -4,6 +4,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument @@ -27,7 +28,7 @@ def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]: def _instrument(controller: ProjectController) -> Instrument: instrument = controller.add_instrument(new_instrument("lead")) controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) - instrument.envelopes = InstrumentEnvelopes(volume=(15,)) + instrument.envelopes = InstrumentEnvelopes(volume=Envelope(items=(15,))) instrument.invalidate() return instrument diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py index c4f604035..bb1954496 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py @@ -4,6 +4,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.note_off import NoteOff @@ -40,7 +41,7 @@ def test_an_instrument_takes_the_step_that_reaches_the_note(self) -> None: controller, logic = _logic() instrument = controller.add_instrument(new_instrument("lead")) controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=8) - instrument.envelopes = InstrumentEnvelopes(volume=(15,)) + instrument.envelopes = InstrumentEnvelopes(volume=Envelope(items=(15,))) instrument.invalidate() _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 01a3a4e28..999c3c14b 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -31,6 +31,7 @@ ) from sampletones_core.exports.scope import ExportScope from sampletones_core.exports.stage import ExportStage +from sampletones_core.features.envelope import Envelope from sampletones_core.project.project import Project from sampletones_shared.music import Tuning @@ -121,13 +122,12 @@ def build_instrument(name: str = "Lead") -> InstrumentExport: channel=ChannelName.PULSE1, features=Features( initial_pitch=60, - volume=np.full(8, 15, dtype=int), - arpeggio=np.zeros(8, dtype=int), + volume=Envelope(items=(15,) * 8), + arpeggio=Envelope(items=(0,) * 8), pitch=None, hi_pitch=None, duty_cycle=None, ), - loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index c8686e912..ec2f7a447 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -14,6 +14,7 @@ ) from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction from tests.conftest import ReconstructionFactory @@ -24,32 +25,16 @@ ResultCallback: TypeAlias = Callable[[Any], None] -class FakeFeatures(Dict[Any, Any]): - """Stands in for ``Features``: records the edited dimension and carries a reference pitch. - - Assigning ``FeatureKey.INITIAL_PITCH`` moves the reference pitch, matching the real model, - so the pitch stepper's edit is observable through ``initial_pitch``. The dimensions left to - the channel are read the same way the real model reports them: those whose envelope is empty. - """ - - def __init__(self, initial_pitch: int) -> None: - super().__init__() - self.initial_pitch = initial_pitch - - @property - def held_features(self) -> Tuple[FeatureKey, ...]: - return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) - - def __setitem__(self, feature_key: Any, value: Any) -> None: - if feature_key == FeatureKey.INITIAL_PITCH: - self.initial_pitch = value - else: - super().__setitem__(feature_key, value) - - @pytest.fixture -def features() -> FakeFeatures: - return FakeFeatures(REFERENCE_PITCH) +def features() -> Features: + return Features( + initial_pitch=REFERENCE_PITCH, + volume=Envelope[int](items=(15, 0)), + arpeggio=Envelope[int](items=(0, 0)), + pitch=None, + hi_pitch=None, + duty_cycle=Envelope[int](items=(0, 0)), + ) @pytest.fixture @@ -93,9 +78,8 @@ def test_start_when_not_canceled_returns_true( result = service.start( reconstruction, synthesis_mocks.channel_name, - cast(Features, {}), FeatureKey.VOLUME, - 1, + cast(Features, {}), ) assert result is True @@ -103,7 +87,7 @@ def test_start_when_canceled_returns_false(self) -> None: service = RegenerationService() service.cancel() - result = service.start(MagicMock(), MagicMock(), cast(Features, {}), MagicMock(), MagicMock()) + result = service.start(MagicMock(), MagicMock(), FeatureKey.VOLUME, cast(Features, {})) assert result is False @@ -116,9 +100,8 @@ def test_start_when_canceled_does_not_emit(self) -> None: service.start( MagicMock(), MagicMock(), - cast(Features, {}), - MagicMock(), MagicMock(), + cast(Features, {}), ) assert results == [] @@ -134,9 +117,8 @@ def test_start_reports_a_submit_failure(self) -> None: result = service.start( MagicMock(), MagicMock(), - cast(Features, {}), - MagicMock(), MagicMock(), + cast(Features, {}), ) assert result is False @@ -172,9 +154,8 @@ def test_run_when_canceled_emits_service_canceled(self) -> None: service._run( MagicMock(), MagicMock(), - cast(Features, {}), - MagicMock(), MagicMock(), + cast(Features, {}), ) assert len(results) == 1 @@ -184,7 +165,7 @@ def test_run_success_emits_service_success( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: service = RegenerationService() results: List[Any] = [] @@ -193,9 +174,8 @@ def test_run_success_emits_service_success( service._run( reconstruction, synthesis_mocks.channel_name, - cast(Features, features), FeatureKey.VOLUME, - 1, + features, ) assert len(results) == 1 @@ -206,40 +186,34 @@ def test_run_success_emits_service_success( assert outcome.channel_name is synthesis_mocks.channel_name assert outcome.feature_key is FeatureKey.VOLUME - def test_run_updates_feature_before_synthesis( + def test_run_regenerates_from_the_envelopes_it_is_handed( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: + """The caller writes the edit into the envelopes, so the service renders what it is given.""" service = RegenerationService() - feature_key = FeatureKey.VOLUME - new_value = 42 - service._run( - reconstruction, - synthesis_mocks.channel_name, - cast(Features, features), - feature_key, - new_value, - ) + service._run(reconstruction, synthesis_mocks.channel_name, FeatureKey.VOLUME, features) - assert features[feature_key] == new_value + _, _, _, initial_pitch, held = reconstruction.model_copy.return_value.update_channel_data.call_args.args + assert initial_pitch == features.initial_pitch + assert held == features.held_features def test_run_updates_reconstruction_copy( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: service = RegenerationService() service._run( reconstruction, synthesis_mocks.channel_name, - cast(Features, features), FeatureKey.VOLUME, - 1, + features, ) updated = reconstruction.model_copy.return_value @@ -252,7 +226,7 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: """An arpeggio edit stores the reference pitch the edit was made from. @@ -264,9 +238,8 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( service._run( reconstruction, synthesis_mocks.channel_name, - cast(Features, features), FeatureKey.ARPEGGIO, - np.array([12, 0], dtype=np.int8), + features, ) call_args = reconstruction.model_copy.return_value.update_channel_data.call_args @@ -276,28 +249,22 @@ def test_run_carries_a_moved_reference_pitch( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: - """The pitch stepper's edit stores the new reference pitch.""" - moved_pitch = REFERENCE_PITCH + 12 + """The pitch stepper's edit reaches the reconstruction as the reference it stores.""" + moved = features.model_copy(update={"initial_pitch": REFERENCE_PITCH + 12}) service = RegenerationService() - service._run( - reconstruction, - synthesis_mocks.channel_name, - cast(Features, features), - FeatureKey.INITIAL_PITCH, - moved_pitch, - ) + service._run(reconstruction, synthesis_mocks.channel_name, FeatureKey.INITIAL_PITCH, moved) - call_args = reconstruction.model_copy.return_value.update_channel_data.call_args - assert call_args.args[3] == moved_pitch + _, _, _, initial_pitch, _ = reconstruction.model_copy.return_value.update_channel_data.call_args.args + assert initial_pitch == REFERENCE_PITCH + 12 def test_run_calls_generator_for_each_instruction( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: extra_instruction = MagicMock() synthesis_mocks.exporter.from_features.return_value = [ @@ -309,9 +276,8 @@ def test_run_calls_generator_for_each_instruction( service._run( reconstruction, synthesis_mocks.channel_name, - cast(Features, features), FeatureKey.VOLUME, - 1, + features, ) assert synthesis_mocks.generator.call_count == 2 @@ -335,9 +301,8 @@ def test_run_exception_emits_service_error( service._run( reconstruction, ChannelName.PULSE1, - cast(Features, {}), FeatureKey.VOLUME, - 1, + cast(Features, {}), ) assert len(results) == 1 @@ -360,9 +325,8 @@ def test_run_exception_does_not_update_reconstruction( service._run( reconstruction, ChannelName.PULSE1, - cast(Features, {}), FeatureKey.VOLUME, - 1, + cast(Features, {}), ) reconstruction.update_channel_data.assert_not_called() @@ -379,8 +343,9 @@ class TestClearingEveryEnvelope: @staticmethod def _regenerated(reconstruction: Reconstruction) -> Reconstruction: """The reconstruction the service returns once every dimension is left to the channel.""" - features = reconstruction.export()[ChannelName.PULSE1] - features.leave_to_channel([FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE]) + features = reconstruction.export()[ChannelName.PULSE1].leave_to_channel( + [FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE] + ) service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) @@ -388,9 +353,8 @@ def _regenerated(reconstruction: Reconstruction) -> Reconstruction: service._run( reconstruction, ChannelName.PULSE1, - features, FeatureKey.VOLUME, - np.array([], dtype=np.int8), + features, ) assert isinstance(results[0], ServiceSuccess) @@ -455,7 +419,7 @@ class TestRegenerationServiceCancellationConstraints: def test_cancel_while_running_does_not_interrupt_synthesis( self, synthesis_mocks: SynthesisMocks, - features: FakeFeatures, + features: Features, ) -> None: service = RegenerationService() results: List[Any] = [] @@ -483,9 +447,8 @@ def blocking_from_features(edited_features: Any) -> List[MagicMock]: target=lambda: service._run( reconstruction, synthesis_mocks.channel_name, - cast(Features, features), FeatureKey.VOLUME, - 1, + features, ), ) thread.start() @@ -512,18 +475,16 @@ def test_cancel_after_completion_prevents_new_tasks( service.start( reconstruction, synthesis_mocks.channel_name, - cast(Features, {}), FeatureKey.VOLUME, - 1, + cast(Features, {}), ) service.cancel() second_result = service.start( reconstruction, synthesis_mocks.channel_name, - cast(Features, {}), FeatureKey.VOLUME, - 2, + cast(Features, {}), ) assert second_result is False diff --git a/tests/unit/sampletones_core/exporters/implementation/test_noise.py b/tests/unit/sampletones_core/exporters/implementation/test_noise.py index eb1c426ec..99f58d47d 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_noise.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_noise.py @@ -81,13 +81,13 @@ def test_empty_instruction_list_references_period_zero(self) -> None: assert NoiseExporter.derive_initial_pitch([]) == 0 -class TestNoiseExporterGetFeatureMap: - def test_feature_map_contains_all_required_keys(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise(period=3, volume=10)], 3) - assert FeatureKey.INITIAL_PITCH in feature_map - assert FeatureKey.VOLUME in feature_map - assert FeatureKey.ARPEGGIO in feature_map - assert FeatureKey.DUTY_CYCLE in feature_map +class TestNoiseExporterReadEnvelopes: + def test_it_reads_the_dimensions_its_generator_offers(self) -> None: + envelopes = NoiseExporter.read_envelopes([_noise()], 60) + assert FeatureKey.VOLUME in envelopes + assert FeatureKey.ARPEGGIO in envelopes + assert FeatureKey.DUTY_CYCLE in envelopes + assert FeatureKey.INITIAL_PITCH not in envelopes def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods( self, @@ -96,27 +96,11 @@ def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods( _noise(period=2, volume=10), _noise(period=5, volume=8), ] - feature_map = NoiseExporter.get_feature_map(instructions, 4) - arpeggio = feature_map[FeatureKey.ARPEGGIO] + envelopes = NoiseExporter.read_envelopes(instructions, 4) + arpeggio = envelopes[FeatureKey.ARPEGGIO] assert int(arpeggio[0]) == (2 - 4) % NUM_PERIODS assert int(arpeggio[1]) == (5 - 4) % NUM_PERIODS - def test_initial_pitch_is_the_given_reference(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise(period=2, volume=10)], 9) - assert feature_map[FeatureKey.INITIAL_PITCH] == 9 - - def test_volume_array_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()], 0) - assert feature_map[FeatureKey.VOLUME].dtype == np.int8 - - def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()], 0) - assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 - - def test_duty_cycle_dtype_is_int8(self) -> None: - feature_map = NoiseExporter.get_feature_map([_noise()], 0) - assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8 - class TestNoiseExporterReconstruction: def test_features_dictionary_to_instruction_round_trip(self) -> None: diff --git a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py index 0b4e842ee..60031f127 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py @@ -66,37 +66,24 @@ def test_empty_instruction_list_references_min_pitch(self) -> None: assert PulseExporter.derive_initial_pitch([]) == MIN_PITCH -class TestPulseExporterGetFeatureMap: - def test_feature_map_contains_required_keys(self) -> None: - feature_map = PulseExporter.get_feature_map([_pulse(pitch=60)], 60) - assert FeatureKey.INITIAL_PITCH in feature_map - assert FeatureKey.VOLUME in feature_map - assert FeatureKey.ARPEGGIO in feature_map - assert FeatureKey.DUTY_CYCLE in feature_map +class TestPulseExporterReadEnvelopes: + def test_it_reads_the_dimensions_its_generator_offers(self) -> None: + envelopes = PulseExporter.read_envelopes([_pulse()], 60) + assert FeatureKey.VOLUME in envelopes + assert FeatureKey.ARPEGGIO in envelopes + assert FeatureKey.DUTY_CYCLE in envelopes + assert FeatureKey.INITIAL_PITCH not in envelopes def test_arpeggio_is_relative_to_the_given_reference(self) -> None: instructions = [_pulse(pitch=60), _pulse(pitch=65)] - feature_map = PulseExporter.get_feature_map(instructions, 60) - arpeggio = feature_map[FeatureKey.ARPEGGIO] + envelopes = PulseExporter.read_envelopes(instructions, 60) + arpeggio = envelopes[FeatureKey.ARPEGGIO] assert int(arpeggio[0]) == 0 assert int(arpeggio[1]) == 5 - def test_initial_pitch_is_the_given_reference(self) -> None: - feature_map = PulseExporter.get_feature_map([_pulse(pitch=60)], 55) - assert feature_map[FeatureKey.INITIAL_PITCH] == 55 - assert int(feature_map[FeatureKey.ARPEGGIO][0]) == 5 - - def test_volume_dtype_is_int8(self) -> None: - feature_map = PulseExporter.get_feature_map([_pulse()], 60) - assert feature_map[FeatureKey.VOLUME].dtype == np.int8 - - def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = PulseExporter.get_feature_map([_pulse()], 60) - assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 - - def test_duty_cycle_dtype_is_int8(self) -> None: - feature_map = PulseExporter.get_feature_map([_pulse()], 60) - assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8 + def test_the_arpeggio_is_measured_from_the_reference_it_is_given(self) -> None: + envelopes = PulseExporter.read_envelopes([_pulse(pitch=60)], 55) + assert envelopes[FeatureKey.ARPEGGIO][0] == 5 class TestPulseExporterReconstruction: diff --git a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py index 517cd2913..db2f25397 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py @@ -65,32 +65,23 @@ def test_empty_instruction_list_references_min_pitch(self) -> None: assert TriangleExporter.derive_initial_pitch([]) == MIN_PITCH -class TestTriangleExporterGetFeatureMap: - def test_feature_map_contains_required_keys(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)], 60) - assert FeatureKey.INITIAL_PITCH in feature_map - assert FeatureKey.VOLUME in feature_map - assert FeatureKey.ARPEGGIO in feature_map +class TestTriangleExporterReadEnvelopes: + def test_it_reads_the_dimensions_its_generator_offers(self) -> None: + envelopes = TriangleExporter.read_envelopes([_tri()], 60) + assert FeatureKey.VOLUME in envelopes + assert FeatureKey.ARPEGGIO in envelopes + assert FeatureKey.INITIAL_PITCH not in envelopes def test_arpeggio_is_relative_to_the_given_reference(self) -> None: instructions = [_tri(pitch=60), _tri(pitch=65)] - feature_map = TriangleExporter.get_feature_map(instructions, 60) - arpeggio = feature_map[FeatureKey.ARPEGGIO] + envelopes = TriangleExporter.read_envelopes(instructions, 60) + arpeggio = envelopes[FeatureKey.ARPEGGIO] assert int(arpeggio[0]) == 0 assert int(arpeggio[1]) == 5 - def test_initial_pitch_is_the_given_reference(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri(pitch=60)], 55) - assert feature_map[FeatureKey.INITIAL_PITCH] == 55 - assert int(feature_map[FeatureKey.ARPEGGIO][0]) == 5 - - def test_volume_dtype_is_int8(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri()], 60) - assert feature_map[FeatureKey.VOLUME].dtype == np.int8 - - def test_arpeggio_dtype_is_int8(self) -> None: - feature_map = TriangleExporter.get_feature_map([_tri()], 60) - assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8 + def test_the_arpeggio_is_measured_from_the_reference_it_is_given(self) -> None: + envelopes = TriangleExporter.read_envelopes([_tri(pitch=60)], 55) + assert envelopes[FeatureKey.ARPEGGIO][0] == 5 class TestTriangleExporterReconstruction: diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 959f90205..e9f5b40d8 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -13,6 +13,7 @@ PulseExporter, TriangleExporter, ) +from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -64,11 +65,11 @@ def _features( ) -> Features: return Features( initial_pitch=initial_pitch, - volume=np.array(volume, dtype=np.int8), - arpeggio=np.array(arpeggio, dtype=np.int8), + volume=Envelope(items=tuple(volume)), + arpeggio=Envelope(items=tuple(arpeggio)), pitch=None, hi_pitch=None, - duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=np.int8), + duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)), ) @@ -143,8 +144,10 @@ def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Fe @classmethod def _edited(cls, test_case: TestCase) -> List[InstructionUnion]: - features = cls._export(test_case, test_case.instructions) - features[FeatureKey.ARPEGGIO] = test_case.arpeggio + features = cls._export(test_case, test_case.instructions).with_envelope( + FeatureKey.ARPEGGIO, + Envelope(items=tuple(int(offset) for offset in test_case.arpeggio)), + ) return test_case.exporter.from_features(features) @pytest.mark.parametrize( @@ -164,7 +167,7 @@ def test_flat_contour_exports_a_zero_offset(self, test_case: TestCase) -> None: features = self._export(test_case, test_case.instructions) assert features.initial_pitch == test_case.expected - assert features.arpeggio.tolist() == [0] + assert list(features.arpeggio.items) == [0] @pytest.mark.parametrize( "test_case", @@ -200,7 +203,7 @@ def test_re_export_keeps_the_stored_reference(self, test_case: TestCase) -> None def test_re_export_reads_the_edited_arpeggio_back(self, test_case: TestCase) -> None: features = self._export(test_case, self._edited(test_case)) - assert features.arpeggio.tolist() == test_case.arpeggio.tolist() + assert list(features.arpeggio.items) == test_case.arpeggio.tolist() @pytest.mark.parametrize( "test_case", @@ -213,8 +216,10 @@ def test_cleared_arpeggio_returns_every_frame_to_the_reference(self, test_case: This is the reported behavior: typing ``12 0`` and then clearing it back to ``0`` sounds the sample at the note it was reconstructed at. """ - features = self._export(test_case, self._edited(test_case)) - features[FeatureKey.ARPEGGIO] = np.zeros(len(test_case.arpeggio), dtype=np.int8) + features = self._export(test_case, self._edited(test_case)).with_envelope( + FeatureKey.ARPEGGIO, + Envelope(items=(0,) * len(test_case.arpeggio)), + ) cleared = test_case.exporter.from_features(features) @@ -239,11 +244,11 @@ class TestCase(BaseRegularTestCase): exporter=PulseExporter, features=Features( initial_pitch=REFERENCE_PITCH, - volume=np.array([PULSE_VOLUME, PULSE_VOLUME, 0], dtype=np.int8), - arpeggio=np.array([], dtype=np.int8), + volume=Envelope(items=(PULSE_VOLUME, PULSE_VOLUME, 0)), + arpeggio=Envelope(items=()), pitch=None, hi_pitch=None, - duty_cycle=np.array([0], dtype=np.int8), + duty_cycle=Envelope(items=(0,)), ), read_pitch=_read_pitch, expected=REFERENCE_PITCH, @@ -253,8 +258,8 @@ class TestCase(BaseRegularTestCase): exporter=TriangleExporter, features=Features( initial_pitch=REFERENCE_PITCH, - volume=np.array([15, 15, 0], dtype=np.int8), - arpeggio=np.array([], dtype=np.int8), + volume=Envelope(items=(15, 15, 0)), + arpeggio=Envelope(items=()), pitch=None, hi_pitch=None, duty_cycle=None, @@ -267,11 +272,11 @@ class TestCase(BaseRegularTestCase): exporter=NoiseExporter, features=Features( initial_pitch=REFERENCE_PERIOD, - volume=np.array([NOISE_VOLUME, NOISE_VOLUME, 0], dtype=np.int8), - arpeggio=np.array([], dtype=np.int8), + volume=Envelope(items=(NOISE_VOLUME, NOISE_VOLUME, 0)), + arpeggio=Envelope(items=()), pitch=None, hi_pitch=None, - duty_cycle=np.array([0], dtype=np.int8), + duty_cycle=Envelope(items=(0,)), ), read_pitch=_read_period, expected=REFERENCE_PERIOD, @@ -287,7 +292,7 @@ def test_every_frame_sounds_at_the_reference(self, test_case: TestCase) -> None: instructions = test_case.exporter.from_features(test_case.features) pitches = [test_case.read_pitch(instruction) for instruction in instructions] - assert pitches == [test_case.expected] * len(test_case.features.volume) + assert pitches == [test_case.expected] * len(test_case.features.volume.items) @pytest.mark.parametrize( "test_case", @@ -408,7 +413,7 @@ def test_a_held_dimension_comes_back_empty(self) -> None: features.held_features, ) - assert exported.arpeggio.size == 0 + assert not exported.arpeggio.written assert exported.held_features == (FeatureKey.ARPEGGIO,) def test_a_written_dimension_comes_back_with_its_items(self) -> None: @@ -426,9 +431,9 @@ def test_a_written_dimension_comes_back_with_its_items(self) -> None: features.held_features, ) - assert exported.volume.tolist() == [PULSE_VOLUME, PULSE_VOLUME, 0] + assert list(exported.volume.items) == [PULSE_VOLUME, PULSE_VOLUME, 0] assert exported.duty_cycle is not None - assert exported.duty_cycle.tolist() == [1] + assert list(exported.duty_cycle.items) == [1] def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None: features = _features( diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index 618738e63..c12bb17cf 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -1,17 +1,16 @@ from typing import Optional -import numpy as np - from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features, playing_channels +from sampletones_core.features.envelope import Envelope def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: - duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) + duty_cycle = None if duty_cycle_frames is None else Envelope(items=(0,) * duty_cycle_frames) return Features( initial_pitch=60, - volume=np.full(frames, 15, dtype=int), - arpeggio=np.zeros(frames, dtype=int), + volume=Envelope(items=(15,) * frames), + arpeggio=Envelope(items=(0,) * frames), pitch=None, hi_pitch=None, duty_cycle=duty_cycle, @@ -36,8 +35,7 @@ def test_an_instrument_writing_every_dimension_leaves_none(self) -> None: assert build_features(8, duty_cycle_frames=8).held_features == () def test_an_empty_envelope_marks_a_dimension_the_channel_governs(self) -> None: - features = build_features(8, duty_cycle_frames=8) - features[FeatureKey.ARPEGGIO] = np.array([], dtype=np.int8) + features = build_features(8, duty_cycle_frames=8).with_envelope(FeatureKey.ARPEGGIO, Envelope(items=())) assert features.held_features == (FeatureKey.ARPEGGIO,) def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None: @@ -45,16 +43,14 @@ def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None: assert build_features(8).held_features == () def test_leaving_a_dimension_to_the_channel_empties_its_envelope(self) -> None: - features = build_features(8, duty_cycle_frames=8) - features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) - assert features.volume.size == 0 - assert features.duty_cycle is not None and features.duty_cycle.size == 0 + features = build_features(8, duty_cycle_frames=8).leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) + assert not features.volume.written + assert features.duty_cycle is not None and not features.duty_cycle.written assert features.held_features == (FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE) def test_leaving_a_dimension_the_channel_lacks_keeps_it_absent(self) -> None: """A record naming a duty cycle on the triangle channel leaves the channel's shape intact.""" - features = build_features(8) - features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) + features = build_features(8).leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) assert features.duty_cycle is None assert features.held_features == (FeatureKey.VOLUME,) diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py deleted file mode 100644 index 86973a2f5..000000000 --- a/tests/unit/sampletones_core/exporters/test_lengths.py +++ /dev/null @@ -1,112 +0,0 @@ -import logging -from typing import Dict, Final, Tuple - -import pytest - -from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths -from sampletones_shared.application import SAMPLETONES_NAME - -VOLUME: Final[str] = "volume" -ARPEGGIO: Final[str] = "arpeggio" -DUTY: Final[str] = "duty" - -ITEM_LIMIT: Final[int] = 252 - - -def items_of(length: int) -> Tuple[int, ...]: - return tuple(index % 16 for index in range(length)) - - -def volume_and_arpeggio(length: int) -> Dict[str, Tuple[int, ...]]: - return { - VOLUME: items_of(length), - ARPEGGIO: (0,) * length, - DUTY: (), - } - - -class TestEqualizeLengths: - def test_loop_takes_the_shortest_populated_dimension(self) -> None: - equalized = equalize_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, loop=True) - assert equalized[VOLUME] == (15, 12, 9) - assert equalized[ARPEGGIO] == (0, 2, 4) - - def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: - equalized = equalize_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, loop=False) - assert equalized[VOLUME] == (15, 12, 9, 0) - assert equalized[ARPEGGIO] == (0, 2, 4, 4) - - def test_empty_dimensions_stay_empty(self) -> None: - equalized = equalize_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, loop=False) - assert equalized[ARPEGGIO] == () - - def test_all_dimensions_empty_stay_empty(self) -> None: - equalized = equalize_lengths({VOLUME: (), ARPEGGIO: (), DUTY: ()}, loop=True) - assert all(items == () for items in equalized.values()) - - -class TestLimitLengths: - def test_every_dimension_keeps_its_own_length(self) -> None: - limited = limit_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, limit=ITEM_LIMIT) - assert limited[VOLUME] == (15, 12, 9, 0) - assert limited[ARPEGGIO] == (0, 2, 4) - - def test_empty_dimensions_stay_empty(self) -> None: - limited = limit_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, limit=ITEM_LIMIT) - assert limited[ARPEGGIO] == () - - def test_an_over_long_envelope_keeps_its_opening_items(self) -> None: - limited = limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 48), limit=ITEM_LIMIT) - assert limited[VOLUME] == items_of(ITEM_LIMIT) - assert len(limited[ARPEGGIO]) == ITEM_LIMIT - - def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), limit=ITEM_LIMIT) - - assert str(ITEM_LIMIT) in caplog.text - - def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - limit_lengths(volume_and_arpeggio(ITEM_LIMIT), limit=ITEM_LIMIT) - - assert caplog.text == "" - - -class TestItemLimit: - @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) - def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None: - length = ITEM_LIMIT + 48 - - equalized = equalize_lengths(volume_and_arpeggio(length), loop=loop, limit=ITEM_LIMIT) - - assert equalized[VOLUME] == items_of(ITEM_LIMIT) - assert len(equalized[ARPEGGIO]) == ITEM_LIMIT - - def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False, limit=ITEM_LIMIT) - - assert str(ITEM_LIMIT) in caplog.text - - def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - equalize_lengths(volume_and_arpeggio(ITEM_LIMIT), loop=False, limit=ITEM_LIMIT) - - assert caplog.text == "" - - -class TestUnboundedFormat: - def test_an_absent_limit_keeps_every_item(self) -> None: - length = ITEM_LIMIT + 48 - - equalized = equalize_lengths(volume_and_arpeggio(length), loop=False) - - assert equalized[VOLUME] == items_of(length) - assert len(equalized[ARPEGGIO]) == length - - def test_an_absent_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - equalize_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), loop=False) - - assert caplog.text == "" diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index f762ed109..dffffd59b 100644 --- a/tests/unit/sampletones_core/exporters/test_slices.py +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -10,6 +10,7 @@ iterate_voice_slices, voice_instrument_entries, ) +from sampletones_core.features.envelope import Envelope from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -35,7 +36,9 @@ def _sample(name: str, channels: Sequence[ChannelName]) -> Sample: def _instrument(name: str) -> Instrument: - return Instrument(name=name, envelopes=InstrumentEnvelopes(volume=(15, 10), arpeggio=(0, 5))) + return Instrument( + name=name, envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 10)), arpeggio=Envelope(items=(0, 5))) + ) def _stand_by(sample: Sample, channel: ChannelName) -> None: diff --git a/tests/unit/sampletones_core/exports/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py index a949a60cc..8cb5466e6 100644 --- a/tests/unit/sampletones_core/exports/test_bitphase.py +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -20,6 +20,7 @@ SampleExport, ) from sampletones_core.exports.scope import ExportScope +from sampletones_core.features.envelope import Envelope from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_shared.music import Tuning @@ -39,8 +40,8 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) return Features( initial_pitch=REFERENCE_PITCH, - volume=np.full(frames, 15, dtype=int), - arpeggio=np.zeros(frames, dtype=int), + volume=Envelope(items=(15,) * frames), + arpeggio=Envelope(items=(0,) * frames), pitch=None, hi_pitch=None, duty_cycle=duty_cycle, @@ -52,7 +53,6 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: name=name, channel=ChannelName.PULSE1, features=build_features(frames), - loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index eeb11889f..645831344 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -13,6 +13,7 @@ from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.exports.scope import ExportScope from sampletones_core.exports.stage import ExportStage +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) @@ -31,8 +32,8 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) return Features( initial_pitch=60, - volume=np.full(frames, 15, dtype=int), - arpeggio=np.zeros(frames, dtype=int), + volume=Envelope(items=(15,) * frames), + arpeggio=Envelope(items=(0,) * frames), pitch=None, hi_pitch=None, duty_cycle=duty_cycle, @@ -44,7 +45,6 @@ def build_instrument(name: str, frames: int) -> InstrumentExport: name=name, channel=ChannelName.PULSE1, features=build_features(frames), - loop_point=None, nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) diff --git a/tests/unit/sampletones_core/features/__init__.py b/tests/unit/sampletones_core/features/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/features/test_envelope.py b/tests/unit/sampletones_core/features/test_envelope.py new file mode 100644 index 000000000..58707bc8a --- /dev/null +++ b/tests/unit/sampletones_core/features/test_envelope.py @@ -0,0 +1,133 @@ +import logging +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.features.envelope import Envelope +from sampletones_shared.application import SAMPLETONES_NAME +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ITEM_LIMIT: Final[int] = 252 + + +def items_of(length: int) -> Tuple[int, ...]: + return tuple(index % 16 for index in range(length)) + + +class TestWhatADimensionHoldsAtATick(BaseTestSuite): + """A dimension answers at every tick, which is what lets a note sound past its written frames.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + items: Tuple[int, ...] + loop_point: Optional[int] + expected: Tuple[Optional[int], ...] + + test_cases = ( + TestCase( + label="a dimension the channel governs answers nothing", + items=(), + loop_point=None, + expected=(None, None, None), + ), + TestCase( + label="a dimension halting holds its final value", + items=(15, 8, 4), + loop_point=None, + expected=(15, 8, 4, 4, 4), + ), + TestCase( + label="a dimension looping circles from its point", + items=(15, 8, 4), + loop_point=1, + expected=(15, 8, 4, 8, 4, 8), + ), + TestCase( + label="a dimension looping from the start circles whole", + items=(9, 3), + loop_point=0, + expected=(9, 3, 9, 3, 9), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_value_at_each_tick(self, test_case: TestCase) -> None: + envelope = Envelope[int](items=test_case.items, loop_point=test_case.loop_point) + + assert tuple(envelope.at(tick) for tick in range(len(test_case.expected))) == test_case.expected + + +class TestWhatADimensionStates: + def test_a_written_dimension_takes_itself_out_of_the_channels_own(self) -> None: + assert Envelope[int](items=(15,)).written + + def test_an_empty_dimension_is_left_to_the_channel(self) -> None: + assert not Envelope[int]().written + + def test_a_dimension_states_whether_it_circles(self) -> None: + assert Envelope[int](items=(15, 8), loop_point=0).loops + assert not Envelope[int](items=(15, 8)).loops + + def test_a_point_past_the_items_written_is_refused(self) -> None: + with pytest.raises(ValidationError): + Envelope[int](items=(15, 8), loop_point=2) + + def test_a_point_on_a_dimension_writing_nothing_is_refused(self) -> None: + with pytest.raises(ValidationError): + Envelope[int](items=(), loop_point=0) + + +class TestADimensionWithinAFormatsLimit: + def test_a_dimension_within_the_limit_stands_as_written(self) -> None: + envelope = Envelope[int](items=(15, 12, 9, 0)) + + assert envelope.limited(ITEM_LIMIT) == envelope + + def test_an_over_long_dimension_keeps_its_opening_items(self) -> None: + limited = Envelope[int](items=items_of(ITEM_LIMIT + 48)).limited(ITEM_LIMIT) + + assert limited.items == items_of(ITEM_LIMIT) + + def test_a_point_past_what_survives_moves_to_the_last_item_kept(self) -> None: + limited = Envelope[int](items=items_of(ITEM_LIMIT + 48), loop_point=ITEM_LIMIT + 10).limited(ITEM_LIMIT) + + assert limited.loop_point == ITEM_LIMIT - 1 + + def test_a_point_inside_what_survives_stays_where_it_was(self) -> None: + limited = Envelope[int](items=items_of(ITEM_LIMIT + 48), loop_point=4).limited(ITEM_LIMIT) + + assert limited.loop_point == 4 + + def test_an_over_long_dimension_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): + Envelope[int](items=items_of(ITEM_LIMIT + 1)).limited(ITEM_LIMIT) + + assert str(ITEM_LIMIT) in caplog.text + + def test_a_dimension_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): + Envelope[int](items=items_of(ITEM_LIMIT)).limited(ITEM_LIMIT) + + assert caplog.text == "" + + +class TestADimensionBroughtToALength: + def test_a_shorter_dimension_holds_its_final_value(self) -> None: + assert Envelope[int](items=(0, 2, 4)).resized(5).items == (0, 2, 4, 4, 4) + + def test_a_dimension_at_the_length_stands_as_written(self) -> None: + envelope = Envelope[int](items=(15, 12, 9)) + + assert envelope.resized(3) == envelope + + def test_a_dimension_the_channel_governs_stays_empty(self) -> None: + assert Envelope[int]().resized(5).items == () + + def test_a_point_stays_inside_the_items_kept(self) -> None: + resized = Envelope[int](items=(15, 12, 9, 0), loop_point=3).resized(2) + + assert resized.items == (15, 12) + assert resized.loop_point == 1 diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index f1178979c..3537af09a 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -5,6 +5,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_shared.music import Tuning @@ -20,14 +21,14 @@ def build_features( initial_pitch: int = REFERENCE_PITCH, ) -> Features: """Builds the envelopes of one channel slice, flat in every dimension left out.""" - contour = np.zeros(len(volume), dtype=int) if arpeggio is None else np.array(arpeggio, dtype=int) + contour = (0,) * len(volume) if arpeggio is None else tuple(arpeggio) return Features( initial_pitch=initial_pitch, - volume=np.array(volume, dtype=int), - arpeggio=contour, + volume=Envelope(items=tuple(volume)), + arpeggio=Envelope(items=contour), pitch=None, hi_pitch=None, - duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)), ) @@ -41,8 +42,7 @@ def build_instrument( return InstrumentExport( name=name, channel=channel, - features=features, - loop_point=loop_point, + features=looping(features, loop_point), nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) @@ -55,3 +55,16 @@ def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: nes_frequency=NES_FREQUENCY, tuning=Tuning(), ) + + +def looping(features: Features, loop_point: Optional[int]) -> Features: + """The envelopes with every dimension they write circling from ``loop_point``.""" + if loop_point is None: + return features + + circling = features + for feature_key, envelope in features.envelopes.items(): + if envelope.written: + circling = circling.with_envelope(feature_key, envelope.model_copy(update={"loop_point": loop_point})) + + return circling diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index b568283d0..dda63c61c 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -20,7 +20,7 @@ ) from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT -from .conftest import build_features +from .conftest import build_features, looping @dataclass @@ -47,7 +47,6 @@ def test_each_volume_item_becomes_one_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop_point=None, ) assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE @@ -55,7 +54,6 @@ def test_the_contour_becomes_the_table(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop_point=None, ) assert list(envelopes.table_rows) == PITCH_CONTOUR @@ -68,7 +66,6 @@ def test_the_duty_item_reaches_the_field_its_channel_reads(self, case: PulseWidt envelopes = features_to_envelopes( build_features([15], duty_cycle=[case.duty_cycle]), case.channel, - loop_point=None, ) assert envelopes.rows[0].pulse_width == case.pulse_width @@ -76,7 +73,6 @@ def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.TRIANGLE, - loop_point=None, ) assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} @@ -85,7 +81,6 @@ def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: envelopes = features_to_envelopes( build_features([15] * len(steps), arpeggio=steps), ChannelName.NOISE, - loop_point=None, ) assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] @@ -98,25 +93,23 @@ class TestTheDimensionsStayInStep: @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), WHOLE_LOOP_POINT if loop else None), ChannelName.PULSE1, - loop_point=WHOLE_LOOP_POINT if loop else None, ) assert len(envelopes.rows) == len(envelopes.table_rows) - def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: + def test_a_looping_slice_stands_at_the_longest_dimension(self) -> None: + """Each dimension keeps its own length, so circling costs a slice none of its rows.""" envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), WHOLE_LOOP_POINT), ChannelName.PULSE1, - loop_point=WHOLE_LOOP_POINT, ) - assert len(envelopes.rows) == 2 + assert len(envelopes.rows) == len(VOLUME_ENVELOPE) def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), ChannelName.PULSE1, - loop_point=None, ) assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] @@ -124,7 +117,6 @@ def test_a_slice_without_a_contour_holds_its_note(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=[]), ChannelName.PULSE1, - loop_point=None, ) assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) @@ -132,9 +124,8 @@ def test_a_slice_without_a_contour_holds_its_note(self) -> None: class TestTheLoopPoint: def test_a_looping_slice_returns_to_its_first_row(self) -> None: envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE), + looping(build_features(VOLUME_ENVELOPE), WHOLE_LOOP_POINT), ChannelName.PULSE1, - loop_point=WHOLE_LOOP_POINT, ) assert envelopes.loop == LOOP_FROM_START @@ -142,7 +133,6 @@ def test_a_one_shot_rests_on_its_last_row(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.PULSE1, - loop_point=None, ) assert envelopes.loop == len(envelopes.rows) - 1 @@ -153,16 +143,14 @@ def test_a_one_shot_rests_in_silence(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), ChannelName.PULSE1, - loop_point=None, ) assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), WHOLE_LOOP_POINT if loop else None), ChannelName.PULSE1, - loop_point=WHOLE_LOOP_POINT if loop else None, ) assert envelopes.loop < len(envelopes.rows) assert envelopes.loop < len(envelopes.table_rows) @@ -177,7 +165,6 @@ def test_it_holds_a_full_row_per_frame(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop_point=None, ) assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) @@ -185,7 +172,6 @@ def test_its_contour_still_moves_the_note(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop_point=None, ) assert list(envelopes.table_rows) == PITCH_CONTOUR @@ -194,7 +180,6 @@ def test_its_duty_envelope_still_reaches_the_rows(self) -> None: envelopes = features_to_envelopes( build_features([], duty_cycle=duty_cycles), ChannelName.PULSE1, - loop_point=None, ) assert [row.pulse_width for row in envelopes.rows] == duty_cycles @@ -202,15 +187,13 @@ def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), ChannelName.PULSE1, - loop_point=None, ) assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: envelopes = features_to_envelopes( - build_features([], arpeggio=PITCH_CONTOUR), + looping(build_features([], arpeggio=PITCH_CONTOUR), WHOLE_LOOP_POINT), ChannelName.PULSE1, - loop_point=WHOLE_LOOP_POINT, ) assert len(envelopes.rows) == len(PITCH_CONTOUR) assert envelopes.loop == LOOP_FROM_START @@ -223,7 +206,7 @@ class TestAnEmptySlice: @pytest.fixture(name="envelopes") def envelopes_fixture(self) -> ChannelEnvelopes: - return features_to_envelopes(build_features([]), ChannelName.PULSE1, loop_point=None) + return features_to_envelopes(build_features([]), ChannelName.PULSE1) def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] diff --git a/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py index 3329b2923..61dbfd66b 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py @@ -1,6 +1,7 @@ from typing import Final, Tuple from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.bitphase.builder import project_to_bitphase from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START from sampletones_core.project.patterns.row import Row @@ -17,8 +18,11 @@ def _project(*channels: ChannelName, loop_point: int | None = None) -> Tuple[Project, Instrument]: instrument = Instrument( name="Lead", - envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), - loop_point=loop_point, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME, loop_point=loop_point), + arpeggio=Envelope(items=ARPEGGIO, loop_point=loop_point), + duty_cycle=Envelope(items=(1,)), + ), ) project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) project.voices.append(instrument) diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index cf68e992d..876c5a822 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -1,5 +1,7 @@ -import numpy as np +from typing import Final, Optional, Sequence +from sampletones_core.exporters.feature import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, ) @@ -11,187 +13,124 @@ ) from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +REFERENCE_PITCH: Final[int] = 60 + + +def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]: + return Envelope[int](items=tuple(items), loop_point=loop_point if items else None) + + +def build( + volume: Sequence[int], + arpeggio: Sequence[int], + *, + pitch: Optional[Sequence[int]] = None, + duty_cycle: Optional[Sequence[int]] = None, + loop_point: Optional[int] = None, +) -> Features: + """One channel slice's envelopes, each dimension carrying the point it repeats from.""" + return Features( + initial_pitch=REFERENCE_PITCH, + volume=envelope(volume, loop_point), + arpeggio=envelope(arpeggio, loop_point), + pitch=None if pitch is None else envelope(pitch, loop_point), + hi_pitch=None, + duty_cycle=None if duty_cycle is None else envelope(duty_cycle, loop_point), + ) + class TestFeaturesToInstrumentSequences: def test_all_five_kinds_present(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 0]), - arpeggio=np.array([0]), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + sequences = features_to_instrument_sequences(build([15, 0], [0])) assert set(sequences) == set(SequenceKind) def test_populated_dimension_is_enabled_with_items(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 12, 0]), - arpeggio=np.array([], dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) - volume = sequences[SequenceKind.VOLUME] + volume = features_to_instrument_sequences(build([15, 12, 0], []))[SequenceKind.VOLUME] assert volume.enabled is True assert volume.items == (15, 12, 0) def test_missing_dimension_is_disabled(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 0]), - arpeggio=np.array([], dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + sequences = features_to_instrument_sequences(build([15, 0], [])) assert sequences[SequenceKind.PITCH].enabled is False assert sequences[SequenceKind.PITCH].items == () assert sequences[SequenceKind.ARPEGGIO].enabled is False def test_items_are_python_ints(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 8], dtype=np.int8), - arpeggio=np.array([-3], dtype=np.int8), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + sequences = features_to_instrument_sequences(build([15, 8], [-3])) assert all(isinstance(item, int) for item in sequences[SequenceKind.VOLUME].items) assert all(isinstance(item, int) for item in sequences[SequenceKind.ARPEGGIO].items) - def test_loop_sets_loop_point_on_populated_sequences(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 10]), - arpeggio=np.array([0, 2]), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=WHOLE_LOOP_POINT, - ) + +class TestTheLoopPointEachSequenceCarries: + def test_a_dimension_carries_the_point_it_states(self) -> None: + sequences = features_to_instrument_sequences(build([15, 10], [0, 2], loop_point=WHOLE_LOOP_POINT)) assert sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START - def test_loop_leaves_empty_sequences_unlooped(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 0]), - arpeggio=np.array([], dtype=int), + def test_each_dimension_repeats_from_its_own_item(self) -> None: + """A tracker advances every sequence on a counter of its own, so the points stand apart.""" + features = Features( + initial_pitch=REFERENCE_PITCH, + volume=envelope([15, 12, 9, 0], 2), + arpeggio=envelope([0, 2, 4], 0), pitch=None, hi_pitch=None, - duty_cycle=None, - loop_point=WHOLE_LOOP_POINT, + duty_cycle=envelope([1, 1]), ) + + sequences = features_to_instrument_sequences(features) + + assert sequences[SequenceKind.VOLUME].loop_point == 2 + assert sequences[SequenceKind.ARPEGGIO].loop_point == 0 + assert sequences[SequenceKind.DUTY].loop_point == NO_LOOP_POINT + + def test_a_dimension_the_instrument_leaves_out_states_no_point(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=WHOLE_LOOP_POINT)) assert sequences[SequenceKind.PITCH].loop_point == NO_LOOP_POINT - def test_no_loop_leaves_loop_point_disabled(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 10]), - arpeggio=np.array([0]), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + def test_a_dimension_that_halts_states_no_point(self) -> None: + sequences = features_to_instrument_sequences(build([15, 10], [0])) assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT class TestSequenceLengths: - def test_loop_drops_the_trailing_note_off_volume_item(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 12, 9, 0]), - arpeggio=np.array([0, 2, 4]), - pitch=None, - hi_pitch=None, - duty_cycle=np.array([1, 1, 2]), - loop_point=WHOLE_LOOP_POINT, - ) - assert sequences[SequenceKind.VOLUME].items == (15, 12, 9) - assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) - assert sequences[SequenceKind.DUTY].items == (1, 1, 2) - - def test_one_shot_carries_each_dimension_as_written(self) -> None: + def test_every_dimension_stands_at_the_length_it_was_written(self) -> None: """A halted sequence holds its final value, so a shorter dimension governs the rest itself.""" - sequences = features_to_instrument_sequences( - volume=np.array([15, 12, 9, 0]), - arpeggio=np.array([0, 2, 4]), - pitch=None, - hi_pitch=None, - duty_cycle=np.array([1]), - loop_point=None, - ) + sequences = features_to_instrument_sequences(build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1])) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) assert sequences[SequenceKind.DUTY].items == (1,) - def test_a_loop_brings_every_populated_dimension_to_one_length(self) -> None: + def test_circling_costs_a_dimension_none_of_its_items(self) -> None: + """Each dimension repeats on its own period, so a loop leaves every length as written.""" sequences = features_to_instrument_sequences( - volume=np.array([15, 12, 9, 0]), - arpeggio=np.array([0, 2, 4]), - pitch=np.array([0, 1]), - hi_pitch=None, - duty_cycle=np.array([1, 1, 2]), - loop_point=WHOLE_LOOP_POINT, + build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1, 1, 2], loop_point=WHOLE_LOOP_POINT) ) - lengths = {len(sequence.items) for sequence in sequences.values() if sequence.enabled} - assert lengths == {2} + assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) + assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) + assert sequences[SequenceKind.DUTY].items == (1, 1, 2) def test_disabled_dimensions_stay_empty(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([15, 12, 0]), - arpeggio=np.array([], dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + sequences = features_to_instrument_sequences(build([15, 12, 0], [])) assert sequences[SequenceKind.ARPEGGIO].items == () assert sequences[SequenceKind.PITCH].items == () def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: """An empty dimension leaves its sequence disabled; a single zero is a value the instrument sets.""" - cleared = features_to_instrument_sequences( - volume=np.array([15, 0]), - arpeggio=np.array([], dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) - zeroed = features_to_instrument_sequences( - volume=np.array([15, 0]), - arpeggio=np.array([0]), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + cleared = features_to_instrument_sequences(build([15, 0], [])) + zeroed = features_to_instrument_sequences(build([15, 0], [0])) + assert cleared[SequenceKind.ARPEGGIO].enabled is False assert zeroed[SequenceKind.ARPEGGIO].enabled is True assert zeroed[SequenceKind.ARPEGGIO].items == (0,) def test_all_dimensions_empty_stays_empty(self) -> None: - sequences = features_to_instrument_sequences( - volume=np.array([], dtype=int), - arpeggio=np.array([], dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=WHOLE_LOOP_POINT, - ) + sequences = features_to_instrument_sequences(build([], [], loop_point=WHOLE_LOOP_POINT)) assert all(not sequence.enabled for sequence in sequences.values()) def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> None: length = MAX_SEQUENCE_ITEMS + 48 - sequences = features_to_instrument_sequences( - volume=np.arange(length) % 16, - arpeggio=np.zeros(length, dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop_point=None, - ) + sequences = features_to_instrument_sequences(build([index % 16 for index in range(length)], [0] * length)) assert all(len(sequence.items) <= MAX_SEQUENCE_ITEMS for sequence in sequences.values()) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 47c4e9355..5ccf3b28c 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -6,6 +6,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters.feature import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.footprint import ( InstrumentFootprint, @@ -40,15 +41,21 @@ def build_features( volume: Sequence[int], arpeggio: Sequence[int], duty_cycle: Optional[Sequence[int]], + *, + loop_point: Optional[int] = None, ) -> Features: """Builds the envelopes of one channel slice, leaving the pitch dimensions unused.""" + + def envelope(items: Sequence[int]) -> Envelope[int]: + return Envelope(items=tuple(items), loop_point=loop_point if items else None) + return Features( initial_pitch=REFERENCE_PITCH, - volume=np.array(volume, dtype=int), - arpeggio=np.array(arpeggio, dtype=int), + volume=envelope(volume), + arpeggio=envelope(arpeggio), pitch=None, hi_pitch=None, - duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + duty_cycle=None if duty_cycle is None else envelope(duty_cycle), ) @@ -56,37 +63,31 @@ class TestFeaturesFootprint(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): features: Features - loop_point: Optional[int] expected: InstrumentFootprint test_cases = ( TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), - loop_point=None, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_one_shot", ), TestCase( - features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), - loop_point=WHOLE_LOOP_POINT, - expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), + features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2], loop_point=WHOLE_LOOP_POINT), + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_loop", ), TestCase( features=build_features([15, 0], [0], [0]), - loop_point=None, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16), label="dimensions_of_differing_lengths", ), TestCase( features=build_features([15, 12, 0], [0, 1], None), - loop_point=None, expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13), label="triangle", ), TestCase( features=build_features([], [], None), - loop_point=None, expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0), label="silent", ), @@ -96,7 +97,6 @@ class TestCase(BaseRegularTestCase): [0] * OVER_LONG_LENGTH, None, ), - loop_point=None, expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), label="capped_at_the_sequence_limit", ), @@ -106,7 +106,6 @@ class TestCase(BaseRegularTestCase): [0] * MAX_SEQUENCE_ITEMS, [0] * MAX_SEQUENCE_ITEMS, ), - loop_point=None, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768), label="largest_instrument_famitracker_holds", ), @@ -117,17 +116,17 @@ def test_both_regions_are_measured_from_the_populated_sequences( self, test_case: TestCase, ) -> None: - assert features_footprint(test_case.features, loop_point=test_case.loop_point) == test_case.expected + assert features_footprint(test_case.features) == test_case.expected @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None: """Both entry points measure one export, so a slice reads the same either way.""" - instrument = build_instrument(0, test_case.label, test_case.features, loop_point=test_case.loop_point) + instrument = build_instrument(0, test_case.label, test_case.features) assert instrument_footprint(instrument) == test_case.expected @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_total_sums_both_regions(self, test_case: TestCase) -> None: - footprint = features_footprint(test_case.features, loop_point=test_case.loop_point) + footprint = features_footprint(test_case.features) assert footprint.total_bytes == test_case.expected.instrument_bytes + test_case.expected.sequence_bytes @@ -162,33 +161,23 @@ class TestReconstructionFootprints: def test_one_entry_per_playing_channel(self) -> None: """The sample holds every channel; the two that play are the two an export writes.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - footprints = reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) + footprints = reconstruction_footprints(sample.reconstruction) assert set(footprints) == {ChannelName.PULSE1, ChannelName.TRIANGLE} def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None: """Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) - footprints = reconstruction_footprints(sample.reconstruction, loop_point=sample.loop_point) + footprints = reconstruction_footprints(sample.reconstruction) pulse = footprints[ChannelName.PULSE1] triangle = footprints[ChannelName.TRIANGLE] assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES - def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: + def test_each_channel_is_measured_as_its_own_envelopes_state_them(self) -> None: sample = pulse_sample("lead", pitch=60) features = sample.reconstruction.export() - for loop in (False, True): - assert reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT if loop else None) == { - channel_name: features_footprint(feature, loop_point=WHOLE_LOOP_POINT if loop else None) - for channel_name, feature in features.items() - if feature.has_frames - } - - def test_looping_costs_the_shortest_dimensions_length(self) -> None: - """A looping instrument shares the shortest dimension's length, so it stores fewer items.""" - sample = pulse_sample("lead", pitch=60) - one_shot = total_footprint(reconstruction_footprints(sample.reconstruction, loop_point=None).values()) - looping = total_footprint( - reconstruction_footprints(sample.reconstruction, loop_point=WHOLE_LOOP_POINT).values() - ) - assert one_shot.instrument_bytes == looping.instrument_bytes - assert looping.sequence_bytes < one_shot.sequence_bytes + + assert reconstruction_footprints(sample.reconstruction) == { + channel_name: features_footprint(feature) + for channel_name, feature in features.items() + if feature.has_frames + } diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index a9693a309..1c2c88b8f 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -1,10 +1,12 @@ from pathlib import Path -from typing import Optional +from typing import Final, Optional import numpy as np import pytest from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.exporters.feature import Features +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.binary import BinaryWriter from sampletones_core.formats.famitracker.instrument import ( fti_bytes_to_instrument, @@ -85,6 +87,9 @@ def fti_stating_volume_items(count: int) -> bytes: return writer.data +REFERENCE_PITCH: Final[int] = 60 + + def build_instrument( name: str, *, @@ -96,13 +101,19 @@ def build_instrument( loop_point: Optional[int] = None, index: int = 0, ) -> Instrument2A03: + def envelope(items: Optional[np.ndarray]) -> Envelope[int]: + values = () if items is None else tuple(int(item) for item in items) + return Envelope[int](items=values, loop_point=loop_point if values else None) + sequences = features_to_instrument_sequences( - volume=volume, - arpeggio=arpeggio if arpeggio is not None else np.array([], dtype=int), - pitch=pitch, - hi_pitch=hi_pitch, - duty_cycle=duty_cycle, - loop_point=loop_point, + Features( + initial_pitch=REFERENCE_PITCH, + volume=envelope(volume), + arpeggio=envelope(arpeggio), + pitch=None if pitch is None else envelope(pitch), + hi_pitch=None if hi_pitch is None else envelope(hi_pitch), + duty_cycle=None if duty_cycle is None else envelope(duty_cycle), + ) ) return Instrument2A03(index=index, name=name, sequences=sequences) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py index b0f390a11..19bf96416 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py @@ -3,6 +3,7 @@ import pytest from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.builder import build_instrument_table, project_to_module from sampletones_core.formats.famitracker.model.pattern import PatternData from sampletones_core.formats.famitracker.notes import period_to_note_cell, pitch_to_note_cell @@ -29,8 +30,11 @@ def _instrument(loop_point: int | None = None) -> Instrument: return Instrument( name="Lead", - envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), - loop_point=loop_point, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME, loop_point=loop_point), + arpeggio=Envelope(items=ARPEGGIO, loop_point=loop_point), + duty_cycle=Envelope(items=DUTY_CYCLE, loop_point=None if loop_point is None else 0), + ), ) @@ -66,31 +70,36 @@ def test_the_table_entry_carries_every_dimension_the_instrument_writes(self) -> sequences = instruments[0].sequences assert sequences[SequenceKind.VOLUME].items == VOLUME assert sequences[SequenceKind.ARPEGGIO].items == ARPEGGIO - assert sequences[SequenceKind.DUTY].items == DUTY_CYCLE * len(VOLUME) + assert sequences[SequenceKind.DUTY].items == DUTY_CYCLE def test_a_one_shot_leaves_every_loop_point_unset(self) -> None: instruments, _ = build_instrument_table(_project(_instrument(), ChannelName.PULSE1)) assert all(sequence.loop_point == NO_LOOP_POINT for sequence in instruments[0].sequences.values()) - def test_a_loop_point_reaches_every_populated_sequence(self) -> None: + def test_each_populated_sequence_carries_the_point_its_dimension_states(self) -> None: instruments, _ = build_instrument_table(_project(_instrument(TAIL_LOOP_POINT), ChannelName.PULSE1)) - populated = [sequence for sequence in instruments[0].sequences.values() if sequence.items] - assert [sequence.loop_point for sequence in populated] == [TAIL_LOOP_POINT] * len(populated) + sequences = instruments[0].sequences + + assert sequences[SequenceKind.VOLUME].loop_point == TAIL_LOOP_POINT + assert sequences[SequenceKind.ARPEGGIO].loop_point == TAIL_LOOP_POINT + assert sequences[SequenceKind.DUTY].loop_point == LOOP_FROM_START - def test_a_shorter_dimension_runs_the_length_of_the_longest(self) -> None: - """A tracker advances each sequence on its own counter, so they must share a length.""" + def test_a_shorter_dimension_stands_at_its_own_length(self) -> None: + """A tracker advances each sequence on its own counter, so each keeps the length it holds.""" instrument = Instrument( name="Lead", - envelopes=InstrumentEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE), - loop_point=TAIL_LOOP_POINT, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME, loop_point=TAIL_LOOP_POINT), + duty_cycle=Envelope(items=DUTY_CYCLE), + ), ) instruments, _ = build_instrument_table(_project(instrument, ChannelName.PULSE1)) duty = instruments[0].sequences[SequenceKind.DUTY] - assert duty.items == DUTY_CYCLE * len(VOLUME) - assert duty.loop_point == TAIL_LOOP_POINT + assert duty.items == DUTY_CYCLE + assert duty.loop_point == NO_LOOP_POINT def test_a_looping_instrument_still_repeats_from_the_start(self) -> None: instruments, _ = build_instrument_table(_project(_instrument(LOOP_FROM_START), ChannelName.PULSE1)) @@ -108,7 +117,7 @@ def test_a_tonal_row_states_the_root_moved_by_its_transpose(self, channel: Chann instrument = _instrument() module = project_to_module(_project(instrument, channel, transpose=TRANSPOSE)) - cell = pitch_to_note_cell(instrument.root_pitch + TRANSPOSE) + cell = pitch_to_note_cell(instrument.initial_pitch + TRANSPOSE) row = _rows(list(module.track.patterns), channel)[0] assert (row.note, row.octave) == (cell.note, cell.octave) @@ -116,7 +125,7 @@ def test_a_noise_row_states_the_period_root_moved_by_its_transpose(self) -> None instrument = _instrument() module = project_to_module(_project(instrument, ChannelName.NOISE, transpose=TRANSPOSE)) - cell = period_to_note_cell(instrument.root_period + TRANSPOSE) + cell = period_to_note_cell(instrument.initial_period + TRANSPOSE) row = _rows(list(module.track.patterns), ChannelName.NOISE)[0] assert (row.note, row.octave) == (cell.note, cell.octave) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_voice.py b/tests/unit/sampletones_core/formats/famitracker/test_voice.py index 336e68212..e849050e4 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_voice.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_voice.py @@ -3,6 +3,7 @@ import pytest from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.instrument import ( fti_bytes_to_instrument, @@ -63,48 +64,53 @@ def test_the_three_dimensions_come_across(self) -> None: written(SequenceKind.DUTY, (0, 1, 2)), ).voice - assert voice.envelopes == InstrumentEnvelopes(volume=(15, 8, 0), arpeggio=(0, 3, 7), duty_cycle=(0, 1, 2)) + assert voice.envelopes == InstrumentEnvelopes( + volume=Envelope(items=(15, 8, 0)), arpeggio=Envelope(items=(0, 3, 7)), duty_cycle=Envelope(items=(0, 1, 2)) + ) def test_a_dimension_the_instrument_leaves_out_stays_empty(self) -> None: voice = imported(written(SequenceKind.VOLUME, (15, 0))).voice - assert voice.envelopes == InstrumentEnvelopes(volume=(15, 0)) + assert voice.envelopes == InstrumentEnvelopes(volume=Envelope(items=(15, 0))) def test_the_name_comes_across(self) -> None: assert imported(written(SequenceKind.VOLUME, (15,))).voice.name == "Lead" def test_the_voice_rests_on_the_roots_a_voice_added_by_hand_does(self) -> None: voice = imported(written(SequenceKind.VOLUME, (15,))).voice - assert voice.root_pitch == RESTING_REFERENCE_PITCH - assert voice.root_period == RESTING_REFERENCE_PERIOD + assert voice.initial_pitch == RESTING_REFERENCE_PITCH + assert voice.initial_period == RESTING_REFERENCE_PERIOD def test_an_item_outside_the_range_a_dimension_holds_is_refused(self) -> None: with pytest.raises(InvalidInstrumentValuesError): imported(written(SequenceKind.VOLUME, (99,))) -class TestTheLoopPointAVoiceAdopts: - def test_the_volume_sequence_governs(self) -> None: - voice = imported( +class TestTheLoopPointsAVoiceTakes: + """A tracker states a point per sequence, and each dimension of the voice takes its own.""" + + def test_each_sequence_brings_its_own_point(self) -> None: + envelopes = imported( written(SequenceKind.VOLUME, (15, 8), loop_point=1), - written(SequenceKind.ARPEGGIO, (0, 3), loop_point=1), - ).voice + written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0), + ).voice.envelopes - assert voice.loop_point == 1 + assert envelopes.volume.loop_point == 1 + assert envelopes.arpeggio.loop_point == 0 - def test_the_first_written_sequence_governs_where_volume_is_absent(self) -> None: - voice = imported(written(SequenceKind.ARPEGGIO, (0, 3), loop_point=1)).voice - assert voice.loop_point == 1 + def test_a_sequence_stating_no_loop_holds_its_last_item(self) -> None: + envelopes = imported(written(SequenceKind.VOLUME, (15, 0))).voice.envelopes + assert envelopes.volume.loop_point is None - def test_an_instrument_that_states_no_loop_plays_once(self) -> None: - voice = imported(written(SequenceKind.VOLUME, (15, 0))).voice - assert voice.loop_point is None + def test_an_instrument_with_nothing_written_states_no_point(self) -> None: + assert all(envelope.loop_point is None for envelope in imported().voice.envelopes.envelope_map.values()) - def test_an_instrument_with_nothing_written_plays_once(self) -> None: - assert imported().voice.loop_point is None + def test_a_loop_point_before_the_first_item_holds_the_last_item(self) -> None: + envelopes = imported(written(SequenceKind.VOLUME, (15, 0), loop_point=-4)).voice.envelopes + assert envelopes.volume.loop_point is None - def test_a_loop_point_before_the_first_item_plays_once(self) -> None: - voice = imported(written(SequenceKind.VOLUME, (15, 0), loop_point=-4)).voice - assert voice.loop_point is None + def test_a_loop_point_past_the_items_holds_the_last_item(self) -> None: + envelopes = imported(written(SequenceKind.VOLUME, (15, 0), loop_point=9)).voice.envelopes + assert envelopes.volume.loop_point is None class TestWhatTheInstrumentStatesPastTheVoice: @@ -134,28 +140,20 @@ def test_the_absolute_arpeggio_a_voice_reads_is_no_omission(self) -> None: arpeggio = written(SequenceKind.ARPEGGIO, (0, 3)) assert not self.omissions(arpeggio)[InstrumentOmission.ARPEGGIO_MODE] - def test_a_second_loop_point_is_reported(self) -> None: - omissions = self.omissions( - written(SequenceKind.VOLUME, (15, 8), loop_point=1), - written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0), + def test_a_point_per_sequence_is_carried_rather_than_reported(self) -> None: + """Each dimension holds a point of its own, so a file stating several leaves nothing behind.""" + assert ( + imported( + written(SequenceKind.VOLUME, (15, 8), loop_point=1), + written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0), + ).omissions + == () ) - assert omissions[InstrumentOmission.SEQUENCE_LOOP_POINTS] - - def test_sequences_repeating_from_one_point_leave_nothing_behind(self) -> None: - omissions = self.omissions( - written(SequenceKind.VOLUME, (15, 8), loop_point=1), - written(SequenceKind.ARPEGGIO, (0, 3), loop_point=1), - ) - assert not omissions[InstrumentOmission.SEQUENCE_LOOP_POINTS] - - def test_a_sequence_the_instrument_leaves_out_states_nothing(self) -> None: - omissions = self.omissions(written(SequenceKind.VOLUME, (15, 8), loop_point=1)) - assert not omissions[InstrumentOmission.SEQUENCE_LOOP_POINTS] def test_every_dimension_past_the_voice_is_named_at_once(self) -> None: reported = imported( written(SequenceKind.VOLUME, (15, 8), loop_point=1), - written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0, setting=ARPEGGIO_SCHEME_SETTING), + written(SequenceKind.ARPEGGIO, (0, 3), setting=ARPEGGIO_SCHEME_SETTING), written(SequenceKind.PITCH, (1, -1), release_point=1), written(SequenceKind.HI_PITCH, (0,)), ).omissions @@ -172,38 +170,58 @@ def round_trip(voice: Instrument) -> ImportedVoice: STANDALONE_INSTRUMENT_INDEX, voice.name, voice.instrument_features(), - loop_point=voice.loop_point, ) return instrument_to_voice(fti_bytes_to_instrument(instrument_to_fti_bytes(tracker))) def test_the_envelopes_come_back(self) -> None: voice = Instrument( name="Pad", - envelopes=InstrumentEnvelopes(volume=(15, 10, 5), arpeggio=(0, 3, 7), duty_cycle=(0, 1, 2)), - loop_point=1, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(15, 10, 5), loop_point=1), + arpeggio=Envelope(items=(0, 3, 7)), + duty_cycle=Envelope(items=(0, 1, 2)), + ), ) assert self.round_trip(voice).voice.envelopes == voice.envelopes - def test_the_loop_point_comes_back(self) -> None: - voice = Instrument(name="Pad", envelopes=InstrumentEnvelopes(volume=(15, 10, 5)), loop_point=2) - assert self.round_trip(voice).voice.loop_point == 2 + def test_each_dimension_keeps_its_own_loop_point(self) -> None: + """A tracker advances every sequence on a counter of its own, and a file states each one.""" + voice = Instrument( + name="Pad", + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(15, 10, 5), loop_point=2), + arpeggio=Envelope(items=(0, 3, 7, 12), loop_point=0), + duty_cycle=Envelope(items=(0, 1)), + ), + ) + + envelopes = self.round_trip(voice).voice.envelopes + + assert envelopes.volume.loop_point == 2 + assert envelopes.arpeggio.loop_point == 0 + assert envelopes.duty_cycle.loop_point is None def test_the_name_comes_back(self) -> None: - voice = Instrument(name="Bass Line", envelopes=InstrumentEnvelopes(volume=(15, 0))) + voice = Instrument(name="Bass Line", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 0)))) assert self.round_trip(voice).voice.name == "Bass Line" def test_a_voice_of_its_own_making_leaves_nothing_behind(self) -> None: voice = Instrument( name="Pad", - envelopes=InstrumentEnvelopes(volume=(15, 10, 5), arpeggio=(0, 3, 7)), - loop_point=1, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(15, 10, 5), loop_point=1), + arpeggio=Envelope(items=(0, 3, 7)), + ), ) assert self.round_trip(voice).omissions == () - def test_a_shorter_dimension_comes_back_holding_its_final_value(self) -> None: + def test_a_shorter_dimension_comes_back_at_its_own_length(self) -> None: + """Every sequence stands at the length it was written, which is what a tracker reads.""" voice = Instrument( name="Pad", - envelopes=InstrumentEnvelopes(volume=(15, 10, 5), duty_cycle=(2,)), - loop_point=0, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(15, 10, 5), loop_point=0), + duty_cycle=Envelope(items=(2,)), + ), ) - assert self.round_trip(voice).voice.envelopes.duty_cycle == (2, 2, 2) + assert self.round_trip(voice).voice.envelopes.duty_cycle.items == (2,) diff --git a/tests/unit/sampletones_core/performance/test_instrument_walk.py b/tests/unit/sampletones_core/performance/test_instrument_walk.py index 0f96f1908..2037c3d0a 100644 --- a/tests/unit/sampletones_core/performance/test_instrument_walk.py +++ b/tests/unit/sampletones_core/performance/test_instrument_walk.py @@ -5,6 +5,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import PulseInstruction from sampletones_core.performance import song_instructions from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -20,10 +21,14 @@ def _instrument(loop: bool = False) -> Instrument: + point = WHOLE_LOOP_POINT if loop else None return Instrument( name="lead", - envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=(1,)), - loop_point=WHOLE_LOOP_POINT if loop else None, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME, loop_point=point), + arpeggio=Envelope(items=ARPEGGIO, loop_point=point), + duty_cycle=Envelope(items=(1,), loop_point=point), + ), ) @@ -81,14 +86,30 @@ def test_the_channels_it_was_not_placed_on_rest(self, test_case: TestCase) -> No class TestAnInstrumentInASong: - def test_a_one_shot_falls_silent_past_its_envelopes(self) -> None: + def test_an_instrument_holds_its_final_values_past_its_envelopes(self) -> None: + """Every dimension halts on the value it wrote, which the note goes on sounding.""" instrument = _instrument() project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument) stream = song_instructions(project)[ChannelName.PULSE1] - assert stream[len(VOLUME) :] == [_resting(ChannelName.PULSE1)] * (len(stream) - len(VOLUME)) + held = stream[len(VOLUME)] + assert held is not None and held.volume == VOLUME[-1] + assert stream[len(VOLUME) :] == [held] * (len(stream) - len(VOLUME)) + + def test_a_volume_envelope_ending_at_silence_releases_the_note(self) -> None: + """A trailing zero is what stops a note, the way a tracker's own sequences end one.""" + instrument = Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 10, 0))), + ) + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument) + + stream = song_instructions(project)[ChannelName.PULSE1] + + assert all(instruction is None or not instruction.on for instruction in stream[2:]) def test_a_looping_instrument_keeps_sounding(self) -> None: instrument = _instrument(loop=True) @@ -113,4 +134,4 @@ def test_a_rows_transpose_bends_the_instrument_off_its_root(self) -> None: first = song_instructions(project)[ChannelName.PULSE1][0] assert isinstance(first, PulseInstruction) - assert first.pitch == instrument.root_pitch + ARPEGGIO[0] + 7 + assert first.pitch == instrument.initial_pitch + ARPEGGIO[0] + 7 diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 13c30775b..0e355ab6a 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -8,6 +8,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.data import Metadata +from sampletones_core.features.envelope import Envelope from sampletones_core.project.container import ProjectContainer from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project @@ -196,10 +197,13 @@ def test_an_instrument_survives_a_round_trip(self, tmp_path: Path) -> None: project = Project.create(title="Demo") instrument = Instrument( name="lead", - envelopes=InstrumentEnvelopes(volume=(15, 12), arpeggio=(0, 7), duty_cycle=(2,)), - root_pitch=55, - root_period=3, - loop_point=WHOLE_LOOP_POINT, + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(15, 12), loop_point=WHOLE_LOOP_POINT), + arpeggio=Envelope(items=(0, 7)), + duty_cycle=Envelope(items=(2,)), + ), + initial_pitch=55, + initial_period=3, ) project.voices.append(instrument) path = tmp_path / "demo.stp" @@ -211,9 +215,9 @@ def test_an_instrument_survives_a_round_trip(self, tmp_path: Path) -> None: restored = loaded.voice(instrument.id) assert isinstance(restored, Instrument) assert restored.envelopes == instrument.envelopes - assert restored.root_pitch == instrument.root_pitch - assert restored.root_period == instrument.root_period - assert restored.loop_point == instrument.loop_point + assert restored.initial_pitch == instrument.initial_pitch + assert restored.initial_period == instrument.initial_period + assert restored.envelopes.volume.loop_point == instrument.envelopes.volume.loop_point def test_an_instrument_leaves_no_reconstruction_in_the_archive(self, tmp_path: Path) -> None: project = Project.create(title="Demo") @@ -248,7 +252,7 @@ def test_a_row_naming_an_instrument_still_names_it_after_a_round_trip( tmp_path: Path, ) -> None: project = Project.create(title="Demo") - instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(volume=(15,))) + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,)))) project.voices.append(instrument) project.song[ChannelName.PULSE1].patterns[0].rows[0] = Row(command=NoteOn(voice_id=instrument.id)) path = tmp_path / "demo.stp" diff --git a/tests/unit/sampletones_core/project/voices/test_creation.py b/tests/unit/sampletones_core/project/voices/test_creation.py index 9f80069f2..f2ea27801 100644 --- a/tests/unit/sampletones_core/project/voices/test_creation.py +++ b/tests/unit/sampletones_core/project/voices/test_creation.py @@ -6,6 +6,7 @@ from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.exporters import Features from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH +from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import instrument_from_features, new_instrument from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT @@ -25,11 +26,11 @@ def _features( """What one channel plays, as its exporter states it.""" return Features( initial_pitch=reference, - volume=np.array(volume, dtype=np.int8), - arpeggio=np.array(ARPEGGIO, dtype=np.int8), + volume=Envelope(items=tuple(volume)), + arpeggio=Envelope(items=tuple(ARPEGGIO)), pitch=None, hi_pitch=None, - duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=np.int8), + duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)), ) @@ -43,22 +44,22 @@ def test_a_new_instrument_sounds_a_frame_on_every_channel(self) -> None: def test_a_new_instrument_sounds_at_full_volume(self) -> None: instrument = new_instrument("lead") - assert instrument.envelopes.volume == (MAX_VOLUME,) + assert instrument.envelopes.volume.items == (MAX_VOLUME,) - def test_a_new_instrument_repeats_its_envelopes_while_the_note_is_held(self) -> None: - assert new_instrument("lead").loop_point == WHOLE_LOOP_POINT + def test_a_new_instrument_repeats_its_volume_while_the_note_is_held(self) -> None: + assert new_instrument("lead").envelopes.volume.loop_point == WHOLE_LOOP_POINT def test_a_new_instrument_leaves_the_arpeggio_and_the_duty_cycle_to_the_channel(self) -> None: instrument = new_instrument("lead") - assert instrument.envelopes.arpeggio == () - assert instrument.envelopes.duty_cycle == () + assert not instrument.envelopes.arpeggio.written + assert not instrument.envelopes.duty_cycle.written def test_a_new_instrument_rests_where_a_channel_added_by_hand_rests(self) -> None: instrument = new_instrument("lead") - assert instrument.root_pitch == RESTING_REFERENCE_PITCH - assert instrument.root_period == RESTING_REFERENCE_PERIOD + assert instrument.initial_pitch == RESTING_REFERENCE_PITCH + assert instrument.initial_period == RESTING_REFERENCE_PERIOD def test_each_new_instrument_is_a_voice_of_its_own(self) -> None: assert new_instrument("lead").id != new_instrument("lead").id @@ -72,12 +73,11 @@ def test_the_envelopes_come_across_as_the_channel_played_them(self) -> None: "Bass (pulse1)", _features(TONAL_REFERENCE), ChannelName.PULSE1, - loop_point=None, ) - assert instrument.envelopes.volume == VOLUME - assert instrument.envelopes.arpeggio == ARPEGGIO - assert instrument.envelopes.duty_cycle == DUTY_CYCLE + assert instrument.envelopes.volume.items == VOLUME + assert instrument.envelopes.arpeggio.items == ARPEGGIO + assert instrument.envelopes.duty_cycle.items == DUTY_CYCLE def test_a_dimension_the_channel_governs_stays_the_channels(self) -> None: """An empty envelope means the channel keeps the value it holds, on either side of this.""" @@ -85,10 +85,9 @@ def test_a_dimension_the_channel_governs_stays_the_channels(self) -> None: "Bass (pulse1)", _features(TONAL_REFERENCE, volume=()), ChannelName.PULSE1, - loop_point=None, ) - assert instrument.envelopes.volume == () + assert instrument.envelopes.volume.items == () def test_a_dimension_the_channel_lacks_is_left_unwritten(self) -> None: """The triangle channel offers no duty cycle, so the voice writes none for it.""" @@ -96,38 +95,35 @@ def test_a_dimension_the_channel_lacks_is_left_unwritten(self) -> None: "Bass (triangle)", _features(TONAL_REFERENCE, duty_cycle=None), ChannelName.TRIANGLE, - loop_point=None, ) - assert instrument.envelopes.duty_cycle == () + assert instrument.envelopes.duty_cycle.items == () def test_a_tonal_channels_reference_becomes_the_note_the_arpeggio_is_measured_against(self) -> None: instrument = instrument_from_features( "Bass (pulse1)", _features(TONAL_REFERENCE), ChannelName.PULSE1, - loop_point=None, ) - assert instrument.root_pitch == TONAL_REFERENCE - assert instrument.root_period == RESTING_REFERENCE_PERIOD + assert instrument.initial_pitch == TONAL_REFERENCE + assert instrument.initial_period == RESTING_REFERENCE_PERIOD def test_the_noise_channels_reference_becomes_the_period_the_arpeggio_is_measured_against(self) -> None: instrument = instrument_from_features( "Bass (noise)", _features(NOISE_REFERENCE), ChannelName.NOISE, - loop_point=None, ) - assert instrument.root_period == NOISE_REFERENCE - assert instrument.root_pitch == RESTING_REFERENCE_PITCH + assert instrument.initial_period == NOISE_REFERENCE + assert instrument.initial_pitch == RESTING_REFERENCE_PITCH def test_the_voice_reads_on_its_own_channel_what_that_channel_stated(self) -> None: """The reference travels with the envelopes, so the two agree where they came from.""" features = _features(TONAL_REFERENCE) - instrument = instrument_from_features("Bass (pulse1)", features, ChannelName.PULSE1, loop_point=None) + instrument = instrument_from_features("Bass (pulse1)", features, ChannelName.PULSE1) assert instrument.features(ChannelName.PULSE1).initial_pitch == features.initial_pitch @@ -136,27 +132,24 @@ def test_the_voice_sounds_the_frames_the_channel_sounded(self) -> None: "Bass (pulse1)", _features(TONAL_REFERENCE), ChannelName.PULSE1, - loop_point=None, ) assert len(instrument.instructions(ChannelName.PULSE1)) == len(VOLUME) - def test_the_voice_repeats_from_the_tick_it_was_given(self) -> None: + def test_the_voice_holds_each_dimension_out_past_its_items(self) -> None: instrument = instrument_from_features( "Bass (pulse1)", _features(TONAL_REFERENCE), ChannelName.PULSE1, - loop_point=WHOLE_LOOP_POINT, ) - assert instrument.loop_point == WHOLE_LOOP_POINT + assert all(envelope.loop_point is None for envelope in instrument.envelopes.envelope_map.values()) def test_the_voice_carries_the_name_it_was_given(self) -> None: instrument = instrument_from_features( "Bass (pulse1)", _features(TONAL_REFERENCE), ChannelName.PULSE1, - loop_point=None, ) assert instrument.name == "Bass (pulse1)" diff --git a/tests/unit/sampletones_core/project/voices/test_instrument.py b/tests/unit/sampletones_core/project/voices/test_instrument.py index 3e5fb1e4d..ce9ad3e49 100644 --- a/tests/unit/sampletones_core/project/voices/test_instrument.py +++ b/tests/unit/sampletones_core/project/voices/test_instrument.py @@ -12,6 +12,7 @@ supported_features, supports, ) +from sampletones_core.features.envelope import Envelope from sampletones_core.features.spec import CHANNEL_GENERATOR_KIND from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -28,7 +29,9 @@ def _instrument(**overrides: object) -> Instrument: fields: dict = { "name": "lead", - "envelopes": InstrumentEnvelopes(volume=VOLUME, arpeggio=ARPEGGIO, duty_cycle=DUTY_CYCLE), + "envelopes": InstrumentEnvelopes( + volume=Envelope(items=VOLUME), arpeggio=Envelope(items=ARPEGGIO), duty_cycle=Envelope(items=DUTY_CYCLE) + ), } fields.update(overrides) return Instrument(**fields) @@ -39,26 +42,25 @@ def test_each_instrument_gets_its_own_id(self) -> None: assert _instrument().id != _instrument().id def test_clone_gets_a_fresh_id_and_carries_the_rest(self) -> None: - instrument = _instrument(root_pitch=48, root_period=3, loop_point=WHOLE_LOOP_POINT) + instrument = _instrument(initial_pitch=48, initial_period=3) clone = instrument.clone() assert clone.id != instrument.id assert clone.name == instrument.name assert clone.envelopes == instrument.envelopes - assert clone.root_pitch == instrument.root_pitch - assert clone.root_period == instrument.root_period - assert clone.loop_point == instrument.loop_point + assert clone.initial_pitch == instrument.initial_pitch + assert clone.initial_period == instrument.initial_period class TestInstrumentRoots: def test_an_instrument_rests_where_a_channel_added_by_hand_rests(self) -> None: instrument = Instrument(name="lead") - assert instrument.root_pitch == RESTING_REFERENCE_PITCH - assert instrument.root_period == RESTING_REFERENCE_PERIOD + assert instrument.initial_pitch == RESTING_REFERENCE_PITCH + assert instrument.initial_period == RESTING_REFERENCE_PERIOD def test_the_tonal_channels_read_the_pitch_and_noise_reads_the_period(self) -> None: - instrument = _instrument(root_pitch=55, root_period=3) + instrument = _instrument(initial_pitch=55, initial_period=3) assert instrument.reference(ChannelName.PULSE1) == 55 assert instrument.reference(ChannelName.PULSE2) == 55 @@ -85,7 +87,7 @@ def test_the_channel_reads_the_dimensions_it_offers(self, test_case: TestCase) - features = _instrument().features(test_case.channel_name) kind = CHANNEL_GENERATOR_KIND[test_case.channel_name] - assert set(features.keys()) >= set(supported_features(kind)) + assert set(features.envelopes) >= set(supported_features(kind)) assert (features.duty_cycle is not None) is supports(kind, FeatureKey.DUTY_CYCLE) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) @@ -143,7 +145,7 @@ def test_an_edit_reaches_the_frames(self) -> None: instrument = _instrument() before = instrument.instructions(ChannelName.PULSE1) - instrument.envelopes = instrument.envelopes.with_envelope(FeatureKey.ARPEGGIO, (7,)) + instrument.envelopes = instrument.envelopes.with_envelope(FeatureKey.ARPEGGIO, Envelope(items=(7,))) instrument.invalidate() after = instrument.instructions(ChannelName.PULSE1) @@ -154,7 +156,7 @@ def test_an_edit_reaches_the_frames(self) -> None: class TestHeldDimensions: def test_an_empty_envelope_is_left_to_the_channel(self) -> None: - instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=ARPEGGIO)) + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=Envelope(items=ARPEGGIO))) held = instrument.held_features(ChannelName.PULSE1) @@ -163,7 +165,7 @@ def test_an_empty_envelope_is_left_to_the_channel(self) -> None: assert FeatureKey.ARPEGGIO not in held def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None: - instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=ARPEGGIO)) + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=Envelope(items=ARPEGGIO))) assert FeatureKey.DUTY_CYCLE not in instrument.held_features(ChannelName.TRIANGLE) @@ -171,13 +173,13 @@ def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None: class TestEnvelopeBounds: def test_a_volume_past_the_range_is_refused(self) -> None: with pytest.raises(ValidationError): - InstrumentEnvelopes(volume=(MAX_VOLUME + 1,)) + InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME + 1,))) def test_a_dimension_an_instrument_writes_none_of_is_refused(self) -> None: with pytest.raises(KeyError): - InstrumentEnvelopes().with_envelope(FeatureKey.PITCH, (1,)) + InstrumentEnvelopes().with_envelope(FeatureKey.PITCH, Envelope(items=(1,))) def test_the_frame_count_is_the_longest_dimension(self) -> None: - envelopes = InstrumentEnvelopes(volume=VOLUME, duty_cycle=DUTY_CYCLE) + envelopes = InstrumentEnvelopes(volume=Envelope(items=VOLUME), duty_cycle=Envelope(items=DUTY_CYCLE)) assert envelopes.frame_count == len(VOLUME) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 295b5cc70..a857cde42 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -482,7 +482,7 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None features = reconstruction.export()[ChannelName.PULSE1] assert features.initial_pitch == _BASE_PITCH - assert features.arpeggio.tolist() == [_OCTAVE, 0] + assert list(features.arpeggio.items) == [_OCTAVE, 0] def test_update_generator_data_replaces_the_reference(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) @@ -531,8 +531,8 @@ def test_a_held_dimension_exports_an_empty_envelope(self) -> None: ) features = reconstruction.export()[ChannelName.PULSE1] - assert features.arpeggio.size == 0 - assert features.volume.size > 0 + assert not features.arpeggio.written + assert features.volume.written def test_the_written_dimensions_export_their_items(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) @@ -547,7 +547,7 @@ def test_the_written_dimensions_export_their_items(self) -> None: features = reconstruction.export()[ChannelName.PULSE1] assert features.duty_cycle is not None - assert features.duty_cycle.size > 0 + assert features.duty_cycle.written def test_the_record_reads_back_off_the_exported_envelopes(self) -> None: """What a reconstruction says it holds is what its export shows, on every channel. @@ -637,8 +637,8 @@ def test_a_channel_standing_by_exports_empty_envelopes(self) -> None: features = _reconstruction([_pulse(_BASE_PITCH)]).export()[ChannelName.PULSE2] assert not features.has_frames - assert features.volume.size == 0 - assert features.arpeggio.size == 0 + assert not features.volume.written + assert not features.arpeggio.written def test_a_channel_standing_by_renders_no_audio(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) diff --git a/tests/unit/sampletones_player/test_instrument_song.py b/tests/unit/sampletones_player/test_instrument_song.py index bc94b45e3..b3250fef4 100644 --- a/tests/unit/sampletones_player/test_instrument_song.py +++ b/tests/unit/sampletones_player/test_instrument_song.py @@ -1,6 +1,7 @@ from typing import Final, Tuple from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project from sampletones_core.project.voices.envelopes import InstrumentEnvelopes @@ -16,7 +17,9 @@ def _project() -> Project: instrument = Instrument( name="Lead", - envelopes=InstrumentEnvelopes(volume=VOLUME, arpeggio=(0, 4, 7), duty_cycle=(1,)), + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME), arpeggio=Envelope(items=(0, 4, 7)), duty_cycle=Envelope(items=(1,)) + ), ) project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN) project.voices.append(instrument) From df3cb0c1ec3f02191c965ae05ad896fd4e225821 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 17:58:08 +0200 Subject: [PATCH 115/142] Implemented: loop point parsing symbol --- .../coordinators/tabs/reconstruction.py | 7 +- .../logic/reconstruction/instruments.py | 40 +----- .../logic/sequencer/clipboard/tracker.py | 4 +- .../reconstruction/instruments/instruments.py | 113 +++++++++------- src/sampletones_config/lang/en.yaml | 2 +- src/sampletones_core/features/text.py | 49 +++++++ src/sampletones_shared/constants/symbols.py | 1 + .../logic/reconstruction/test_instruments.py | 71 ++++------ .../reconstruction/test_instruments_panel.py | 123 ++++++++++++++++-- .../sampletones_core/features/test_text.py | 78 +++++++++++ 10 files changed, 348 insertions(+), 140 deletions(-) create mode 100644 src/sampletones_core/features/text.py create mode 100644 tests/unit/sampletones_core/features/test_text.py diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index e78b5ede9..9b198c335 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -292,11 +292,8 @@ def __init__( self._reconstruction_instruments_panel.on_pitch_value_changed = ( self._reconstruction_instruments_logic.handle_pitch_value_changed ) - self._reconstruction_instruments_panel.on_bar_data_changed = ( - self._reconstruction_instruments_logic.handle_bar_point_clicked - ) - self._reconstruction_instruments_panel.on_raw_data_changed = ( - self._reconstruction_instruments_logic.handle_raw_data_changed + self._reconstruction_instruments_panel.on_envelope_changed = ( + self._reconstruction_instruments_logic.handle_envelope_changed ) def _on_export_result(self, result: ExportResult) -> None: diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 0b4088bff..5c65d5a6b 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -1,7 +1,5 @@ from typing import Callable, Dict, Optional -import numpy as np - from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -180,27 +178,17 @@ def handle_pitch_value_changed( ) ) - def handle_bar_point_clicked( + def handle_envelope_changed( self, channel_name: ChannelName, feature_key: FeatureKey, - data: np.ndarray, + envelope: Envelope[int], ) -> None: - envelope = self._edited_envelope(channel_name, feature_key, data) - if self._write_instrument_envelope(feature_key, envelope): - return - - features = self._get_features(channel_name).with_envelope(feature_key, envelope) - self._report_edited_size(channel_name, features) - self._schedule_reconstruction_update(ReconstructionUpdate(channel_name, feature_key, features)) + """Takes one dimension as an edit leaves it, values and loop point together. - def handle_raw_data_changed( - self, - channel_name: ChannelName, - feature_key: FeatureKey, - data: np.ndarray, - ) -> None: - envelope = self._edited_envelope(channel_name, feature_key, data) + The panel states the whole dimension, so a bar redrawn on the plot and a sequence typed + into the text field arrive the same way and are written the same way. + """ if self._write_instrument_envelope(feature_key, envelope): return @@ -208,22 +196,6 @@ def handle_raw_data_changed( self._report_edited_size(channel_name, features) self._schedule_reconstruction_update(ReconstructionUpdate(channel_name, feature_key, features)) - def _edited_envelope( - self, - channel_name: ChannelName, - feature_key: FeatureKey, - data: np.ndarray, - ) -> Envelope[int]: - """The dimension as the edit leaves it, repeating from the point it already held.""" - items = tuple(int(value) for value in data) - instrument = self.instrument_edit - standing = ( - instrument.features.envelopes.get(feature_key) - if instrument is not None - else self._get_features(channel_name).envelopes.get(feature_key) - ) - return standing.with_items(items) if standing is not None else Envelope[int](items=items) - def _write_instrument_envelope( self, feature_key: FeatureKey, diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py index a5eb89728..aa95dec50 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/tracker.py +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -25,7 +25,7 @@ display_transpose, display_volume, ) -from sampletones_shared.constants.symbols import PLUS, SIGNS +from sampletones_shared.constants.symbols import PIPE, PLUS, SIGNS from .fields import ( FieldReading, @@ -39,7 +39,7 @@ TRACKER_GRID: Final[str] = "tracker" SLOT_KEY: Final[str] = "slots" -COLUMN_SEPARATOR: Final[str] = "|" +COLUMN_SEPARATOR: Final[str] = PIPE NOTE_WIDTH: Final[int] = len(display_id(None)) TRANSPOSE_WIDTH: Final[int] = len(display_transpose(None)) VOLUME_WIDTH: Final[int] = len(display_volume(None)) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 536ac1ed2..c19eef850 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -80,6 +80,8 @@ resting_reference, supported_features, ) +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.text import format_envelope, parse_envelope from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) @@ -97,6 +99,11 @@ OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None] +def _plotted_items(envelope: Envelope[int]) -> np.ndarray: + """The values a dimension writes, as the bar plot draws them.""" + return np.array(envelope.items, dtype=np.int8) + + class GUIReconstructionInstrumentsPanel(GUIPanel): def __init__( self, @@ -123,7 +130,7 @@ def __init__( self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) self._graphs: Dict[str, GUIBarGraph] = {} - self._sequence_lengths: Dict[Tuple[ChannelName, FeatureKey], int] = {} + self._sequences: Dict[Tuple[ChannelName, FeatureKey], Envelope[int]] = {} self._pitch_stepper_style = pitch_stepper_style self._copy_width = copy_width self._layout_graphs = layout_graphs @@ -140,8 +147,7 @@ def __init__( self.on_reconstruction_instrument_hovered: Optional[OnReconstructionInstrumentHoveredCallback] = None self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None - self.on_bar_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None - self.on_raw_data_changed: Optional[Callable[[ChannelName, FeatureKey, np.ndarray], None]] = None + self.on_envelope_changed: Optional[Callable[[ChannelName, FeatureKey, Envelope[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) @@ -384,11 +390,10 @@ def _add_generator_feature_display( parent=window_tag, ): dpg.add_separator(parent=window_tag) - feature_data_array = np.empty(0, dtype=np.int8) plot = self._create_feature_display( channel_name, feature_key, - feature_data_array, + Envelope[int](), feature_group_tag, ) self.channel_plots[channel_name][feature_key] = plot @@ -429,16 +434,15 @@ def _update_raw_data_text( self, channel_name: ChannelName, feature_key: FeatureKey, - data: np.ndarray, + envelope: Envelope[int], ) -> None: text_group_tag = self._get_feature_text_group_tag( channel_name, feature_key, ) raw_data_tag = self._get_feature_text_tag(text_group_tag) - raw_data_text = self._format_data(data) - dpg_set_value(raw_data_tag, raw_data_text) - self._apply_input_theme(channel_name, feature_key, len(data)) + dpg_set_value(raw_data_tag, format_envelope(envelope)) + self._show_sequence(channel_name, feature_key, envelope) def update_view( self, @@ -557,18 +561,17 @@ def _update_generator_feature_display( generator_features: Features, feature_key: FeatureKey, ) -> None: - feature = self._feature_array(generator_features, feature_key) - self._update_generator_plot(channel_name, feature_key, feature) - self._update_raw_data_text(channel_name, feature_key, feature) + envelope = self._feature_envelope(generator_features, feature_key) + self._update_generator_plot(channel_name, feature_key, _plotted_items(envelope)) + self._update_raw_data_text(channel_name, feature_key, envelope) - def _feature_array( + def _feature_envelope( self, generator_features: Features, feature_key: FeatureKey, - ) -> np.ndarray: + ) -> Envelope[int]: envelope = generator_features.envelopes.get(feature_key) - items = envelope.items if envelope is not None else () - return np.array(items, dtype=np.int8) + return envelope if envelope is not None else Envelope[int]() def _pitch_kind(self, channel_name: ChannelName) -> PitchValueKind: return PERIOD_VALUE_KIND if channel_name == ChannelName.NOISE else PITCH_VALUE_KIND @@ -630,14 +633,14 @@ def _create_feature_display( self, channel_name: ChannelName, feature_key: FeatureKey, - data: np.ndarray, + envelope: Envelope[int], parent: str, ) -> GUIBarGraph: config = self._feature_plot_config(channel_name, feature_key) plot = self._add_bar_plot( parent, config, - data, + _plotted_items(envelope), channel_name, feature_key, ) @@ -647,7 +650,7 @@ def _create_feature_display( feature_key, config, plot, - data, + envelope, ) return plot @@ -731,9 +734,11 @@ def _on_bar_point_clicked( data: np.ndarray, plot_tag: str, ) -> None: + envelope = self._standing_sequence(channel_name, feature_key).with_items(tuple(int(value) for value in data)) raw_data_tag = compose_tag(plot_tag, SUF_GRAPH_RAW_DATA) - dpg_set_value(raw_data_tag, self._format_data(data)) - self.call(self.on_bar_data_changed, channel_name, feature_key, data) + dpg_set_value(raw_data_tag, format_envelope(envelope)) + self._show_sequence(channel_name, feature_key, envelope) + self.call(self.on_envelope_changed, channel_name, feature_key, envelope) def _on_bar_point_hovered( self, @@ -755,13 +760,13 @@ def _add_raw_data_text( feature_key: FeatureKey, config: FeaturePlotConfig, plot: GUIBarGraph, - data: np.ndarray, + envelope: Envelope[int], ) -> None: text_group_tag = self._get_feature_text_group_tag( channel_name, feature_key, ) - raw_data_text = self._format_data(data) + raw_data_text = format_envelope(envelope) raw_data_tag = self._get_feature_text_tag(text_group_tag) copy_button_tag = compose_tag(text_group_tag, SUF_BUTTON_COPY) @@ -770,8 +775,9 @@ def _add_raw_data_text( tag=copy_button_tag, label=self._lbl_copy, width=self._copy_width, - callback=lambda: self._on_copy_button_clicked( - raw_data_text, + callback=self._copy_callback( + channel_name, + feature_key, copy_button_tag, ), ) @@ -810,7 +816,7 @@ def _sequence_status_message( **_kwargs: Any, ) -> str: """Describes the sequence input, naming the export limit once a sequence passes it.""" - item_count = self._sequence_lengths.get((channel_name, feature_key), 0) + item_count = len(self._standing_sequence(channel_name, feature_key).items) if item_count > MAX_SEQUENCE_ITEMS: return self._language_manager["reconstructions.instruments.message.status_sequence_too_long"].format( instrument_feature=feature_key.capitalized, @@ -822,22 +828,30 @@ def _sequence_status_message( instrument_feature=feature_key.capitalized ) - def _apply_input_theme( + def _standing_sequence( + self, + channel_name: ChannelName, + feature_key: FeatureKey, + ) -> Envelope[int]: + """The dimension this input shows, which is what an edit of its values starts from.""" + return self._sequences.get((channel_name, feature_key), Envelope[int]()) + + def _show_sequence( self, channel_name: ChannelName, feature_key: FeatureKey, - item_count: int, + envelope: Envelope[int], ) -> None: - """Colors the sequence input by how a FamiTracker export treats its length. + """Holds the dimension the input now shows, colored by how a FamiTracker export treats its length. A sequence longer than ``MAX_SEQUENCE_ITEMS`` exports its opening items, so the input carries the warning color to show which part of the envelope reaches a FamiTracker file. """ - self._sequence_lengths[(channel_name, feature_key)] = item_count + self._sequences[(channel_name, feature_key)] = envelope text_group_tag = self._get_feature_text_group_tag(channel_name, feature_key) raw_data_tag = self._get_feature_text_tag(text_group_tag) - theme = self.warning_input_theme if item_count > MAX_SEQUENCE_ITEMS else self.theme + theme = self.warning_input_theme if len(envelope.items) > MAX_SEQUENCE_ITEMS else self.theme theme.bind_to_item(raw_data_tag) def _parse_raw_data_input( @@ -847,27 +861,18 @@ def _parse_raw_data_input( user_data: Tuple[ChannelName, FeatureKey, FeaturePlotConfig, GUIBarGraph], ) -> None: channel_name, feature_key, config, plot = user_data - data_range = config.data_range if config.data_range is not None else (-128, 127) - try: - raw_data_items = app_data.strip().split() - raw_data = np.array( - [clamp(int(value), *data_range) for value in raw_data_items], - dtype=np.int8, - ) + typed = parse_envelope(app_data) except ValueError: logger.error(f"Invalid {channel_name.name} data input for {feature_key.name}: {app_data}") self.invalid_input_theme.bind_to_item(sender) return - self._apply_input_theme(channel_name, feature_key, len(raw_data)) - dpg.set_value(sender, self._format_data(raw_data)) - self.call(self.on_raw_data_changed, channel_name, feature_key, raw_data) - self._load_plot_data(plot, channel_name, feature_key, config, raw_data) - - def _format_data(self, data: np.ndarray) -> str: - string_data = [str(clamp(int(value), -128, 127)) for value in data] - return " ".join(string_data) + envelope = typed.with_items(tuple(clamp(item, *config.data_range) for item in typed.items)) + self._show_sequence(channel_name, feature_key, envelope) + dpg.set_value(sender, format_envelope(envelope)) + self.call(self.on_envelope_changed, channel_name, feature_key, envelope) + self._load_plot_data(plot, channel_name, feature_key, config, _plotted_items(envelope)) def _load_plot_data( self, @@ -886,6 +891,22 @@ def _load_plot_data( y_ticks=y_ticks, ) + def _copy_callback( + self, + channel_name: ChannelName, + feature_key: FeatureKey, + button_tag: str, + ) -> VoidCallback: + """The press handler for one dimension's copy button. + + The button stands beside its input from the moment the tab is built, so the press reads + the dimension the input shows then, written out as a reader would type it. + """ + return lambda: self._on_copy_button_clicked( + format_envelope(self._standing_sequence(channel_name, feature_key)), + button_tag, + ) + def _on_copy_button_clicked(self, text: str, button_tag: str) -> None: copy_to_clipboard( text, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 3d0f941bf..6fd95c744 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -488,7 +488,7 @@ reconstructions.instruments.label.initial_pitch: "Initial pitch: " reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type value. Enter note name (e.g. \"C-4\") or MIDI value (72)." reconstructions.instruments.message.status_input_period: "Ctrl + click to type value. Enter period name (e.g. \"4-#\") or integer value (4)." reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options." -reconstructions.instruments.message.status_sequence: "Edit and press Enter to change {instrument_feature}." +reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply." reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on a FamiTracker export." reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." reconstructions.instruments.message.status_channel_toggle: "Click to turn {on_or_off} {channel_name}." diff --git a/src/sampletones_core/features/text.py b/src/sampletones_core/features/text.py new file mode 100644 index 000000000..dcfe05b54 --- /dev/null +++ b/src/sampletones_core/features/text.py @@ -0,0 +1,49 @@ +from typing import Final, List, Optional + +from sampletones_core.features.envelope import Envelope +from sampletones_shared.constants.symbols import PIPE + +LOOP_POINT: Final[str] = PIPE +ITEM_SEPARATOR: Final[str] = " " + + +def format_envelope(envelope: Envelope[int]) -> str: + """Writes a dimension out as a reader types it: its values, spaced, around the point they repeat from. + + Args: + envelope: The dimension to write out. + + Returns: + str: The values separated by spaces, with ``|`` standing before the item they repeat from. + """ + tokens: List[str] = [str(item) for item in envelope.items] + if envelope.loop_point is not None: + tokens.insert(envelope.loop_point, LOOP_POINT) + + return ITEM_SEPARATOR.join(tokens) + + +def parse_envelope(text: str) -> Envelope[int]: + """Reads a dimension a reader typed, taking ``|`` as the item the values repeat from. + + Args: + text: Whole numbers separated by spaces, with at most one ``|`` standing among them. + + Returns: + Envelope[int]: The values written, repeating from the item ``|`` stands before. + + Raises: + ValueError: Where a token is neither a whole number nor ``|``, where a second ``|`` stands + among the values, or where ``|`` stands past the last value written. + """ + items: List[int] = [] + loop_point: Optional[int] = None + for token in text.split(): + if token != LOOP_POINT: + items.append(int(token)) + elif loop_point is None: + loop_point = len(items) + else: + raise ValueError(f"a dimension repeats from one item, and {text!r} marks two") + + return Envelope[int](items=tuple(items), loop_point=loop_point) diff --git a/src/sampletones_shared/constants/symbols.py b/src/sampletones_shared/constants/symbols.py index 05f61f5f9..47c98799c 100644 --- a/src/sampletones_shared/constants/symbols.py +++ b/src/sampletones_shared/constants/symbols.py @@ -5,6 +5,7 @@ DOT: Final[str] = "." UNDERSCORE: Final[str] = "_" MIXED: Final[str] = "?" +PIPE: Final[str] = "|" MINUS: Final[str] = "-" PLUS: Final[str] = "+" PLUS_MINUS: Final[str] = f"{PLUS}{MINUS}" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 061f2c5f4..b955f4985 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -1,7 +1,6 @@ from typing import Callable, Dict, List, Optional from unittest.mock import MagicMock -import numpy as np import pytest from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL @@ -175,56 +174,36 @@ def test_an_envelope_edit_is_measured_as_it_arrives( received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append - volume = np.array([15, 12, 8, 4, 0], dtype=np.int8) - instruments_logic.handle_raw_data_changed( + volume = Envelope[int](items=(15, 12, 8, 4, 0)) + instruments_logic.handle_envelope_changed( ChannelName.PULSE1, FeatureKey.VOLUME, volume, ) - edited = feature_data.channels[ChannelName.PULSE1].model_copy(deep=True) - edited = edited.with_envelope(FeatureKey.VOLUME, Envelope(items=tuple(int(item) for item in volume))) + edited = feature_data.channels[ChannelName.PULSE1].with_envelope(FeatureKey.VOLUME, volume) footprint = received[0].footprint assert footprint is not None assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited).total_bytes - def test_a_bar_edit_is_measured_as_it_arrives( - self, - instruments_logic: ReconstructionInstrumentsLogic, - mock_reconstruction_manager: MagicMock, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - feature_data = FeatureData.load(reconstruction_factory()) - mock_reconstruction_manager.current_features = feature_data - received: List[ReconstructionInstrumentsViewModel] = [] - instruments_logic.on_view_changed = received.append - - instruments_logic.handle_bar_point_clicked( - ChannelName.PULSE1, - FeatureKey.ARPEGGIO, - np.zeros(6, dtype=np.int8), - ) - - assert received[0].footprint is not None - def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - """The regeneration owns the loaded envelopes, so the measurement reads a copy.""" + """The regeneration owns the loaded envelopes, so the measurement reads them without writing.""" feature_data = FeatureData.load(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data - loaded_volume = feature_data.channels[ChannelName.PULSE1].volume.copy() + loaded_volume = feature_data.channels[ChannelName.PULSE1].volume - instruments_logic.handle_raw_data_changed( + instruments_logic.handle_envelope_changed( ChannelName.PULSE1, FeatureKey.VOLUME, - np.array([15, 12, 8, 4, 0], dtype=np.int8), + Envelope[int](items=(15, 12, 8, 4, 0)), ) - assert np.array_equal(feature_data.channels[ChannelName.PULSE1].volume, loaded_volume) + assert feature_data.channels[ChannelName.PULSE1].volume == loaded_volume def test_a_refresh_reports_the_view_alone( self, @@ -273,36 +252,42 @@ def test_forwards_generator_pitch_feature_and_value( channel_features.model_copy.assert_called_once_with(update={"initial_pitch": 61}) -class TestReconstructionInstrumentsLogicHandleBarPoint: - def test_handle_bar_point_clicked_schedules_update( +class TestReconstructionInstrumentsLogicHandleEnvelope: + def test_an_edited_envelope_schedules_an_update( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, ) -> None: callback = MagicMock() instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_bar_point_clicked( + instruments_logic.handle_envelope_changed( ChannelName.PULSE1, FeatureKey.VOLUME, - np.zeros(4, dtype=np.float32), + Envelope[int](items=(0, 0, 0, 0)), ) callback.assert_called_once() - -class TestReconstructionInstrumentsLogicHandleRawData: - def test_handle_raw_data_changed_schedules_update( + def test_an_edited_envelope_keeps_the_point_it_was_given( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], ) -> None: - callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_raw_data_changed( + """The panel states values and loop point together, so the update carries both.""" + mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory()) + received: List[Features] = [] + instruments_logic.on_reconstruction_instrument_updated = lambda _channel, _key, features: received.append( + features + ) + arpeggio = Envelope[int](items=(0, 4, 7), loop_point=1) + + instruments_logic.handle_envelope_changed( ChannelName.PULSE1, FeatureKey.ARPEGGIO, - np.zeros(4, dtype=np.float32), + arpeggio, ) - callback.assert_called_once() + + assert received[0].arpeggio == arpeggio class TestReconstructionInstrumentsLogicOnUpdateScheduled: @@ -398,10 +383,10 @@ def test_an_envelope_edit_reaches_the_instrument_without_a_regeneration( regenerated: List[object] = [] instrument_logic.on_reconstruction_instrument_updated = lambda *args: regenerated.append(args) - instrument_logic.handle_raw_data_changed( + instrument_logic.handle_envelope_changed( INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO, - np.array([0, 7], dtype=np.int8), + Envelope[int](items=(0, 7)), ) instrument = project_controller.project.voices[project_controller.project.voices[0].id] diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index a8fe14937..c020870ab 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -2,6 +2,7 @@ from typing import Dict, Final, List, cast from unittest.mock import MagicMock +import numpy as np import pytest from sampletones_application.categories.manager import LanguageManager @@ -14,12 +15,14 @@ PALETTES_DIRECTORY, THEME_DIRECTORY, ) +from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_INSTRUMENT_TABS, TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, ) +from sampletones_application.tags.graphs import SUF_GRAPH_RAW_DATA from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle @@ -36,6 +39,7 @@ ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, @@ -57,6 +61,11 @@ ) +def sequence(item_count: int) -> Envelope[int]: + """A dimension of a given length, which is all the length warning reads of it.""" + return Envelope[int](items=(0,) * item_count) + + def build_view_model( channel_footprints: Dict[ChannelName, InstrumentFootprint], ) -> ReconstructionInstrumentsViewModel: @@ -143,7 +152,7 @@ def test_a_sequence_within_the_limit_keeps_the_default_theme( bound_themes: List[str], item_count: int, ) -> None: - panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, item_count) + panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(item_count)) assert bound_themes == [TAG_GLOBAL_THEME_DEFAULT] def test_a_sequence_beyond_the_limit_takes_the_warning_theme( @@ -151,7 +160,7 @@ def test_a_sequence_beyond_the_limit_takes_the_warning_theme( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1) + panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS + 1)) assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING] def test_a_shortened_sequence_returns_to_the_default_theme( @@ -159,8 +168,8 @@ def test_a_shortened_sequence_returns_to_the_default_theme( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(ChannelName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 40) - panel._apply_input_theme(ChannelName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS) + panel._show_sequence(ChannelName.NOISE, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS + 40)) + panel._show_sequence(ChannelName.NOISE, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS)) assert bound_themes == [ TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT, @@ -171,14 +180,110 @@ def test_each_dimension_carries_its_own_length( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1) - panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.ARPEGGIO, 8) + panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS + 1)) + panel._show_sequence(ChannelName.PULSE1, FeatureKey.ARPEGGIO, sequence(8)) assert bound_themes == [ TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT, ] +class TestEditingASequence: + """A bar redrawn on the plot restates the values; the item the dimension repeats from is its own.""" + + def test_a_redrawn_bar_states_the_values_it_leaves( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + edited: List[Envelope[int]] = [] + panel.on_envelope_changed = lambda _channel, _key, envelope: edited.append(envelope) + panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(15, 12, 8))) + + panel._on_bar_point_clicked( + ChannelName.PULSE1, + FeatureKey.VOLUME, + np.array([15, 4, 8], dtype=np.int8), + "plot", + ) + + assert edited == [Envelope[int](items=(15, 4, 8))] + + def test_a_redrawn_bar_keeps_the_item_the_dimension_repeats_from( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + edited: List[Envelope[int]] = [] + panel.on_envelope_changed = lambda _channel, _key, envelope: edited.append(envelope) + panel._show_sequence( + ChannelName.PULSE1, + FeatureKey.VOLUME, + Envelope[int](items=(15, 12, 8), loop_point=1), + ) + + panel._on_bar_point_clicked( + ChannelName.PULSE1, + FeatureKey.VOLUME, + np.array([15, 4, 8], dtype=np.int8), + "plot", + ) + + assert edited == [Envelope[int](items=(15, 4, 8), loop_point=1)] + + def test_a_redrawn_bar_writes_the_dimension_out_with_its_point( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + ) -> None: + panel._show_sequence( + ChannelName.PULSE1, + FeatureKey.VOLUME, + Envelope[int](items=(15, 12, 8), loop_point=1), + ) + + panel._on_bar_point_clicked( + ChannelName.PULSE1, + FeatureKey.VOLUME, + np.array([15, 4, 8], dtype=np.int8), + "plot", + ) + + assert written[compose_tag("plot", SUF_GRAPH_RAW_DATA)] == "15 | 4 8" + + +class TestCopyingASequence: + def test_the_copy_button_hands_over_the_dimension_the_input_shows( + self, + panel: GUIReconstructionInstrumentsPanel, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The button is built while every dimension is still empty, so the press reads the input.""" + copied: List[str] = [] + monkeypatch.setattr( + GUIReconstructionInstrumentsPanel, + "_on_copy_button_clicked", + lambda _self, text, _tag: copied.append(text), + ) + callback = panel._copy_callback(ChannelName.PULSE1, FeatureKey.VOLUME, "button") + panel._show_sequence( + ChannelName.PULSE1, + FeatureKey.VOLUME, + Envelope[int](items=(15, 12, 8), loop_point=1), + ) + + callback() + + assert copied == ["15 | 12 8"] + + def test_the_handler_is_one_the_framework_can_dispatch( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + """DearPyGui reads a callback's ``__code__`` to decide how many arguments to pass it.""" + callback = panel._copy_callback(ChannelName.PULSE1, FeatureKey.VOLUME, "button") + + assert callback.__code__.co_argcount == 0 + + class TestInstrumentExport: """The export button carries the channel whose slice it writes; the destination the dialog answers with names the tracker, so no format travels from here.""" @@ -224,10 +329,10 @@ def test_a_sequence_within_the_limit_describes_editing( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme( + panel._show_sequence( ChannelName.PULSE1, FeatureKey.VOLUME, - HEXADECIMAL_BASE, + sequence(HEXADECIMAL_BASE), ) message = panel._sequence_status_message( ChannelName.PULSE1, @@ -242,7 +347,7 @@ def test_a_sequence_beyond_the_limit_names_the_limit( panel: GUIReconstructionInstrumentsPanel, bound_themes: List[str], ) -> None: - panel._apply_input_theme(ChannelName.PULSE1, FeatureKey.VOLUME, 300) + panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(300)) message = panel._sequence_status_message(ChannelName.PULSE1, FeatureKey.VOLUME) assert "300" in message assert str(MAX_SEQUENCE_ITEMS) in message diff --git a/tests/unit/sampletones_core/features/test_text.py b/tests/unit/sampletones_core/features/test_text.py new file mode 100644 index 000000000..f8a005e48 --- /dev/null +++ b/tests/unit/sampletones_core/features/test_text.py @@ -0,0 +1,78 @@ +from typing import Optional, Tuple + +import pytest + +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.text import format_envelope, parse_envelope + + +class TestWhatAReaderTypes: + @pytest.mark.parametrize( + ("text", "items", "loop_point"), + [ + ("15 14 12 10", (15, 14, 12, 10), None), + ("15 14 | 12 10", (15, 14, 12, 10), 2), + ("| 15 14", (15, 14), 0), + ("0", (0,), None), + ("", (), None), + (" 15 14 ", (15, 14), None), + ("-4 0 7", (-4, 0, 7), None), + ], + ) + def test_a_typed_sequence_states_its_values_and_the_item_they_repeat_from( + self, + text: str, + items: Tuple[int, ...], + loop_point: Optional[int], + ) -> None: + envelope = parse_envelope(text) + + assert (envelope.items, envelope.loop_point) == (items, loop_point) + + def test_the_point_names_the_item_it_stands_before(self) -> None: + """A reader places the bar where the sequence turns back, and that item is what plays next.""" + envelope = parse_envelope("15 14 | 12 10") + + assert envelope.at(4) == 12 + + @pytest.mark.parametrize( + "text", + [ + "15 | 14 | 12", + "15 14 |", + "|", + "15 x 14", + "15 1.5", + "15 -", + ], + ) + def test_a_sequence_that_states_no_dimension_is_refused(self, text: str) -> None: + with pytest.raises(ValueError): + parse_envelope(text) + + +class TestWhatAReaderIsShown: + @pytest.mark.parametrize( + ("items", "loop_point", "text"), + [ + ((15, 14, 12, 10), None, "15 14 12 10"), + ((15, 14, 12, 10), 2, "15 14 | 12 10"), + ((15, 14), 0, "| 15 14"), + ((15, 14), 1, "15 | 14"), + ((), None, ""), + ], + ) + def test_a_dimension_is_written_out_as_it_would_be_typed( + self, + items: Tuple[int, ...], + loop_point: Optional[int], + text: str, + ) -> None: + assert format_envelope(Envelope[int](items=items, loop_point=loop_point)) == text + + @pytest.mark.parametrize( + "text", + ["15 14 12 10", "15 14 | 12 10", "| 15 14", ""], + ) + def test_what_is_shown_reads_back_as_what_it_shows(self, text: str) -> None: + assert format_envelope(parse_envelope(text)) == text From 2664b146ca8c2002685d97591b0642a113332000 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 18:33:49 +0200 Subject: [PATCH 116/142] Retired: global loop point --- .../categories/elements/sequencer.py | 3 - .../coordinators/tabs/sequencer.py | 29 +----- .../layout/tabs/sequencer/tables/voice.py | 5 +- .../logic/history/action.py | 1 - .../logic/history/fingerprint.py | 1 - .../logic/project/controller.py | 15 +-- .../logic/sequencer/history_detail.py | 16 +--- .../logic/sequencer/voices.py | 31 +------ .../ui/panels/sequencer/voices/panel.py | 36 -------- .../view_model/sequencer/voices.py | 1 - .../view_model/shared/history.py | 23 +---- src/sampletones_config/lang/en.yaml | 4 - .../layout/tabs/sequencer/table_cells.yaml | 1 - src/sampletones_core/compatibility/fields.py | 1 + .../compatibility/project/v1_1.py | 20 +++- src/sampletones_core/exporters/feature.py | 23 ----- .../exporters/slices/instrument.py | 2 +- src/sampletones_core/performance/voice.py | 17 +--- src/sampletones_core/project/container.py | 2 - .../project/voices/__init__.py | 2 - src/sampletones_core/project/voices/loop.py | 3 - src/sampletones_core/project/voices/record.py | 7 +- src/sampletones_core/project/voices/sample.py | 17 +--- tests/integration/assets/reconstruction.py | 7 +- tests/suite/performance.py | 7 +- tests/suite/player.py | 3 +- .../coordinators/tabs/test_sequencer.py | 24 ++--- .../logic/history/test_action_labels.py | 19 ---- .../logic/project/test_controller.py | 12 --- .../logic/reconstruction/test_editor.py | 5 +- .../logic/reconstruction/test_instruments.py | 1 - .../logic/sequencer/playback/conftest.py | 22 +++-- .../sequencer/playback/test_synthesizer.py | 72 ++++++++------- .../sequencer/playback/test_tick_clock.py | 9 +- .../logic/sequencer/test_history_detail.py | 20 ---- .../logic/sequencer/test_voices.py | 19 +--- .../sequencer/test_tracker_context_menu.py | 3 - .../sequencer/test_tracker_typed_voice.py | 2 - .../ui/panels/sequencer/voices/test_keys.py | 6 +- .../ui/panels/sequencer/voices/test_menu.py | 6 +- .../panels/sequencer/voices/test_selection.py | 6 +- .../formats/bitphase/conftest.py | 1 - .../formats/bitphase/test_envelopes.py | 11 +-- .../formats/bitphase/test_preset.py | 3 +- .../formats/famitracker/conftest.py | 6 +- .../famitracker/sequences/test_features.py | 9 +- .../formats/famitracker/test_builder.py | 15 ++- .../formats/famitracker/test_footprint.py | 3 +- .../formats/famitracker/test_fti.py | 5 +- .../formats/famitracker/test_ftm.py | 13 ++- .../performance/test_instrument_walk.py | 3 +- .../performance/test_ticks.py | 92 ++++++++++--------- .../project/test_container.py | 3 +- .../project/voices/test_creation.py | 3 +- .../project/voices/test_instrument.py | 1 - .../project/voices/test_sample.py | 10 +- 56 files changed, 204 insertions(+), 477 deletions(-) delete mode 100644 src/sampletones_core/project/voices/loop.py diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 98ed3d0b4..1468de641 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -78,7 +78,6 @@ class SequencerVoicesElements(AbstractElement): COLUMN_KIND = "column_kind" COLUMN_ID = "column_id" COLUMN_NAME = "column_name" - COLUMN_LOOP = "column_loop" CONTEXT_EDIT = "context_edit" CONTEXT_RENAME = "context_rename" CONTEXT_DUPLICATE = "context_duplicate" @@ -102,5 +101,3 @@ class SequencerHistoryElements(AbstractElement): STATUS_UNDO = "status_undo" STATUS_REDO = "status_redo" EMPTY = "empty" - LOOP_ON = "loop_on" - LOOP_OFF = "loop_off" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index acccc606f..0d37d6693 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1,11 +1,8 @@ from pathlib import Path -from typing import Callable, Optional, ParamSpec, Sequence, Tuple, Union +from typing import Callable, Optional, ParamSpec, Sequence, Tuple import dearpygui.dearpygui as dpg -from sampletones_application.categories.elements.sequencer import ( - SequencerHistoryElements, -) from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType from sampletones_application.categories.instrument import InstrumentImportMessages from sampletones_application.categories.manager import LanguageManager @@ -128,8 +125,6 @@ ) from sampletones_application.view_model.shared.history import ( HistoryDetail, - HistoryDetailSegment, - HistoryDetailWordSegment, ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName, FeatureKey @@ -635,11 +630,6 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_voices_panel.sample_footprint = self._sequencer_voices_logic.build_voice_footprint self._sequencer_voices_panel.on_sample_selected = self._on_sample_selected self._sequencer_voices_panel.on_sample_edit_requested = self._sequencer_voices_logic.request_edit - self._sequencer_voices_panel.on_loop_changed = self._undoable( - HistoryAction.SET_SAMPLE_LOOP, - self._sequencer_voices_logic.set_sample_loop, - detail=self._history_detail.set_sample_loop, - ) self._sequencer_voices_panel.on_remove_requested = self._remove_voice self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice self._sequencer_voices_panel.on_move_requested = self._undoable( @@ -1087,7 +1077,7 @@ def _build_history_view_model(self) -> HistoryViewModel: HistoryEntryViewModel( index=index, label=self._history_action_label(entry.action), - detail_segments=tuple(self._resolve_detail_segment(segment) for segment in entry.detail), + detail_segments=entry.detail, is_current=index == cursor, is_future=index > cursor, ) @@ -1103,21 +1093,6 @@ def _history_action_label(self, action: HistoryAction) -> str: action, ] - def _resolve_detail_segment( - self, - segment: Union[HistoryDetailSegment, HistoryDetailWordSegment], - ) -> HistoryDetailSegment: - if isinstance(segment, HistoryDetailWordSegment): - text = self._language_manager[ - Page.SEQUENCER, - Panel.HISTORY, - TextType.LABEL, - SequencerHistoryElements(segment.word.value), - ] - return HistoryDetailSegment(text=text, role=segment.role) - - return segment - def initialize(self) -> None: """Pushes the current project into every sequencer panel. diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/voice.py b/src/sampletones_application/layout/tabs/sequencer/tables/voice.py index 014ba346f..cfefb44fb 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tables/voice.py +++ b/src/sampletones_application/layout/tabs/sequencer/tables/voice.py @@ -2,11 +2,10 @@ class VoiceColumnWidths(BaseModel, extra="forbid", frozen=True): - """Widths of the four sub-columns that make up a voice row: its kind mark, its id, - its name, and its loop marker. They only mean anything as a set, so they live together. + """Widths of the three sub-columns that make up a voice row: its kind mark, its id and + its name. They only mean anything as a set, so they live together. """ kind: int id: int name: int - loop: int diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index 4ad90b0e6..ca2dfa7cd 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -31,7 +31,6 @@ class HistoryAction(AbstractElement): RENAME_SAMPLE = "rename_sample" MOVE_SAMPLE = "move_sample" DUPLICATE_SAMPLE = "duplicate_sample" - SET_SAMPLE_LOOP = "set_sample_loop" ADD_INSTRUMENT = "add_instrument" EDIT_INSTRUMENT = "edit_instrument" SET_TEMPO = "set_tempo" diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index 7e4b24d6b..c196adab6 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -33,7 +33,6 @@ def fingerprint_project( parts.append(voice.name) match voice: case Sample(): - parts.append(str(voice.loop_point)) parts.append(reconstruction_hash(voice.reconstruction)) case Instrument(): parts.append(voice.model_dump_json()) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 91c180bb6..a59143434 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -275,10 +275,7 @@ def replace_sample_reconstruction(self, voice_id: str, reconstruction: Reconstru reconstruction, the project stays a self-contained, shareable artifact. """ reconstruction.detach_source() - voice = self.project.voices[voice_id] - if not isinstance(voice, Sample): - raise TypeError(f"Voice '{voice_id}' carries no reconstruction to substitute") - + voice = self._sample(voice_id) voice.reconstruction = reconstruction self._touch() self._announce(self.on_voices_changed) @@ -290,16 +287,6 @@ def rename_voice(self, voice_id: str, name: str) -> None: self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def set_voice_loop_point(self, voice_id: str, loop_point: Optional[int]) -> None: - """Sets the tick a recording's instructions repeat from, or ``None`` where it plays once. - - Raises: - TypeError: If ``voice_id`` names a voice that is no recording. - """ - self._sample(voice_id).loop_point = loop_point - self._touch() - self._announce(self.on_voices_changed) - def is_voice_used(self, voice_id: str) -> bool: return self.song.references_voice(voice_id) diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 4398ca6a7..5dbd0c634 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -14,8 +14,6 @@ HistoryDetail, HistoryDetailRole, HistoryDetailSegment, - HistoryDetailWord, - HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ( ChannelName, @@ -88,11 +86,8 @@ class SequencerHistoryDetail: an ordered tuple of :class:`HistoryDetailSegment`, tagging each token with a semantic role that the panel later paints. Positions, rows and pattern indices read as two-digit hex; channels use the ``P``/``p``/``T``/``N`` abbreviations, - concatenated when a sample-column gesture spans several channels. - Language-managed words — the loop on/off states — are emitted as - :class:`HistoryDetailWordSegment` keys and translated when the history view is - built, keeping committed entries language-independent. A gesture on the voice - pool names its voice in the color of the kind that voice is, so a recording + concatenated when a sample-column gesture spans several channels. A gesture on the + voice pool names its voice in the color of the kind that voice is, so a recording and a hand-written one read apart down the list of entries. """ @@ -310,13 +305,6 @@ def duplicate_voice(self, voice_id: str) -> Segments: self._voice_name(voice_id), ) - def set_sample_loop(self, voice_id: str, loop: bool) -> Segments: - word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF - return ( - self._voice(voice_id, colon=True), - HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE), - ) - def edit_reconstruction( self, voice_id: str, diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 748302885..cc2185455 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -36,9 +36,7 @@ new_instrument, ) from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample -from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import display_voice from sampletones_shared.exceptions import PlaybackError @@ -86,7 +84,6 @@ def build_voices(self) -> SequencerVoicesViewModel: voice_id=voice.id, name=voice.name, kind=voice_kind(voice), - loop=_loops(voice), ) for voice in self._controller.project.voices ) @@ -195,11 +192,9 @@ def build_voice_footprint( ) -> Optional[SampleFootprintViewModel]: """Measures one voice's instruments as the module export writes them. - A voice carries its own loop point, and a looping instrument is compiled to one shared - length, so it is measured the way it is placed. A sample yields a figure per channel its - reconstruction covers; an instrument yields one, since every channel reaches the same - envelopes. Measuring a single voice on demand keeps a pool edit clear of an export it was - not asked for. + A sample yields a figure per channel its reconstruction covers; an instrument yields one, + since every channel reaches the same envelopes. Measuring a single voice on demand keeps a + pool edit clear of an export it was not asked for. Args: voice_id: The voice to measure. @@ -251,14 +246,6 @@ def move_voice(self, voice_id: str, to_index: int) -> None: def duplicate_voice(self, voice_id: str) -> None: self._controller.duplicate_voice(voice_id) - def set_sample_loop(self, voice_id: str, loop: bool) -> None: - """Turns the list's loop tick into the point the voice repeats from. - - The list offers looping as a switch, and a voice that loops repeats the whole of its - instructions, which is the point at their start. - """ - self._controller.set_voice_loop_point(voice_id, WHOLE_LOOP_POINT if loop else None) - def request_edit(self, voice_id: str) -> None: self.cancel_autoplay() self.call(self.on_edit_sample_requested, voice_id) @@ -349,15 +336,3 @@ def _play_voice( f"Failed to preview sample: {voice_id}", ) self.call(self.on_autoplay_error, exception) - - -def _loops(voice: VoiceUnion) -> bool: - """Whether the voice list marks this voice as repeating. - - A recording states one point for the whole of it, while a hand-written voice repeats wherever - any of its dimensions circles. - """ - if isinstance(voice, Sample): - return voice.loops - - return any(envelope.loops for envelope in voice.envelopes.envelope_map.values()) diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index c60d4827a..0c8b38bc3 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -85,7 +85,6 @@ def __init__( self.instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None - self.on_loop_changed: Optional[Callable[[str, bool], None]] = None self.on_remove_requested: Optional[StringCallback] = None self.on_play_requested: Optional[StringCallback] = None self.on_move_requested: Optional[Callable[[str, int], None]] = None @@ -207,14 +206,6 @@ def _create_voices_table(self) -> None: width_stretch=True, init_width_or_weight=self._layout.table_cells.voice.name, ) - dpg.add_table_column( - label=self._label( - self._language_manager, - SequencerVoicesElements.COLUMN_LOOP, - ), - width_fixed=True, - init_width_or_weight=self._layout.table_cells.voice.loop, - ) ThemeRegistry.get(TAG_SEQUENCER_VOICES_THEME_ROW).bind_to_item(TAG_SEQUENCER_VOICES_TABLE) def update_view(self, view_model: SequencerVoicesViewModel) -> None: @@ -246,7 +237,6 @@ def _build_sample_row( self._build_kind_cell(row_id, entry) self._build_id_cell(row_id, position, entry) self._build_name_cell(row_id, position, entry) - self._build_loop_cell(row_id, entry) if entry.voice_id == self._selected_voice_id: self._selected_row = position self._highlight_selected_row(position) @@ -369,20 +359,6 @@ def _build_name_input( FontRegistry.bind_to_item(name_input, Font.MONO_SMALL) dpg.bind_item_handler_registry(name_input, self._rename_handler_tag) - def _build_loop_cell( - self, - row_id: int | str, - entry: VoiceEntryViewModel, - ) -> None: - loop_cell = dpg.add_table_cell(parent=row_id) - loop_checkbox = dpg.add_checkbox( - parent=loop_cell, - default_value=entry.loop, - user_data=entry.voice_id, - callback=self._on_loop_toggled, - ) - FontRegistry.bind_to_item(loop_checkbox, Font.REGULAR_SMALL) - def _on_sample_selected( self, sender: Sender, @@ -548,18 +524,6 @@ def _on_rename_enter(self, _sender: Sender, _app_data: str) -> None: def _on_rename_deactivated(self, _sender: Sender, _app_data: int) -> None: self._commit_rename() - def _on_loop_toggled( - self, - _sender: Sender, - app_data: bool, - user_data: str, - ) -> None: - self.call( - self.on_loop_changed, - user_data, - app_data, - ) - def _on_sample_double_clicked( self, _sender: Sender, diff --git a/src/sampletones_application/view_model/sequencer/voices.py b/src/sampletones_application/view_model/sequencer/voices.py index 4c34d3d83..4334363bf 100644 --- a/src/sampletones_application/view_model/sequencer/voices.py +++ b/src/sampletones_application/view_model/sequencer/voices.py @@ -21,7 +21,6 @@ class VoiceEntryViewModel(BaseModel, frozen=True): voice_id: str name: str kind: VoiceKind - loop: bool class VoiceSelection(BaseModel, frozen=True): diff --git a/src/sampletones_application/view_model/shared/history.py b/src/sampletones_application/view_model/shared/history.py index 762b49c61..482752aa2 100644 --- a/src/sampletones_application/view_model/shared/history.py +++ b/src/sampletones_application/view_model/shared/history.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Tuple, Union +from typing import Tuple from pydantic import BaseModel @@ -31,18 +31,6 @@ class HistoryDetailRole(StrEnum): SEPARATOR = "separator" -class HistoryDetailWord(StrEnum): - """A language-managed detail token, stored by key and translated at render time. - - Values mirror the ``SequencerHistoryElements`` members carrying the words, so - a coordinator resolves a token exactly the way it resolves an entry's action - label — committed entries stay language-independent. - """ - - LOOP_ON = "loop_on" - LOOP_OFF = "loop_off" - - class HistoryDetailSegment(BaseModel, frozen=True): """One colored token of a history entry's detail line.""" @@ -50,11 +38,4 @@ class HistoryDetailSegment(BaseModel, frozen=True): role: HistoryDetailRole -class HistoryDetailWordSegment(BaseModel, frozen=True): - """One colored token whose text is looked up from the language manager when rendered.""" - - word: HistoryDetailWord - role: HistoryDetailRole - - -HistoryDetail = Tuple[Union[HistoryDetailSegment, HistoryDetailWordSegment], ...] +HistoryDetail = Tuple[HistoryDetailSegment, ...] diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6fd95c744..8afea4647 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -603,7 +603,6 @@ sequencer.voices.label.import_instrument: "Import instrument..." sequencer.voices.label.column_kind: "Kind" sequencer.voices.label.column_id: "ID" sequencer.voices.label.column_name: "Name" -sequencer.voices.label.column_loop: "Loop" sequencer.voices.label.context_edit: "Edit" sequencer.voices.label.context_rename: "Rename" sequencer.voices.label.context_duplicate: "Duplicate" @@ -657,11 +656,8 @@ sequencer.history.label.replace_sample: "Replace sample" sequencer.history.label.rename_sample: "Rename sample" sequencer.history.label.move_sample: "Move sample" sequencer.history.label.duplicate_sample: "Duplicate sample" -sequencer.history.label.set_sample_loop: "Toggle sample loop" sequencer.history.label.add_instrument: "Add instrument" sequencer.history.label.edit_instrument: "Edit instrument" -sequencer.history.label.loop_on: "on" -sequencer.history.label.loop_off: "off" sequencer.history.label.set_tempo: "Set tempo" sequencer.history.label.set_speed: "Set speed" sequencer.history.label.set_nes_frequency: "Set NES frequency" diff --git a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml index dc16a0ff9..4a766c386 100644 --- a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml @@ -6,4 +6,3 @@ voice: kind: 44 id: 40 name: 1 - loop: 40 diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index 2555cb432..c5223a00e 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -37,5 +37,6 @@ VOICES: Final = "voices" KIND: Final = "kind" KIND_SAMPLE: Final = "sample" +LOOP_POINT: Final = "loop_point" SAMPLE_ID: Final = "sample_id" VOICE_ID: Final = "voice_id" diff --git a/src/sampletones_core/compatibility/project/v1_1.py b/src/sampletones_core/compatibility/project/v1_1.py index 4643bd4d7..a843060dc 100644 --- a/src/sampletones_core/compatibility/project/v1_1.py +++ b/src/sampletones_core/compatibility/project/v1_1.py @@ -6,6 +6,7 @@ GENERATOR, KIND, KIND_SAMPLE, + LOOP_POINT, NAME, PATTERNS, ROWS, @@ -25,17 +26,19 @@ def update(data: SerializedData) -> SerializedData: """Gathers a project's samples into its voices, and names each channel once. Project format 1.0 held the pool under ``samples``, stored a channel pool's channel under - ``generator``, and wrote a row's note command as a sample id beside the channel slice it named. - Project format 1.1 holds the pool under ``voices``, each record stating the ``kind`` of voice - it carries; a channel pool names its channel under ``name``; and a note command names the voice - alone, since the channel a voice sounds on is the one whose pattern holds the row. + ``generator``, wrote a row's note command as a sample id beside the channel slice it named, and + let a sample state a tick its frames repeated from. Project format 1.1 holds the pool under + ``voices``, each record stating the ``kind`` of voice it carries; a channel pool names its + channel under ``name``; a note command names the voice alone, since the channel a voice sounds + on is the one whose pattern holds the row; and a sample plays the frames its conversion found, + which leaves the repeat to the envelopes an instrument writes. """ updated = dict(data) samples = data.get(SAMPLES) if isinstance(samples, list): updated.pop(SAMPLES, None) - updated[VOICES] = [{KIND: KIND_SAMPLE, **sample} if isinstance(sample, dict) else sample for sample in samples] + updated[VOICES] = [_updated_sample(sample) if isinstance(sample, dict) else sample for sample in samples] song = data.get(SONG) if isinstance(song, dict): @@ -44,6 +47,13 @@ def update(data: SerializedData) -> SerializedData: return updated +def _updated_sample(sample: SerializedData) -> SerializedData: + """A 1.0 sample as a voice record, leaving behind the tick its frames repeated from.""" + record = {KIND: KIND_SAMPLE, **sample} + record.pop(LOOP_POINT, None) + return record + + def _updated_song(song: SerializedData) -> SerializedData: channels = song.get(CHANNELS) if not isinstance(channels, dict): diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 0399decdf..abe7e9e08 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -124,29 +124,6 @@ def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> Features: emptied = {feature_key.value: Envelope[int]() for feature_key in feature_keys if self.offers(feature_key)} return self.model_copy(update=emptied) - def repeating_from(self, loop_point: Optional[int]) -> Features: - """These features with every dimension they write circling from one item. - - A recording states one point for the whole of it, so an export of one gives every - dimension the same point; a point past what a dimension writes moves to its last item. - - Args: - loop_point: The item to repeat from, or ``None`` to leave every dimension halting. - - Returns: - Features: The features with those dimensions circling. - """ - if loop_point is None: - return self - - circling = self - for feature_key, envelope in self.envelopes.items(): - if envelope.written: - point = min(loop_point, len(envelope.items) - 1) - circling = circling.with_envelope(feature_key, envelope.model_copy(update={"loop_point": point})) - - return circling - @property def frame_count(self) -> int: """The frame count the envelopes describe, taken from the longest populated dimension.""" diff --git a/src/sampletones_core/exporters/slices/instrument.py b/src/sampletones_core/exporters/slices/instrument.py index ef15b1857..1a16eec5a 100644 --- a/src/sampletones_core/exporters/slices/instrument.py +++ b/src/sampletones_core/exporters/slices/instrument.py @@ -78,7 +78,7 @@ def sample_instrument_entries( index=index, voice_id=sample.id, name=instrument_slice_name(sample.name, channel), - features=features.repeating_from(sample.loop_point), + features=features, channel=channel, slots={ channel: InstrumentSlot( diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py index 2b488694f..129bb1feb 100644 --- a/src/sampletones_core/performance/voice.py +++ b/src/sampletones_core/performance/voice.py @@ -32,7 +32,6 @@ class VoiceReading: held_features: The dimensions the voice leaves to the channel. channel_name: The channel doing the reading. sustaining: The instrument whose envelopes go on past the written frames, where one does. - loop_point: The frame a recording circles back to, or ``None`` where it plays through once. """ exporter: ExporterTypeUnion @@ -41,7 +40,6 @@ class VoiceReading: held_features: Tuple[FeatureKey, ...] channel_name: ChannelName sustaining: Optional[Instrument] - loop_point: Optional[int] @classmethod def read( @@ -65,12 +63,10 @@ def read( voice describes no frame there and the channel rests. """ sustaining: Optional[Instrument] = None - loop_point: Optional[int] = None match voice: case Sample(): instructions: Sequence[InstructionUnion] = voice.reconstruction.instructions[channel_name] held_features = voice.reconstruction.held_features[channel_name] - loop_point = voice.loop_point case Instrument(): instructions = voice.instructions(channel_name) held_features = voice.held_features(channel_name) @@ -86,7 +82,6 @@ def read( held_features=held_features, channel_name=channel_name, sustaining=sustaining, - loop_point=loop_point, ) def at(self, tick_index: int) -> Optional[InstructionUnion]: @@ -94,9 +89,8 @@ def at(self, tick_index: int) -> Optional[InstructionUnion]: An instrument goes on past its written frames: each dimension circles from its own loop point or holds its last item, so a note sounds for as long as rows keep it sounding and a - volume envelope ending at silence is what releases it. A recording circles the frames its - conversion found from the point it states, and the channel rests past the last of them - where it states none. + volume envelope ending at silence is what releases it. A recording plays the frames its + conversion found, and the channel rests once they run out. Args: tick_index: How many ticks of the voice the channel has played. @@ -111,12 +105,7 @@ def at(self, tick_index: int) -> Optional[InstructionUnion]: if self.sustaining is not None: return self.sustaining.instruction_at(self.channel_name, tick_index) - if self.loop_point is None: - return None - - point = min(self.loop_point, len(self.instructions) - 1) - cycle = len(self.instructions) - point - return self.instructions[point + (tick_index - point) % cycle] + return None def sound( self, diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index c0a833be4..d5555e70b 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -147,7 +147,6 @@ def _voice_record(voice: VoiceUnion) -> VoiceRecord: id=voice.id, name=voice.name, reconstruction_id=voice.reconstruction.id, - loop_point=voice.loop_point, ) case Instrument(): return voice @@ -167,7 +166,6 @@ def _restore_voice( sample = Sample( name=record.name, reconstruction=reconstructions[record.reconstruction_id], - loop_point=record.loop_point, ) sample.id = record.id return sample diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py index 7fe43927b..4c18e4f8a 100644 --- a/src/sampletones_core/project/voices/__init__.py +++ b/src/sampletones_core/project/voices/__init__.py @@ -1,7 +1,6 @@ from .creation import new_instrument from .envelopes import InstrumentEnvelopes from .instrument import Instrument -from .loop import WHOLE_LOOP_POINT from .note_off import NoteOff from .note_on import NoteOn from .record import SampleRecord, VoiceRecord @@ -9,7 +8,6 @@ from .voice import VoiceUnion, samples, voice_channels, voice_reference __all__ = [ - "WHOLE_LOOP_POINT", "Instrument", "InstrumentEnvelopes", "NoteOff", diff --git a/src/sampletones_core/project/voices/loop.py b/src/sampletones_core/project/voices/loop.py deleted file mode 100644 index 4fdc83205..000000000 --- a/src/sampletones_core/project/voices/loop.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import Final - -WHOLE_LOOP_POINT: Final[int] = 0 diff --git a/src/sampletones_core/project/voices/record.py b/src/sampletones_core/project/voices/record.py index d6adb06cb..9929e7ea9 100644 --- a/src/sampletones_core/project/voices/record.py +++ b/src/sampletones_core/project/voices/record.py @@ -1,4 +1,4 @@ -from typing import Annotated, Literal, Optional, Union +from typing import Annotated, Literal, Union from pydantic import BaseModel, Field @@ -16,11 +16,6 @@ class SampleRecord(BaseModel): ..., description="Id of the reconstruction stored in the archive.", ) - loop_point: Optional[int] = Field( - default=None, - ge=0, - description="Tick the sample's instructions repeat from, or None where it plays once.", - ) VoiceRecord = Annotated[Union[SampleRecord, Instrument], Field(discriminator="kind")] diff --git a/src/sampletones_core/project/voices/sample.py b/src/sampletones_core/project/voices/sample.py index b7749b701..d9062ff4c 100644 --- a/src/sampletones_core/project/voices/sample.py +++ b/src/sampletones_core/project/voices/sample.py @@ -1,4 +1,4 @@ -from typing import Optional, Self +from typing import Self from uuid import uuid4 from sampletones_core.reconstructions import Reconstruction @@ -8,37 +8,28 @@ class Sample: """A reconstruction placed in a project as a playable voice. The reconstruction carries one instruction stream per channel; the sample adds what a song - needs of it — a name, a stable id the tracker rows reference, and the tick its instructions - repeat from while a note is held. + needs of it — a name and a stable id the tracker rows reference. Its frames are the run its + conversion found, so a note sounds them through and the channel then rests. """ def __init__( self, name: str, reconstruction: Reconstruction, - *, - loop_point: Optional[int] = None, ) -> None: self.id: str = uuid4().hex self.name: str = name self.reconstruction: Reconstruction = reconstruction - self.loop_point: Optional[int] = loop_point - - @property - def loops(self) -> bool: - """Whether the sample repeats its instructions rather than playing them once.""" - return self.loop_point is not None def clone(self) -> Self: """Return an independent copy with a fresh id. The reconstruction is deep-copied so the copy can be edited independently of - the original; the name and loop point are carried over. + the original; the name is carried over. """ return type(self)( name=self.name, reconstruction=self.reconstruction.model_copy(deep=True), - loop_point=self.loop_point, ) def __hash__(self) -> int: diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 7a4b0d328..0ec1ed42d 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -17,7 +17,6 @@ InstructionLibraryData, InstructionLibraryFragment, ) -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -155,11 +154,7 @@ def make_sample( if played != expected_slices: raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") - return Sample( - name=name, - reconstruction=reconstruction, - loop_point=WHOLE_LOOP_POINT if loop else None, - ) + return Sample(name=name, reconstruction=reconstruction) def load_instrument_catalog( diff --git a/tests/suite/performance.py b/tests/suite/performance.py index 36b100a6e..78bf607cb 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -15,7 +15,6 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.voice import VoiceUnion @@ -121,11 +120,7 @@ def project_with_sample( reaching back into the collection for an id it already knows. """ project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings) - sample = Sample( - name=name, - reconstruction=reconstruction, - loop_point=WHOLE_LOOP_POINT if loop else None, - ) + sample = Sample(name=name, reconstruction=reconstruction) project.voices.append(sample) return project, sample diff --git a/tests/suite/player.py b/tests/suite/player.py index 424081503..3605d2ab9 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -11,7 +11,6 @@ from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import InstructionUnion, PulseInstruction -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction from sampletones_core.timers.utils import get_timer_table from sampletones_player.clock.schedule import PlaySchedule @@ -269,7 +268,7 @@ def looping_features(features: Features) -> Features: looping = features for feature_key, envelope in features.envelopes.items(): if envelope.written: - looping = looping.with_envelope(feature_key, envelope.model_copy(update={"loop_point": WHOLE_LOOP_POINT})) + looping = looping.with_envelope(feature_key, envelope.model_copy(update={"loop_point": 0})) return looping diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 35bd4cfa3..12cc3702e 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -65,8 +65,6 @@ from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, - HistoryDetailWord, - HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ChannelName from sampletones_core.features.envelope import Envelope @@ -1545,37 +1543,31 @@ def view_coordinator() -> SequencerTabCoordinator: return instance -def _loop_entry(loop: bool) -> HistoryEntry: - word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF +def _detail_entry(value: str) -> HistoryEntry: return HistoryEntry( project=MagicMock(), - action=HistoryAction.SET_SAMPLE_LOOP, + action=HistoryAction.MOVE_SAMPLE, created=datetime.now(tz=UTC), detail=( HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE), - HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE), + HistoryDetailSegment(text=value, role=HistoryDetailRole.VALUE), ), ) class TestHistoryViewModelBuild: - def test_word_segments_resolve_to_language_text( + def test_an_entry_reaches_the_view_with_the_detail_it_was_committed_with( self, view_coordinator: SequencerTabCoordinator, ) -> None: + """A detail is built in the words it is read in, so the view shows what was stored.""" view_coordinator._history.cursor = 1 - view_coordinator._history.entries = (_loop_entry(True), _loop_entry(False)) + entries = (_detail_entry("01"), _detail_entry("02")) + view_coordinator._history.entries = entries view_model = view_coordinator._build_history_view_model() - assert view_model.entries[0].detail_segments == ( - HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE), - HistoryDetailSegment(text="on", role=HistoryDetailRole.VALUE), - ) - assert view_model.entries[1].detail_segments == ( - HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE), - HistoryDetailSegment(text="off", role=HistoryDetailRole.VALUE), - ) + assert [entry.detail_segments for entry in view_model.entries] == [entry.detail for entry in entries] @pytest.fixture diff --git a/tests/unit/sampletones_application/logic/history/test_action_labels.py b/tests/unit/sampletones_application/logic/history/test_action_labels.py index ec94cc159..3ab630a7e 100644 --- a/tests/unit/sampletones_application/logic/history/test_action_labels.py +++ b/tests/unit/sampletones_application/logic/history/test_action_labels.py @@ -1,12 +1,10 @@ import pytest from sampletones_application.categories.abstract import AbstractElement -from sampletones_application.categories.elements.sequencer import SequencerHistoryElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.logic.history.action import HistoryAction from sampletones_application.paths import LANG_EN -from sampletones_application.view_model.shared.history import HistoryDetailWord @pytest.fixture @@ -32,20 +30,3 @@ def test_every_action_resolves_a_label( ] assert label - - -class TestDetailWordLabels: - @pytest.mark.parametrize("word", list(HistoryDetailWord), ids=lambda word: word.value) - def test_every_word_resolves_a_label( - self, - word: HistoryDetailWord, - language_manager: LanguageManager, - ) -> None: - label = language_manager[ - Page.SEQUENCER, - Panel.HISTORY, - TextType.LABEL, - SequencerHistoryElements(word.value), - ] - - assert label diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index cf0d159e0..4c45244ec 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -13,7 +13,6 @@ from sampletones_core.project import ProjectContainer from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction @@ -493,17 +492,6 @@ def test_set_sample_rate_updates_settings(self) -> None: assert fired == ["settings"] -class TestSampleLoop: - def test_set_sample_loop_toggles_loop_flag( - self, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - controller = _controller() - sample = controller.add_sample(reconstruction_factory(), name="lead") - controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) - assert controller.project.voice(sample.id).loop_point == WHOLE_LOOP_POINT - - class TestInstruments: def test_add_instrument_appends_the_voice_it_is_given(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index fdd4672f2..96476e3c5 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -12,7 +12,6 @@ from sampletones_core.exporters import Features from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import new_instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT ROOT_PITCH: Final[int] = 55 ROOT_PERIOD: Final[int] = 3 @@ -152,10 +151,10 @@ def test_a_point_written_on_an_envelope_reaches_the_instrument( instrument = controller.add_instrument(new_instrument("lead")) editor.edit_instrument(instrument.id) - editor.write_envelope(FeatureKey.VOLUME, Envelope(items=(15, 8), loop_point=WHOLE_LOOP_POINT)) + editor.write_envelope(FeatureKey.VOLUME, Envelope(items=(15, 8), loop_point=0)) assert instrument.envelopes.volume.items == (15, 8) - assert instrument.envelopes.volume.loop_point == WHOLE_LOOP_POINT + assert instrument.envelopes.volume.loop_point == 0 def test_a_write_with_no_instrument_in_front_is_refused(self, editor: InstrumentEditor) -> None: with pytest.raises(TypeError): diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index b955f4985..7a00bd58a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -24,7 +24,6 @@ total_footprint, ) from sampletones_core.project.voices.creation import new_instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.reconstructions import Reconstruction diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 310a2fc39..04fbfd77e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, FrozenSet, Iterable +from typing import Callable, Final, FrozenSet, Iterable import numpy as np import pytest @@ -11,12 +11,15 @@ from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE from sampletones_core.constants.enums import ChannelName -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction +SOUNDING_FRAMES: Final[int] = 16 + def make_controller() -> ProjectController: return ProjectController(ProjectManager()) @@ -47,13 +50,18 @@ def add_sample( controller: ProjectController, reconstruction: Reconstruction, *, - loop: bool = False, name: str = "test", ) -> Sample: - sample = controller.add_sample(reconstruction, name) - if loop: - controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) - return sample + return controller.add_sample(reconstruction, name) + + +def add_instrument( + controller: ProjectController, + *, + name: str = "test", +) -> Instrument: + """A hand-written voice sustaining at full volume, sounding for as long as a row holds it.""" + return controller.add_instrument(new_instrument(name)) def place_row( diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index e0de7d133..bd980f975 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -28,6 +28,8 @@ ) from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( + SOUNDING_FRAMES, + add_instrument, add_sample, make_controller, make_synthesizer, @@ -370,9 +372,9 @@ def test_mask_change_between_rows_takes_effect_without_restart(self) -> None: pitch and volume the pattern has reached rather than retriggering. """ - def place_looping_pulse_sample(context: SynthesizerContext) -> None: - recon = make_pulse_reconstruction(count=4) - sample = add_sample(_controller(context), recon, loop=True) + def place_sounding_pulse_sample(context: SynthesizerContext) -> None: + recon = make_pulse_reconstruction(count=SOUNDING_FRAMES) + sample = add_sample(_controller(context), recon) place_row( _controller(context), channel=ChannelName.PULSE1, @@ -394,8 +396,8 @@ def unmute_pulse1_and_render_row_1(context: SynthesizerContext) -> None: build=_make_context, steps=[ ScenarioStep( - label="place looping pulse sample on row 0", - action=place_looping_pulse_sample, + label="place sounding pulse sample on row 0", + action=place_sounding_pulse_sample, ), ScenarioStep( label="mute PULSE1, render row 0 — silence", @@ -492,10 +494,10 @@ def render_and_check_returned_position(context: SynthesizerContext) -> None: class TestNoteOff: - def test_note_off_cuts_a_sounding_looped_voice(self) -> None: - def place_looped_sample_then_note_off(context: SynthesizerContext) -> None: - recon = make_pulse_reconstruction(count=2) - sample = add_sample(_controller(context), recon, loop=True) + def test_note_off_cuts_a_sounding_voice(self) -> None: + def place_sounding_sample_then_note_off(context: SynthesizerContext) -> None: + recon = make_pulse_reconstruction(count=SOUNDING_FRAMES) + sample = add_sample(_controller(context), recon) place_row( _controller(context), channel=ChannelName.PULSE1, @@ -513,12 +515,12 @@ def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None: assert _performance(context).voice_id is None BaseTestScenario( - label="note-off silences a looped voice and clears channel state", + label="note-off silences a sounding voice and clears channel state", build=_make_context, steps=[ ScenarioStep( - label="place looped sample on row 0, note-off on row 1", - action=place_looped_sample_then_note_off, + label="place sounding sample on row 0, note-off on row 1", + action=place_sounding_sample_then_note_off, ), ScenarioStep( label="render row 0 — audible", @@ -532,16 +534,17 @@ def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None: ).run() -class TestLoopBehavior: - def test_loop_true_keeps_playing_after_instruction_list_exhausted(self) -> None: - def place_two_instruction_loop_sample(context: SynthesizerContext) -> None: - recon = make_pulse_reconstruction(count=2) - sample = add_sample(_controller(context), recon, loop=True) +class TestWhenAVoiceRunsOut: + """A recording sounds the frames its conversion found; an instrument goes on past them.""" + + def test_an_instrument_keeps_sounding_past_the_frames_it_writes(self) -> None: + def place_instrument(context: SynthesizerContext) -> None: + instrument = add_instrument(_controller(context)) place_row( _controller(context), channel=ChannelName.PULSE1, row_index=0, - voice_id=sample.id, + voice_id=instrument.id, ) def render_row_0_and_assert_non_silence(context: SynthesizerContext) -> None: @@ -556,12 +559,12 @@ def render_rows_1_to_3_and_assert_tick_advanced( assert _performance(context).tick_index > 2 BaseTestScenario( - label="loop=True wraps instruction index", + label="an instrument sounds on past its envelopes", build=_make_context, steps=[ ScenarioStep( - label="place 2-instruction looping sample on row 0", - action=place_two_instruction_loop_sample, + label="place a sustaining instrument on row 0", + action=place_instrument, ), ScenarioStep( label="render row 0 — has audio", @@ -574,13 +577,13 @@ def render_rows_1_to_3_and_assert_tick_advanced( ], ).run() - def test_loop_false_produces_silence_after_instructions_end(self) -> None: + def test_a_sample_falls_silent_once_its_frames_run_out(self) -> None: settings = make_controller().project.settings frame_length = settings.sample_rate // settings.nes_frequency - def place_one_instruction_non_loop_sample(context: SynthesizerContext) -> None: + def place_one_frame_sample(context: SynthesizerContext) -> None: recon = make_pulse_reconstruction(count=1) - sample = add_sample(_controller(context), recon, loop=False) + sample = add_sample(_controller(context), recon) place_row( _controller(context), channel=ChannelName.PULSE1, @@ -598,12 +601,12 @@ def render_row_0_and_assert_first_tick_audible_rest_silent( assert np.all(remaining == 0.0), "ticks after instruction exhaustion should be silent" BaseTestScenario( - label="loop=False silences after instruction list exhausted", + label="a sample rests once its frames run out", build=_make_context, steps=[ ScenarioStep( - label="place 1-instruction non-looping sample", - action=place_one_instruction_non_loop_sample, + label="place a one-frame sample", + action=place_one_frame_sample, ), ScenarioStep( label="render row 0 — first tick audible, rest silent", @@ -612,16 +615,15 @@ def render_row_0_and_assert_first_tick_audible_rest_silent( ], ).run() - def test_looped_voice_sustains_across_an_empty_next_frame(self) -> None: - def place_loop_then_append_empty_frame(context: SynthesizerContext) -> None: + def test_a_sustaining_voice_carries_into_an_empty_next_frame(self) -> None: + def place_instrument_then_append_empty_frame(context: SynthesizerContext) -> None: controller = _controller(context) - recon = make_pulse_reconstruction(count=2) - sample = add_sample(controller, recon, loop=True) + instrument = add_instrument(controller) place_row( controller, channel=ChannelName.PULSE1, row_index=0, - voice_id=sample.id, + voice_id=instrument.id, ) controller.append_frame() @@ -638,12 +640,12 @@ def render_into_empty_second_frame_and_assert_sustained( assert _performance(context).voice_id is not None BaseTestScenario( - label="looped voice carries across an empty (None-slot) next frame", + label="a sustaining voice carries across an empty (None-slot) next frame", build=_make_context, steps=[ ScenarioStep( - label="loop on frame 0, append all-None frame 1", - action=place_loop_then_append_empty_frame, + label="instrument on frame 0, append all-None frame 1", + action=place_instrument_then_append_empty_frame, ), ScenarioStep( label="render into frame 1 — voice still sounding", diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index 10e0ad550..6308e4726 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -12,6 +12,7 @@ from tests.suite.base import BaseTestSuite from tests.suite.performance import make_pulse_reconstruction from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( + SOUNDING_FRAMES, add_sample, all_channels, make_controller, @@ -222,8 +223,8 @@ class TestChannelsFillTheRow(BaseTestSuite): def test_a_sounding_channel_fills_every_tick(self) -> None: controller = make_controller() - reconstruction = make_pulse_reconstruction(count=1) - sample = add_sample(controller, reconstruction, loop=True) + reconstruction = make_pulse_reconstruction(count=SOUNDING_FRAMES) + sample = add_sample(controller, reconstruction) place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) @@ -235,8 +236,8 @@ def test_a_sounding_channel_fills_every_tick(self) -> None: def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> None: """A tick of a different length resumes the oscillator where the last one ended.""" controller = make_controller() - reconstruction = make_pulse_reconstruction(count=1) - sample = add_sample(controller, reconstruction, loop=True) + reconstruction = make_pulse_reconstruction(count=SOUNDING_FRAMES) + sample = add_sample(controller, reconstruction) place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index b459a6ef5..8dff3afc5 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -22,8 +22,6 @@ from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, - HistoryDetailWord, - HistoryDetailWordSegment, ) from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.project.voices.creation import new_instrument @@ -357,24 +355,6 @@ def test_move_sample_shows_source_position_and_destination(self) -> None: ("05", HistoryDetailRole.VALUE), ] - def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None: - controller = _controller() - sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") - formatter = _formatter(controller) - - on_segments = formatter.set_sample_loop(sample.id, True) - off_segments = formatter.set_sample_loop(sample.id, False) - - assert on_segments[0] == HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE) - assert on_segments[1] == HistoryDetailWordSegment( - word=HistoryDetailWord.LOOP_ON, - role=HistoryDetailRole.VALUE, - ) - assert off_segments[1] == HistoryDetailWordSegment( - word=HistoryDetailWord.LOOP_OFF, - role=HistoryDetailRole.VALUE, - ) - def test_value_wraps_a_number(self) -> None: formatter = _formatter(_controller()) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 69af8bf66..6815d03d9 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -27,7 +27,6 @@ from sampletones_core.formats.famitracker.specification.sequences import SequenceKind from sampletones_core.formats.famitracker.voice import InstrumentOmission from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import LoadInstrumentError @@ -220,33 +219,17 @@ def test_it_names_each_playing_channel(self) -> None: assert footprint is not None assert [instrument.channel for instrument in footprint.instruments] == list(channels) - def test_it_measures_the_sample_under_its_own_loop_flag( + def test_it_measures_the_sample_as_its_own_export_writes_it( self, reconstruction_factory: Callable[[], Reconstruction], ) -> None: controller, logic = _logic() sample = controller.add_sample(reconstruction_factory(), name="lead") - controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) footprint = logic.build_voice_footprint(sample.id) assert footprint == SampleFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) - def test_a_looping_sample_costs_what_a_one_shot_costs( - self, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - """Each dimension keeps the length it was written at, so circling costs a sample nothing.""" - controller, logic = _logic() - sample = controller.add_sample(reconstruction_factory(), name="lead") - one_shot = logic.build_voice_footprint(sample.id) - - controller.set_voice_loop_point(sample.id, WHOLE_LOOP_POINT) - looping = logic.build_voice_footprint(sample.id) - - assert one_shot is not None and looping is not None - assert looping.total_bytes == one_shot.total_bytes - def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: """A channel's figure is the cost of its own instrument, and the channels differ. diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 961410dca..42d4a5dc8 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -153,7 +153,6 @@ def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) - voice_id="lead-id", name="lead", kind=VoiceKind.SAMPLE, - loop=False, ), ), ) @@ -181,13 +180,11 @@ def _panel_with_both_kinds() -> tracker_module.GUISequencerTrackerPanel: voice_id="lead-id", name="lead", kind=VoiceKind.SAMPLE, - loop=False, ), VoiceEntryViewModel( voice_id="pad-id", name="pad", kind=VoiceKind.INSTRUMENT, - loop=False, ), ), ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py index d21dadd59..d5ae9ab6e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -38,13 +38,11 @@ def __init__(self) -> None: voice_id="lead-id", name="lead", kind=VoiceKind.SAMPLE, - loop=False, ), VoiceEntryViewModel( voice_id="pad-id", name="pad", kind=VoiceKind.INSTRUMENT, - loop=False, ), ), ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py index e0aaae67e..ba3af24cb 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py @@ -10,9 +10,9 @@ from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( - VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE, loop=False), - VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE, loop=True), - VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE, loop=False), + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE), ) SELECTED_ID = "bass-id" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index 4d29db63e..09bb8f300 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -24,9 +24,9 @@ from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( - VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE, loop=False), - VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE, loop=True), - VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE, loop=False), + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE), ) SELECTED_ID = "bass-id" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py index 00f9b45c1..803e414d3 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py @@ -4,8 +4,8 @@ from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( - VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE, loop=False), - VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE, loop=True), + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE), ) @@ -48,7 +48,7 @@ def test_absent_once_the_selected_sample_leaves_the_pool(self) -> None: def test_follows_a_renamed_sample(self) -> None: panel = _panel("kick-id", 0) - panel._entries = (VoiceEntryViewModel(voice_id="kick-id", name="Thump", kind=VoiceKind.SAMPLE, loop=False),) + panel._entries = (VoiceEntryViewModel(voice_id="kick-id", name="Thump", kind=VoiceKind.SAMPLE),) selection = panel.selection diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py index 3537af09a..8b8b36ba2 100644 --- a/tests/unit/sampletones_core/formats/bitphase/conftest.py +++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py @@ -6,7 +6,6 @@ from sampletones_core.exporters.feature import Features from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.features.envelope import Envelope -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_shared.music import Tuning NES_FREQUENCY: Final[int] = 60 diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index dda63c61c..3662685c4 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -18,7 +18,6 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from .conftest import build_features, looping @@ -93,7 +92,7 @@ class TestTheDimensionsStayInStep: @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), WHOLE_LOOP_POINT if loop else None), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), 0 if loop else None), ChannelName.PULSE1, ) assert len(envelopes.rows) == len(envelopes.table_rows) @@ -101,7 +100,7 @@ def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: def test_a_looping_slice_stands_at_the_longest_dimension(self) -> None: """Each dimension keeps its own length, so circling costs a slice none of its rows.""" envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), WHOLE_LOOP_POINT), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), 0), ChannelName.PULSE1, ) assert len(envelopes.rows) == len(VOLUME_ENVELOPE) @@ -124,7 +123,7 @@ def test_a_slice_without_a_contour_holds_its_note(self) -> None: class TestTheLoopPoint: def test_a_looping_slice_returns_to_its_first_row(self) -> None: envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE), WHOLE_LOOP_POINT), + looping(build_features(VOLUME_ENVELOPE), 0), ChannelName.PULSE1, ) assert envelopes.loop == LOOP_FROM_START @@ -149,7 +148,7 @@ def test_a_one_shot_rests_in_silence(self) -> None: @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: envelopes = features_to_envelopes( - looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), WHOLE_LOOP_POINT if loop else None), + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), 0 if loop else None), ChannelName.PULSE1, ) assert envelopes.loop < len(envelopes.rows) @@ -192,7 +191,7 @@ def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: envelopes = features_to_envelopes( - looping(build_features([], arpeggio=PITCH_CONTOUR), WHOLE_LOOP_POINT), + looping(build_features([], arpeggio=PITCH_CONTOUR), 0), ChannelName.PULSE1, ) assert len(envelopes.rows) == len(PITCH_CONTOUR) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index a9d5e683e..d99ed9729 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -19,7 +19,6 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument @@ -56,7 +55,7 @@ def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset def test_a_looping_slice_returns_to_its_first_row(self) -> None: preset = instrument_to_preset( - build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop_point=WHOLE_LOOP_POINT), + build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop_point=0), ) assert preset.loop == LOOP_FROM_START diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index 3d422d475..9c1ad563c 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -17,7 +17,6 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.song import Song -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample @@ -43,7 +42,7 @@ def build_reconstruction( ) -def pulse_sample(name: str, pitch: int, *, loop: bool = False) -> Sample: +def pulse_sample(name: str, pitch: int) -> Sample: instructions = [ PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0), PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0), @@ -51,7 +50,6 @@ def pulse_sample(name: str, pitch: int, *, loop: bool = False) -> Sample: return Sample( name=name, reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}), - loop_point=WHOLE_LOOP_POINT if loop else None, ) @@ -83,7 +81,7 @@ class ProjectFixture: @pytest.fixture def project_fixture() -> ProjectFixture: lead = pulse_sample("lead", pitch=60) - pad = pulse_sample("pad", pitch=48, loop=True) + pad = pulse_sample("pad", pitch=48) drum = noise_sample("drum", period=4) bell = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 876c5a822..1d18df018 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -11,7 +11,6 @@ NO_LOOP_POINT, SequenceKind, ) -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT REFERENCE_PITCH: Final[int] = 60 @@ -63,7 +62,7 @@ def test_items_are_python_ints(self) -> None: class TestTheLoopPointEachSequenceCarries: def test_a_dimension_carries_the_point_it_states(self) -> None: - sequences = features_to_instrument_sequences(build([15, 10], [0, 2], loop_point=WHOLE_LOOP_POINT)) + sequences = features_to_instrument_sequences(build([15, 10], [0, 2], loop_point=0)) assert sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START @@ -85,7 +84,7 @@ def test_each_dimension_repeats_from_its_own_item(self) -> None: assert sequences[SequenceKind.DUTY].loop_point == NO_LOOP_POINT def test_a_dimension_the_instrument_leaves_out_states_no_point(self) -> None: - sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=WHOLE_LOOP_POINT)) + sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=0)) assert sequences[SequenceKind.PITCH].loop_point == NO_LOOP_POINT def test_a_dimension_that_halts_states_no_point(self) -> None: @@ -104,7 +103,7 @@ def test_every_dimension_stands_at_the_length_it_was_written(self) -> None: def test_circling_costs_a_dimension_none_of_its_items(self) -> None: """Each dimension repeats on its own period, so a loop leaves every length as written.""" sequences = features_to_instrument_sequences( - build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1, 1, 2], loop_point=WHOLE_LOOP_POINT) + build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1, 1, 2], loop_point=0) ) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) @@ -125,7 +124,7 @@ def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: assert zeroed[SequenceKind.ARPEGGIO].items == (0,) def test_all_dimensions_empty_stays_empty(self) -> None: - sequences = features_to_instrument_sequences(build([], [], loop_point=WHOLE_LOOP_POINT)) + sequences = features_to_instrument_sequences(build([], [], loop_point=0)) assert all(not sequence.enabled for sequence in sequences.values()) def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> None: diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index 8b99912f6..ee5cf4e86 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -23,7 +23,6 @@ NoteValue, ) from sampletones_core.formats.famitracker.specification.sequences import ( - LOOP_FROM_START, NO_LOOP_POINT, SequenceKind, ) @@ -78,16 +77,14 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj assert slot.initial_pitch == LEAD_PITCH assert list(instruments[slot.index].sequences[SequenceKind.ARPEGGIO].items)[0] == OCTAVE - def test_looping_sample_loops_populated_sequences(self, project_fixture: ProjectFixture) -> None: + def test_a_recordings_sequences_state_no_loop_point(self, project_fixture: ProjectFixture) -> None: + """A recording is the fixed run of frames its conversion found, so nothing in it circles.""" instruments, slots = build_instrument_table(project_fixture.project) - pad_index = slots[(project_fixture.pad.id, ChannelName.PULSE1)].index - pad = instruments[pad_index] - assert pad.sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START + indices = [slots[(voice.id, ChannelName.PULSE1)].index for voice in (project_fixture.lead, project_fixture.pad)] - def test_non_looping_sample_leaves_loop_disabled(self, project_fixture: ProjectFixture) -> None: - instruments, slots = build_instrument_table(project_fixture.project) - lead_index = slots[(project_fixture.lead.id, ChannelName.PULSE1)].index - assert instruments[lead_index].sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT + points = [instruments[index].sequences[SequenceKind.VOLUME].loop_point for index in indices] + + assert points == [NO_LOOP_POINT, NO_LOOP_POINT] def test_exceeding_max_instruments_raises(self) -> None: project = Project.create() diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 5ccf3b28c..1ee9d2ff0 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -27,7 +27,6 @@ MAX_SEQUENCE_ITEMS, SequenceKind, ) -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -72,7 +71,7 @@ class TestCase(BaseRegularTestCase): label="pulse_one_shot", ), TestCase( - features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2], loop_point=WHOLE_LOOP_POINT), + features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2], loop_point=0), expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_loop", ), diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index 1c2c88b8f..b916be82d 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -35,7 +35,6 @@ SEQUENCE_ENABLED, SequenceKind, ) -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_shared.exceptions import ( IncompatibleInstrumentVersionError, InvalidInstrumentValuesError, @@ -182,9 +181,9 @@ def test_missing_sequences_come_back_disabled(self) -> None: assert not instrument.sequences[SequenceKind.DUTY].enabled def test_the_loop_point_round_trips(self) -> None: - instrument = build_instrument("Pad", volume=np.array([15, 10, 5]), loop_point=WHOLE_LOOP_POINT) + instrument = build_instrument("Pad", volume=np.array([15, 10, 5]), loop_point=0) read = fti_bytes_to_instrument(instrument_to_fti_bytes(instrument)) - assert read.sequences[SequenceKind.VOLUME].loop_point == WHOLE_LOOP_POINT + assert read.sequences[SequenceKind.VOLUME].loop_point == 0 def test_a_written_file_reads_back(self, tmp_path: Path) -> None: path = tmp_path / "instrument.fti" diff --git a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py index 815bd7509..4192d21ce 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py @@ -30,7 +30,7 @@ EMPTY_VOLUME, NoteValue, ) -from sampletones_core.formats.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.specification.sequences import NO_LOOP_POINT, SequenceKind from tests.suite.famitracker import ParsedModule, ParsedSequence, parse_ftm from .conftest import ProjectFixture @@ -136,11 +136,18 @@ def test_volume_reference_resolves_to_populated_sequence(self, project_fixture: class TestSequencesBlock: - def test_looping_instrument_sequence_loops_from_start(self, project_fixture: ProjectFixture) -> None: + def test_a_recordings_sequence_is_pooled_with_its_items_and_no_point( + self, + project_fixture: ProjectFixture, + ) -> None: parsed = _parsed(project_fixture) pad = next(instrument for instrument in parsed.instruments if instrument.name.startswith("pad")) _, index = pad.sequence_refs[int(SequenceKind.VOLUME)] - assert _pooled(parsed, SequenceKind.VOLUME, index).loop_point == 0 + + sequence = _pooled(parsed, SequenceKind.VOLUME, index) + + assert sequence.items + assert sequence.loop_point == NO_LOOP_POINT class TestFramesBlock: diff --git a/tests/unit/sampletones_core/performance/test_instrument_walk.py b/tests/unit/sampletones_core/performance/test_instrument_walk.py index 2037c3d0a..d1f5c0e92 100644 --- a/tests/unit/sampletones_core/performance/test_instrument_walk.py +++ b/tests/unit/sampletones_core/performance/test_instrument_walk.py @@ -10,7 +10,6 @@ from sampletones_core.performance import song_instructions from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.performance import place_instrument, project_with_instrument @@ -21,7 +20,7 @@ def _instrument(loop: bool = False) -> Instrument: - point = WHOLE_LOOP_POINT if loop else None + point = 0 if loop else None return Instrument( name="lead", envelopes=InstrumentEnvelopes( diff --git a/tests/unit/sampletones_core/performance/test_ticks.py b/tests/unit/sampletones_core/performance/test_ticks.py index d3c42eec3..6dd9756b5 100644 --- a/tests/unit/sampletones_core/performance/test_ticks.py +++ b/tests/unit/sampletones_core/performance/test_ticks.py @@ -5,31 +5,49 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.performance import ChannelPerformance, VoiceReading, sound_tick -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.performance import make_pulse_reconstruction ENVELOPE_TICKS: int = 3 SOUNDING_PITCH: int = 60 -TAIL_LOOP_POINT: int = ENVELOPE_TICKS - 1 -def _reading(loop_point: Optional[int]) -> VoiceReading: - """A pulse voice over a three-tick envelope, read the way a channel reads it.""" - voice = Sample( - name="lead", - reconstruction=make_pulse_reconstruction(pitch=SOUNDING_PITCH, count=ENVELOPE_TICKS), - loop_point=loop_point, - ) +def _reading(*, sustaining: bool = False) -> VoiceReading: + """A pulse voice over a three-tick envelope, read the way a channel reads it. + + A recording sounds the frames its conversion found; an instrument writing the same three + ticks holds its final values past them, so the two kinds part company where the written + run ends. + """ + voice: VoiceUnion = _instrument() if sustaining else _sample() reading = VoiceReading.read(voice, ChannelName.PULSE1) assert reading is not None return reading +def _sample() -> Sample: + return Sample( + name="lead", + reconstruction=make_pulse_reconstruction(pitch=SOUNDING_PITCH, count=ENVELOPE_TICKS), + ) + + +def _instrument() -> Instrument: + return Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(MAX_VOLUME,) * ENVELOPE_TICKS)), + initial_pitch=SOUNDING_PITCH, + ) + + class TestSoundTick(BaseTestSuite): """Which of a voice's instructions a channel reaches, and where it runs out.""" @@ -37,45 +55,39 @@ class TestSoundTick(BaseTestSuite): class TestCase(BaseRegularTestCase): expected: bool tick_index: int - loop_point: Optional[int] + sustaining: bool test_cases: Tuple["TestSoundTick.TestCase", ...] = ( - TestCase(label="a one-shot within its envelope", tick_index=0, loop_point=None, expected=True), + TestCase(label="a recording within its frames", tick_index=0, sustaining=False, expected=True), TestCase( - label="a one-shot on its final tick", + label="a recording on its final frame", tick_index=ENVELOPE_TICKS - 1, - loop_point=None, + sustaining=False, expected=True, ), TestCase( - label="a one-shot past its envelope", + label="a recording past its frames", tick_index=ENVELOPE_TICKS, - loop_point=None, + sustaining=False, expected=False, ), TestCase( - label="a looping voice past its envelope", + label="an instrument past its envelopes", tick_index=ENVELOPE_TICKS, - loop_point=WHOLE_LOOP_POINT, + sustaining=True, expected=True, ), TestCase( - label="a looping voice several passes on", + label="an instrument several passes on", tick_index=ENVELOPE_TICKS * 4 + 1, - loop_point=WHOLE_LOOP_POINT, - expected=True, - ), - TestCase( - label="a voice circling its tail", - tick_index=ENVELOPE_TICKS * 4, - loop_point=TAIL_LOOP_POINT, + sustaining=True, expected=True, ), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_whether_the_channel_still_sounds(self, test_case: TestCase) -> None: - reading = _reading(test_case.loop_point) + reading = _reading(sustaining=test_case.sustaining) performance = ChannelPerformance(tick_index=test_case.tick_index) instruction = sound_tick(performance, reading) @@ -85,40 +97,34 @@ def test_whether_the_channel_still_sounds(self, test_case: TestCase) -> None: @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_channel_moves_on_whether_or_not_it_sounds(self, test_case: TestCase) -> None: """A voice that has played out keeps counting, so the tick index states the song's time.""" - reading = _reading(test_case.loop_point) + reading = _reading(sustaining=test_case.sustaining) performance = ChannelPerformance(tick_index=test_case.tick_index) sound_tick(performance, reading) assert performance.tick_index == test_case.tick_index + 1 - def test_a_looping_voice_wraps_onto_the_instruction_the_pass_reaches(self) -> None: - reading = _reading(WHOLE_LOOP_POINT) + def test_a_recording_rests_once_its_frames_are_played(self) -> None: + """A recording is a fixed run, so a note holding past it leaves the channel silent.""" + reading = _reading() performance = ChannelPerformance() sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS * 2)] - assert sounded[:ENVELOPE_TICKS] == sounded[ENVELOPE_TICKS:] + assert sounded[ENVELOPE_TICKS:] == [None] * ENVELOPE_TICKS - def test_a_loop_point_leaves_the_opening_behind(self) -> None: - """A voice repeating from a point plays its opening once, then circles the frames past it.""" - reading = _reading(TAIL_LOOP_POINT) + def test_an_instrument_goes_on_holding_its_final_frame(self) -> None: + """An instrument's dimensions hold their last item, so the frame it ended on sounds on.""" + reading = _reading(sustaining=True) performance = ChannelPerformance() - sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS + 2)] - - assert sounded[:ENVELOPE_TICKS] == [reading.at(index) for index in range(ENVELOPE_TICKS)] - assert sounded[ENVELOPE_TICKS:] == [sounded[TAIL_LOOP_POINT]] * 2 - - def test_a_loop_point_past_the_frames_circles_the_last_one(self) -> None: - reading = _reading(ENVELOPE_TICKS * 2) - performance = ChannelPerformance(tick_index=ENVELOPE_TICKS * 3) + sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS * 2)] - assert sound_tick(performance, reading) == _reading(None).at(ENVELOPE_TICKS - 1) + assert sounded[ENVELOPE_TICKS:] == [sounded[ENVELOPE_TICKS - 1]] * ENVELOPE_TICKS def test_the_row_bends_the_instruction_the_voice_holds(self) -> None: """The transpose and volume a row reached are applied to what the channel sounds.""" - reading = _reading(None) + reading = _reading() transpose = 7 volume = MAX_VOLUME // 3 performance = ChannelPerformance(transpose=transpose, volume=volume) diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 0e355ab6a..58907cfd1 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -14,7 +14,6 @@ from sampletones_core.project.project import Project from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.sample import Sample from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION @@ -198,7 +197,7 @@ def test_an_instrument_survives_a_round_trip(self, tmp_path: Path) -> None: instrument = Instrument( name="lead", envelopes=InstrumentEnvelopes( - volume=Envelope(items=(15, 12), loop_point=WHOLE_LOOP_POINT), + volume=Envelope(items=(15, 12), loop_point=0), arpeggio=Envelope(items=(0, 7)), duty_cycle=Envelope(items=(2,)), ), diff --git a/tests/unit/sampletones_core/project/voices/test_creation.py b/tests/unit/sampletones_core/project/voices/test_creation.py index f2ea27801..f30a3b341 100644 --- a/tests/unit/sampletones_core/project/voices/test_creation.py +++ b/tests/unit/sampletones_core/project/voices/test_creation.py @@ -8,7 +8,6 @@ from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import instrument_from_features, new_instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT TONAL_REFERENCE = 64 NOISE_REFERENCE = 5 @@ -47,7 +46,7 @@ def test_a_new_instrument_sounds_at_full_volume(self) -> None: assert instrument.envelopes.volume.items == (MAX_VOLUME,) def test_a_new_instrument_repeats_its_volume_while_the_note_is_held(self) -> None: - assert new_instrument("lead").envelopes.volume.loop_point == WHOLE_LOOP_POINT + assert new_instrument("lead").envelopes.volume.loop_point == 0 def test_a_new_instrument_leaves_the_arpeggio_and_the_duty_cycle_to_the_channel(self) -> None: instrument = new_instrument("lead") diff --git a/tests/unit/sampletones_core/project/voices/test_instrument.py b/tests/unit/sampletones_core/project/voices/test_instrument.py index ce9ad3e49..fdd40c8e6 100644 --- a/tests/unit/sampletones_core/project/voices/test_instrument.py +++ b/tests/unit/sampletones_core/project/voices/test_instrument.py @@ -17,7 +17,6 @@ from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase diff --git a/tests/unit/sampletones_core/project/voices/test_sample.py b/tests/unit/sampletones_core/project/voices/test_sample.py index 5c45e46bd..d967f6025 100644 --- a/tests/unit/sampletones_core/project/voices/test_sample.py +++ b/tests/unit/sampletones_core/project/voices/test_sample.py @@ -1,6 +1,5 @@ from unittest.mock import Mock -from sampletones_core.project.voices.loop import WHOLE_LOOP_POINT from sampletones_core.project.voices.sample import Sample @@ -9,11 +8,10 @@ def test_clone_gets_a_fresh_id(self) -> None: sample = Sample(name="lead", reconstruction=Mock()) assert sample.clone().id != sample.id - def test_clone_carries_name_and_loop(self) -> None: - sample = Sample(name="lead", reconstruction=Mock(), loop_point=WHOLE_LOOP_POINT) - clone = sample.clone() - assert clone.name == "lead" - assert clone.loop_point == WHOLE_LOOP_POINT + def test_clone_carries_the_name(self) -> None: + sample = Sample(name="lead", reconstruction=Mock()) + + assert sample.clone().name == "lead" def test_clone_deep_copies_the_reconstruction(self) -> None: reconstruction = Mock() From 026c0ba8cfaa82e63a0d1d0376f19cad6b5eabb1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 18:56:59 +0200 Subject: [PATCH 117/142] Removed: pitch widgets --- docs/glossary.md | 18 +++++++------- .../logic/project/controller.py | 20 ---------------- .../logic/reconstruction/editing.py | 12 ++++------ .../logic/reconstruction/editor.py | 14 ----------- .../logic/reconstruction/instruments.py | 12 ++++------ .../ui/elements/pitch_stepper.py | 6 ++++- .../reconstruction/instruments/instruments.py | 15 ++++++++++-- .../logic/project/test_controller.py | 16 +++++++++---- .../logic/reconstruction/test_editor.py | 24 ++++++------------- .../logic/reconstruction/test_instruments.py | 9 ------- .../sequencer/tracker/test_pitch_faces.py | 13 ++++++---- .../sequencer/tracker/test_write_note.py | 15 +++++++----- 12 files changed, 71 insertions(+), 103 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 74d63a0bf..5fdbd07fb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -245,18 +245,20 @@ plays. See [The sequencer](guide/sequencer.md) and [FamiTracker export](formats/famitracker.md). Bitphase takes the same envelopes as a `.json` instrument preset. See [Bitphase export](formats/bitphase.md). -### Root +### Initial pitch -The note an instrument's arpeggio is measured against, which a row's step moves it -from. An instrument written by hand states one for the tonal channels and one for the -noise channel's periods, so the same envelopes sound on any of the four. The matching -value on a sample is its per-channel -[reference pitch](formats/reconstructions.md#contents). +The value an instrument's frames are built at, and the note an exported preset is +tuned to. An instrument written by hand states one for the tonal channels and a period +for the noise channel, so the same envelopes sound on any of the four; the note it +actually sounds at comes from the row that places it. The matching value on a sample is +its per-channel [reference pitch](formats/reconstructions.md#contents). ### Loop point -The tick a voice's envelopes repeat from while a note is held, which lets an attack -be followed by a sustained tail. A voice without one plays its envelopes once. +The item a single envelope repeats from while a note is held, which lets an attack be +followed by a sustained tail. Each envelope states its own, so a two-item duty cycle +circles on its own period beside a longer volume envelope. An envelope without one +holds its last item for as long as the note sounds. ## File types diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index a59143434..be6b0aae0 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -233,26 +233,6 @@ def set_instrument_envelope( self._announce(self.on_voices_changed) self._announce(self.on_song_changed) - def set_instrument_root( - self, - voice_id: str, - *, - pitch: int, - period: int, - ) -> None: - """Moves the pitch an instrument's arpeggio is measured against, on the tonal channels and on noise. - - Raises: - TypeError: If ``voice_id`` names a voice that states no pitch of its own. - """ - instrument = self._instrument(voice_id) - instrument.initial_pitch = pitch - instrument.initial_period = period - instrument.invalidate() - self._touch() - self._announce(self.on_voices_changed) - self._announce(self.on_song_changed) - def _instrument(self, voice_id: str) -> Instrument: voice = self.project.voices[voice_id] if not isinstance(voice, Instrument): diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py index 8c87503b7..0d0128e0e 100644 --- a/src/sampletones_application/logic/reconstruction/editing.py +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -15,21 +15,20 @@ class ReconstructionEdit: @dataclass(frozen=True) class InstrumentEdit: - """The one envelope set an instrument carries, with the pitch its arpeggio is measured from. + """The one envelope set an instrument carries, as the panel has it in front of a reader. + + The pitch an instrument's arpeggio is measured from travels inside :attr:`features`, where + an export reads it; a row states the note the voice sounds at, so nothing here edits it. Attributes: voice_id: The instrument an edit is written back into. name: The name the panel titles it by. features: The envelopes, read as the channel offering every dimension an instrument writes. - initial_pitch: The note the tonal channels measure the arpeggio against. - initial_period: The period the noise channel measures the arpeggio against. """ voice_id: str name: str features: Features - initial_pitch: int - initial_period: int EditedVoice = Union[ReconstructionEdit, InstrumentEdit] @@ -49,6 +48,3 @@ def edited_instrument(self) -> Optional[EditedVoice]: def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: """Writes one dimension of the instrument in front of the panel.""" - - def write_roots(self, *, pitch: int, period: int) -> None: - """Moves the roots the instrument in front of the panel is measured against.""" diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index 3f1c77367..bede1cc75 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -55,8 +55,6 @@ def edited_instrument(self) -> Optional[EditedVoice]: voice_id=instrument.id, name=instrument.name, features=instrument.instrument_features(), - initial_pitch=instrument.initial_pitch, - initial_period=instrument.initial_period, ) feature_data = self._reconstruction_manager.current_features @@ -73,15 +71,3 @@ def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> No raise TypeError("The tab holds no instrument to write an envelope into") self._controller.set_instrument_envelope(instrument.id, feature_key, envelope) - - def write_roots(self, *, pitch: int, period: int) -> None: - """Moves the pitch the instrument in front of the tab is measured against. - - Raises: - TypeError: If the tab holds no instrument to write into. - """ - instrument = self.instrument - if instrument is None: - raise TypeError("The tab holds no instrument to move the pitch of") - - self._controller.set_instrument_root(instrument.id, pitch=pitch, period=period) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 5c65d5a6b..6df9867b6 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -160,15 +160,11 @@ def handle_pitch_value_changed( channel_name: ChannelName, value: int, ) -> None: - instrument = self.instrument_edit - if instrument is not None: - self._editor.write_roots( - pitch=value, - period=instrument.initial_period, - ) - self.update_display() - return + """Moves the pitch one channel of a reconstruction has its frames measured against. + A conversion states the value it found, and moving it rebuilds the channel's frames around + the new origin, so the edit travels back out through the regeneration. + """ features = self._get_features(channel_name) self._schedule_reconstruction_update( ReconstructionUpdate( diff --git a/src/sampletones_application/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py index 17cbd8633..a5901260f 100644 --- a/src/sampletones_application/ui/elements/pitch_stepper.py +++ b/src/sampletones_application/ui/elements/pitch_stepper.py @@ -23,7 +23,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item, dpg_set_value from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.colors.base import BaseColor @@ -117,6 +117,10 @@ def set_value(self, value: int) -> None: self._value = self._kind.clamp(value) self._render() + def set_shown(self, shown: bool) -> None: + """Shows or hides the stepper, label and buttons together, which is the whole of it.""" + dpg_configure_item(self._table_tag, show=shown) + def _build(self) -> None: self._clear_existing_items() with dpg.table( diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index c19eef850..782c095c5 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -453,8 +453,8 @@ def update_view( A reconstruction shows a tab per channel, and every channel is editable for as long as it is open, so writing an envelope into a channel standing by is what puts it in play; a muted tab label and a withheld export say which channels are there. An instrument is one - instrument every channel reads, so it shows a single tab under its own name, carrying the - roots and the loop point it states. + set every channel reads, so it shows a single tab under its own name, and the pitch + stepper stands down: a row states the note a hand-written voice sounds at. """ instrument = view_model.instrument is_open = view_model.is_open @@ -462,6 +462,7 @@ def update_view( dpg_configure_item(self.tab_bar_tag, show=is_open) dpg_configure_item(self.sample_size_group_tag, show=is_open) self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None) + self._show_pitch_steppers(shown=instrument is None) for channel_name in ChannelName.items(): tab_tag = self._get_generator_tab_tag(channel_name) @@ -477,6 +478,16 @@ def update_view( channel_name in view_model.playing_channels, ) + def _show_pitch_steppers(self, *, shown: bool) -> None: + """Offers the pitch a channel measures its arpeggio against, where a reader may move it. + + A conversion states the value it found for each channel and moving it rebuilds that + channel's frames. An instrument is placed by a row that states the note itself, so the + value it stores for an export stands as it is and the stepper stays out of the way. + """ + for stepper in self._pitch_steppers.values(): + stepper.set_shown(shown) + def _apply_playing_state( self, channel_name: ChannelName, diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 4c45244ec..67b2966d7 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -12,6 +12,7 @@ from sampletones_core.instructions import PulseInstruction from sampletones_core.project import ProjectContainer from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction @@ -520,12 +521,17 @@ def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None: assert FeatureKey.VOLUME in instrument.held_features(ChannelName.PULSE1) - def test_moving_the_roots_reaches_the_frames(self) -> None: + def test_the_pitch_an_instrument_states_is_the_one_its_frames_are_built_at(self) -> None: + """The stored value is the origin an export reads; a row moves the voice off it.""" controller = _controller() - instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15,))) - - controller.set_instrument_root(instrument.id, pitch=48, period=3) + instrument = controller.add_instrument( + Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))), + initial_pitch=48, + initial_period=3, + ) + ) assert instrument.reference(ChannelName.PULSE1) == 48 assert instrument.reference(ChannelName.NOISE) == 3 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index 96476e3c5..3ec55b179 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -11,10 +11,10 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.features.envelope import Envelope -from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.creation import SUSTAINING_ENVELOPES, new_instrument +from sampletones_core.project.voices.instrument import Instrument ROOT_PITCH: Final[int] = 55 -ROOT_PERIOD: Final[int] = 3 VOLUME: Final[Tuple[int, ...]] = (15, 12, 9) @@ -67,15 +67,17 @@ def test_an_instrument_answers_with_what_it_states( editor: InstrumentEditor, controller: ProjectController, ) -> None: - instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) + """The pitch an export reads travels inside the envelopes the tab has in front of it.""" + instrument = controller.add_instrument( + Instrument(name="lead", envelopes=SUSTAINING_ENVELOPES, initial_pitch=ROOT_PITCH) + ) editor.edit_instrument(instrument.id) edit = editor.edited_instrument() assert isinstance(edit, InstrumentEdit) assert (edit.voice_id, edit.name) == (instrument.id, "lead") - assert (edit.initial_pitch, edit.initial_period) == (ROOT_PITCH, ROOT_PERIOD) + assert edit.features.initial_pitch == ROOT_PITCH def test_opening_an_instrument_closes_the_reconstruction_the_tab_held( self, @@ -130,18 +132,6 @@ def test_an_envelope_reaches_the_instrument( assert instrument.envelopes.volume.items == VOLUME - def test_the_roots_reach_the_instrument( - self, - editor: InstrumentEditor, - controller: ProjectController, - ) -> None: - instrument = controller.add_instrument(new_instrument("lead")) - editor.edit_instrument(instrument.id) - - editor.write_roots(pitch=ROOT_PITCH, period=ROOT_PERIOD) - - assert (instrument.initial_pitch, instrument.initial_period) == (ROOT_PITCH, ROOT_PERIOD) - def test_a_point_written_on_an_envelope_reaches_the_instrument( self, editor: InstrumentEditor, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 7a00bd58a..101ad6304 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -392,15 +392,6 @@ def test_an_envelope_edit_reaches_the_instrument_without_a_regeneration( assert instrument.envelopes.arpeggio.items == (0, 7) assert regenerated == [] - def test_the_pitch_stepper_moves_the_instruments_tonal_root( - self, - instrument_logic: ReconstructionInstrumentsLogic, - project_controller: ProjectController, - ) -> None: - instrument_logic.handle_pitch_value_changed(INSTRUMENT_CHANNEL, 48) - - assert project_controller.project.voices[0].initial_pitch == 48 - def test_the_figure_measures_the_one_instrument_it_exports( self, instrument_logic: ReconstructionInstrumentsLogic, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py index 69e34be80..e58be918c 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -26,11 +26,14 @@ def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]: def _instrument(controller: ProjectController) -> Instrument: - instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=ROOT_PERIOD) - instrument.envelopes = InstrumentEnvelopes(volume=Envelope(items=(15,))) - instrument.invalidate() - return instrument + return controller.add_instrument( + Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))), + initial_pitch=ROOT_PITCH, + initial_period=ROOT_PERIOD, + ) + ) def _write( diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py index bb1954496..c675740e1 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py @@ -7,6 +7,7 @@ from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.creation import new_instrument from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.project.voices.voice import voice_reference @@ -39,10 +40,13 @@ def _transpose(logic: SequencerTrackerLogic, channel: ChannelName, row_index: in class TestATypedNoteIsStatedAsAStepFromTheVoice: def test_an_instrument_takes_the_step_that_reaches_the_note(self) -> None: controller, logic = _logic() - instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=8) - instrument.envelopes = InstrumentEnvelopes(volume=Envelope(items=(15,))) - instrument.invalidate() + instrument = controller.add_instrument( + Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))), + initial_pitch=ROOT_PITCH, + ) + ) _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH) @@ -61,8 +65,7 @@ def test_a_sample_takes_the_step_from_its_own_pitch(self) -> None: def test_a_row_below_the_note_is_measured_against_the_voice_it_carries(self) -> None: controller, logic = _logic() - instrument = controller.add_instrument(new_instrument("lead")) - controller.set_instrument_root(instrument.id, pitch=ROOT_PITCH, period=8) + instrument = controller.add_instrument(Instrument(name="lead", initial_pitch=ROOT_PITCH)) _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH) From e637395f804ecc6dd70cf894602bdd41e9c87a85 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 19:33:55 +0200 Subject: [PATCH 118/142] Added: the instrument audition --- src/sampletones_application/application.py | 10 + .../categories/context.py | 21 +- .../categories/elements/global_.py | 1 + .../constants/instruments.py | 3 +- .../coordinators/tabs/reconstruction.py | 26 ++- .../logic/reconstruction/audition.py | 105 +++++++++ .../logic/reconstruction/editing.py | 14 ++ .../logic/sequencer/voices.py | 19 +- .../tags/reconstructions.py | 6 + .../reconstruction/instruments/instruments.py | 111 +++++++++- src/sampletones_config/lang/en.yaml | 4 + src/sampletones_core/features/__init__.py | 4 + src/sampletones_core/features/spec.py | 17 ++ src/sampletones_core/performance/__init__.py | 2 + src/sampletones_core/performance/audition.py | 67 ++++++ .../logic/reconstruction/test_audition.py | 209 ++++++++++++++++++ .../reconstruction/test_instruments_panel.py | 132 ++++++++++- .../sampletones_core/features/test_spec.py | 12 + .../performance/test_audition.py | 132 +++++++++++ 19 files changed, 874 insertions(+), 21 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/audition.py create mode 100644 src/sampletones_core/performance/audition.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/test_audition.py create mode 100644 tests/unit/sampletones_core/performance/test_audition.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index c03a15b60..ef00c3a05 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -444,6 +444,8 @@ def __init__( on_reconstruction_stem_removed=self._reconstruction_coordinator.apply_edit, original_audio_locator=self._original_audio_locator, instrument_exports=self._instrument_exports, + key_router=self.key_router, + tab_active=self._is_reconstructions_tab_current, layout=ReconstructionTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, @@ -792,6 +794,14 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) + def _is_reconstructions_tab_current(self) -> bool: + """Whether the Reconstructions tab is in front, which is what puts its panels on the keyboard. + + The instruments panel keeps the voice it is editing while another tab is worked on, so this + is what tells a note key meant to sound that voice from one meant for the song's grid. + """ + return self._shell.get_current_tab() == Tab.RECONSTRUCTIONS + def _is_sequencer_tab_current(self) -> bool: """Whether the Sequencer is the tab in front, which is what puts its panels on the keyboard. diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 7ed551702..ab5e1d2dd 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -3,7 +3,7 @@ from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, GeneratorName CHANNEL_ELEMENTS: Final[Dict[ChannelName, ContextElements]] = { ChannelName.PULSE1: ContextElements.PULSE_1, @@ -12,6 +12,12 @@ ChannelName.NOISE: ContextElements.NOISE, } +GENERATOR_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = { + GeneratorName.PULSE: ContextElements.PULSE, + GeneratorName.TRIANGLE: ContextElements.TRIANGLE, + GeneratorName.NOISE: ContextElements.NOISE, +} + def context_text( language_manager: LanguageManager, @@ -63,3 +69,16 @@ def channel_label( channel read it from one entry, so a reader meets the same name for the same channel. """ return context_label(language_manager, CHANNEL_ELEMENTS[channel]) + + +def generator_label( + language_manager: LanguageManager, + generator_name: GeneratorName, +) -> str: + """Resolves a generator's name, the words every display naming a generator prints. + + A generator names the sound itself rather than one of the channels playing it, so the two + pulse channels share the one name. Reading it from a single entry keeps a generator called + the same thing wherever it is offered. + """ + return context_label(language_manager, GENERATOR_ELEMENTS[generator_name]) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 4fac92ce1..8f08d4cc9 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -33,6 +33,7 @@ class ContextElements(AbstractElement): REPLACE_SAMPLE = "replace_sample" LOCATE_ORIGINAL_AUDIO = "locate_original_audio" TRIANGLE = "triangle" + PULSE = "pulse" PULSE_1 = "pulse_1" PULSE_2 = "pulse_2" NOISE = "noise" diff --git a/src/sampletones_application/constants/instruments.py b/src/sampletones_application/constants/instruments.py index ed537443d..03d802ef9 100644 --- a/src/sampletones_application/constants/instruments.py +++ b/src/sampletones_application/constants/instruments.py @@ -1,5 +1,6 @@ from typing import Final -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, GeneratorName INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +AUDITION_GENERATOR: Final[GeneratorName] = GeneratorName.PULSE diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9b198c335..db3b4c152 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -17,6 +17,9 @@ from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.reconstruction.audition import ( + InstrumentAuditionLogic, +) from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.edit import StemRemoval @@ -83,6 +86,7 @@ from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -131,6 +135,8 @@ def __init__( original_audio_locator: OriginalAudioLocator, instrument_exports: InstrumentExportCoordinator, *, + key_router: KeyRouter, + tab_active: ActivePredicate, layout: ReconstructionTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, @@ -190,7 +196,7 @@ def __init__( self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled self._browser_tree_logic.on_favorite_changed = on_favorite_changed self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility - self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error + self._browser_tree_logic.on_autoplay_error = self._on_preview_error self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed) self._browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed self._reconstruction_player_logic = PlayerLogic( @@ -238,6 +244,8 @@ def __init__( layout_graphs=layout.graphs, language_manager=language_manager, status_bar=status_bar, + key_router=key_router, + tab_active=tab_active, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL), ) self._reconstruction_instruments_panel.set_collapse_handler(self._on_instruments_collapse_changed) @@ -245,6 +253,12 @@ def __init__( self._instrument_editor, scheduling=layout.scheduling, ) + self._instrument_audition_logic: InstrumentAuditionLogic = InstrumentAuditionLogic( + self._instrument_editor, + project_controller, + session_manager, + audio_device_manager, + ) self._browser_panel.on_refresh_tree = self._browser_logic.refresh_tree self._browser_panel.on_load_reconstruction = on_load_reconstruction_with_confirmation @@ -295,6 +309,8 @@ def __init__( self._reconstruction_instruments_panel.on_envelope_changed = ( self._reconstruction_instruments_logic.handle_envelope_changed ) + self._reconstruction_instruments_panel.on_audition_requested = self._instrument_audition_logic.sound + self._instrument_audition_logic.on_audition_error = self._on_preview_error def _on_export_result(self, result: ExportResult) -> None: """Reports a finished export in the words of the artefact it produced. @@ -730,7 +746,13 @@ def request_export_instruments_dialog( ) -> None: self._reconstruction_panel_logic.request_export_instruments_dialog(export_format) - def _on_browser_autoplay_error(self, exception: Exception) -> None: + def _on_preview_error(self, exception: Exception) -> None: + """Reports a preview the audio device refused, whichever of the tab's previews asked for it. + + A browser autoplay and an instrument audition both sound on demand, so both report the + same way: on the frame after the one that failed, which leaves the gesture that started it + finished before a dialog is raised. + """ FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) def _on_audio_data_changed(self, audio_data: Optional[AudioData]) -> None: diff --git a/src/sampletones_application/logic/reconstruction/audition.py b/src/sampletones_application/logic/reconstruction/audition.py new file mode 100644 index 000000000..970d44e72 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/audition.py @@ -0,0 +1,105 @@ +from typing import Callable, Optional + +import numpy as np + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.reconstruction.editing import ( + InstrumentAuditionProtocol, +) +from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_core.audio import AudioDeviceManager +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, GeneratorName +from sampletones_core.features import generator_channel, speaks_in_periods +from sampletones_core.performance.audition import audition_audio +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES +from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.logger import logger +from sampletones_shared.utils.callbacks import CallbackMixin + + +class InstrumentAuditionLogic(CallbackMixin): + """Sounds the instrument the Reconstructions tab is showing, at the note a key names. + + An instrument stands on no recording, so hearing one means playing it: the tab states which + generator to sound it on and a piano key states the note, and the two together name the frames + the voice makes. The audition plays at preview priority, so it yields to playback the reader + asked for and answers Stop the way every other preview does. + """ + + def __init__( + self, + editor: InstrumentAuditionProtocol, + project_controller: ProjectController, + session_manager: SessionManager, + audio_device_manager: AudioDeviceManager, + ) -> None: + self._editor = editor + self._controller = project_controller + self._session_manager = session_manager + self._audio_device_manager = audio_device_manager + + self.on_audition_error: Optional[Callable[[Exception], None]] = None + + def sound(self, generator_name: GeneratorName, semitone: int) -> None: + """Sounds the instrument in front of the tab on one generator, at one key of the keyboard. + + Args: + generator_name: The generator the voice is heard on. + semitone: How far the key pressed stands above the C of the octave in force. + """ + instrument = self._editor.instrument + if instrument is None: + return + + channel_name = generator_channel(generator_name) + audio = audition_audio( + instrument, + channel_name, + self._audition_config(), + pitch=self._sounding_pitch(instrument, channel_name, semitone), + ) + if audio is None: + return + + self._play(audio, instrument.id) + + def _sounding_pitch( + self, + instrument: Instrument, + channel_name: ChannelName, + semitone: int, + ) -> int: + """The value the channel sounds the voice at, which is a note or a noise period. + + Two rows of keys name two octaves above the one in force, the way the tracker's own note + entry reads them. The noise channel selects one of sixteen periods instead of naming + notes, so a key there sounds the instrument at the period it states. + """ + if speaks_in_periods(channel_name): + return instrument.reference(channel_name) + + return (self._session_manager.octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone + + def _audition_config(self) -> Config: + settings = self._controller.project.settings + return Config().with_library( + nes_frequency=settings.nes_frequency, + sample_rate=settings.sample_rate, + ) + + def _play(self, audio: np.ndarray, voice_id: str) -> None: + try: + self._audio_device_manager.play( + audio, + update=False, + priority=PlaybackPriority.PREVIEW, + ) + except (PlaybackError, ValueError) as exception: + logger.error_with_traceback( + exception, + f"Failed to audition instrument: {voice_id}", + ) + self.call(self.on_audition_error, exception) diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py index 0d0128e0e..699e7f20c 100644 --- a/src/sampletones_application/logic/reconstruction/editing.py +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -4,6 +4,7 @@ from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.instrument import Instrument @dataclass(frozen=True) @@ -48,3 +49,16 @@ def edited_instrument(self) -> Optional[EditedVoice]: def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: """Writes one dimension of the instrument in front of the panel.""" + + +class InstrumentAuditionProtocol(Protocol): + """Where the voice an audition sounds comes from. + + An audition plays the instrument the tab is showing, at the note a key names, so it reads the + voice whole rather than the envelopes a panel draws: the frames it sounds are made from the + envelopes and the pitch they are measured against together. + """ + + @property + def instrument(self) -> Optional[Instrument]: + """The instrument in front of the tab, or ``None`` while it shows a recording or nothing.""" diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index cc2185455..3f1af0fbf 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -30,7 +30,7 @@ ImportedVoice, instrument_to_voice, ) -from sampletones_core.generators.render import render_instructions +from sampletones_core.performance.audition import audition_audio from sampletones_core.project.voices.creation import ( instrument_from_features, new_instrument, @@ -280,10 +280,12 @@ def _execute_autoplay(self) -> None: self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW) def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: - """The audio a preview sounds: a sample's approximation, or an instrument rendered on the pulse. + """The audio a preview sounds: a sample's approximation, or an instrument on the pulse. - The pulse channel offers every dimension an instrument writes, so rendering the preview there - sounds the whole instrument rather than the part another channel would read. + The pulse channel offers every dimension an instrument writes, so sounding the preview + there sounds the whole instrument rather than the part another channel would read. The + pool states a voice rather than a note, so it sounds at the pitch the instrument itself is + measured against. Args: voice_id: The voice to preview. @@ -295,14 +297,11 @@ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: case Sample() as sample: return sample.reconstruction.approximation case Instrument() as instrument: - instructions = instrument.instructions(PREVIEW_CHANNEL) - if not instructions: - return None - - return render_instructions( - instructions, + return audition_audio( + instrument, PREVIEW_CHANNEL, self._preview_config(), + pitch=instrument.reference(PREVIEW_CHANNEL), ) case _: return None diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index d654aed38..003e8ef06 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -164,6 +164,12 @@ Widget.TEXT, "sample_size", ) +TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION = TagName( + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + Widget.RADIO, + "audition", +) PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel") PRE_RECONSTRUCTION_STEMS = compose_tag("reconstruction", "stems") diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 782c095c5..d2c52c25c 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -4,12 +4,20 @@ import dearpygui.dearpygui as dpg import numpy as np -from sampletones_application.categories.context import channel_label, context_label, context_text +from sampletones_application.categories.context import ( + channel_label, + context_label, + context_text, + generator_label, +) from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.hierarchy import TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.categories.pitch import PitchTooltips -from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +from sampletones_application.constants.instruments import ( + AUDITION_GENERATOR, + INSTRUMENT_CHANNEL, +) from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag @@ -36,6 +44,7 @@ SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, + TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE, ) @@ -63,6 +72,13 @@ dpg_configure_item, dpg_set_value, ) +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, +) +from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.instruments import ( @@ -96,6 +112,7 @@ from sampletones_shared.utils.arrays import clamp OnInstrumentExportCallback = Callable[[ChannelName], None] +OnAuditionCallback = Callable[[GeneratorName, int], None] OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None] @@ -114,10 +131,14 @@ def __init__( layout_graphs: GraphsLayout, language_manager: LanguageManager, status_bar: GUIStatusBar, + key_router: KeyRouter, + tab_active: ActivePredicate, initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager self._status_bar = status_bar + self._router = key_router + self._tab_active = tab_active self.channel_plots: Dict[ChannelName, Dict[FeatureKey, GUIBarGraph]] = {} self._pitch_steppers: Dict[ChannelName, GUIPitchStepper] = {} @@ -128,9 +149,13 @@ def __init__( self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY) self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) + self.audition_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION + self.audition_group_tag = compose_tag(self.audition_tag, SUF_GROUP) self._graphs: Dict[str, GUIBarGraph] = {} self._sequences: Dict[Tuple[ChannelName, FeatureKey], Envelope[int]] = {} + self._audition_generator: GeneratorName = AUDITION_GENERATOR + self._audition_open: bool = False self._pitch_stepper_style = pitch_stepper_style self._copy_width = copy_width self._layout_graphs = layout_graphs @@ -147,6 +172,7 @@ def __init__( self.on_reconstruction_instrument_hovered: Optional[OnReconstructionInstrumentHoveredCallback] = None self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None + self.on_audition_requested: Optional[OnAuditionCallback] = None self.on_envelope_changed: Optional[Callable[[ChannelName, FeatureKey, Envelope[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] @@ -161,6 +187,9 @@ def __init__( self._channel_labels: Dict[ChannelName, str] = { channel_name: channel_label(language_manager, channel_name) for channel_name in ChannelName.items() } + self._generator_labels: Dict[GeneratorName, str] = { + generator_name: generator_label(language_manager, generator_name) for generator_name in GeneratorName + } super().__init__( tag=TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, @@ -188,6 +217,11 @@ def create_panel(self, parent: str) -> None: self._create_content() self._setup_mouse_event_handler() + self._router.register( + self._on_key_pressed, + priority=PRIORITY_PANEL, + active=self._audition_keys_active, + ) def _create_content(self) -> None: dpg.add_text( @@ -358,6 +392,9 @@ def _create_generator_content( window_tag, ) self._create_pitch_stepper(channel_name, initial_pitch, window_tag) + if channel_name is INSTRUMENT_CHANNEL: + self._create_audition_selector(window_tag) + self._create_generator_feature_displays(channel_name, window_tag) def _default_initial_pitch(self, channel_name: ChannelName) -> int: @@ -453,8 +490,9 @@ def update_view( A reconstruction shows a tab per channel, and every channel is editable for as long as it is open, so writing an envelope into a channel standing by is what puts it in play; a muted tab label and a withheld export say which channels are there. An instrument is one - set every channel reads, so it shows a single tab under its own name, and the pitch - stepper stands down: a row states the note a hand-written voice sounds at. + set every channel reads, so it shows a single tab under its own name, and the audition + takes the pitch stepper's place: a row states the note a hand-written voice sounds at, and + what the panel offers instead is the generator to hear it on. """ instrument = view_model.instrument is_open = view_model.is_open @@ -463,6 +501,7 @@ def update_view( dpg_configure_item(self.sample_size_group_tag, show=is_open) self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None) self._show_pitch_steppers(shown=instrument is None) + self._show_audition_selector(shown=instrument is not None) for channel_name in ChannelName.items(): tab_tag = self._get_generator_tab_tag(channel_name) @@ -488,6 +527,70 @@ def _show_pitch_steppers(self, *, shown: bool) -> None: for stepper in self._pitch_steppers.values(): stepper.set_shown(shown) + def _create_audition_selector(self, window_tag: str) -> None: + """Offers the generator a hand-written voice is heard on, in the pitch stepper's column. + + An instrument is one set of envelopes every generator reads what it can of, so hearing it + means choosing which one reads it. The choice belongs to the reader listening rather than + to the voice, so it stays on the panel and reaches no document. + """ + with dpg.group( + tag=self.audition_group_tag, + parent=window_tag, + show=False, + ): + with labeled_field( + self._language_manager["reconstructions.instruments.label.audition"], + self._pitch_stepper_style.dimensions.label_width, + parent=self.audition_group_tag, + ): + dpg.add_radio_button( + items=[self._generator_labels[generator_name] for generator_name in GeneratorName], + tag=self.audition_tag, + default_value=self._generator_labels[self._audition_generator], + callback=self._on_audition_generator_changed, + horizontal=True, + ) + FontRegistry.bind_to_item(self.audition_tag, Font.REGULAR_SMALL) + + self._status_bar.bind_to_item( + self.audition_tag, + self._language_manager["reconstructions.instruments.message.status_audition"], + ) + show_tooltip( + self.audition_tag, + self._language_manager["reconstructions.instruments.tooltip.audition"], + tag=compose_tag(self.audition_tag, SUF_TOOLTIP), + ) + + def _on_audition_generator_changed(self, _sender: Sender, app_data: str) -> None: + self._audition_generator = next( + generator_name for generator_name, label in self._generator_labels.items() if label == app_data + ) + + def _show_audition_selector(self, *, shown: bool) -> None: + """Offers the audition while a hand-written voice is open, which is the voice it sounds.""" + self._audition_open = shown + dpg_configure_item(self.audition_group_tag, show=shown) + + def _audition_keys_active(self) -> bool: + """Whether a note key sounds the instrument the panel has in front of it. + + The keys reach an instrument alone, since a recording plays the audio it was made from, + and only while the Reconstructions tab is in front. A field being typed into keeps its own + characters, so a sequence entered by hand types letters rather than sounding notes. + """ + return self._audition_open and self._tab_active() and not self._router.is_field_focused + + def _on_key_pressed(self, event: KeyEvent) -> bool: + """Sounds the open instrument at the note a piano key names, reporting whether it did.""" + semitone = PIANO_KEYS.get(event.key) + if semitone is None: + return False + + self.call(self.on_audition_requested, self._audition_generator, semitone) + return True + def _apply_playing_state( self, channel_name: ChannelName, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8afea4647..a88804f4c 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -160,6 +160,7 @@ global.context.label.add_to_sequencer: "Add to Sequencer" global.context.template.replace_sample: "Replace {sample}" global.context.label.locate_original_audio: "Locate original audio" global.context.label.triangle: "Triangle" +global.context.label.pulse: "Pulse" global.context.label.pulse_1: "Pulse 1" global.context.label.pulse_2: "Pulse 2" global.context.label.noise: "Noise" @@ -485,8 +486,10 @@ reconstructions.instruments.label.arpeggio_label: "Arpeggio" reconstructions.instruments.label.duty_cycle_label: "Duty cycle" reconstructions.instruments.label.initial_period: "Initial period:" reconstructions.instruments.label.initial_pitch: "Initial pitch: " +reconstructions.instruments.label.audition: "Audition: " reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type value. Enter note name (e.g. \"C-4\") or MIDI value (72)." reconstructions.instruments.message.status_input_period: "Ctrl + click to type value. Enter period name (e.g. \"4-#\") or integer value (4)." +reconstructions.instruments.message.status_audition: "Click to choose the generator the note keys sound this instrument on." reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options." reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply." reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on a FamiTracker export." @@ -505,6 +508,7 @@ reconstructions.instruments.title.not_loaded_dialog: "Reconstruction not loaded" reconstructions.instruments.title.export_wav_dialog: "Export WAV" reconstructions.instruments.title.export_instrument_dialog: "Export instrument" reconstructions.instruments.title.export_instruments_dialog: "Export instruments" +reconstructions.instruments.tooltip.audition: "Play this instrument with the note keys: the bottom row starts at the tracker's octave and the top row an octave above it." reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the initial {} by name (e.g. {}) or value ({})." # ============================================================================= diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index 41d817841..35e3809e0 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -2,12 +2,14 @@ CHANNEL_FEATURE_DEFAULTS, CHANNEL_GENERATOR_KIND, FEATURE_DIMENSION_ORDER, + GENERATOR_CHANNEL_KINDS, GENERATOR_FEATURE_RANGES, RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH, FeatureRange, channel_reference, feature_range, + generator_channel, resting_held_features, resting_reference, speaks_in_periods, @@ -19,6 +21,7 @@ __all__ = [ "CHANNEL_FEATURE_DEFAULTS", "FEATURE_DIMENSION_ORDER", + "GENERATOR_CHANNEL_KINDS", "GENERATOR_FEATURE_RANGES", "CHANNEL_GENERATOR_KIND", "RESTING_REFERENCE_PERIOD", @@ -26,6 +29,7 @@ "FeatureRange", "channel_reference", "feature_range", + "generator_channel", "speaks_in_periods", "resting_held_features", "resting_reference", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index 0b7e5f056..94e9c823a 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -74,6 +74,23 @@ class FeatureRange: } +def generator_channel(generator_name: GeneratorName) -> ChannelName: + """The channel that stands for a generator, which is the first one it drives. + + A generator drives one channel or two, and the two pulse channels read an instrument the same + way, so naming a generator names a channel to sound it on. Reading the channels in channel + order keeps the answer the same on every call. + + Args: + generator_name: The generator being resolved. + + Returns: + ChannelName: The channel that generator is heard on. + """ + channels = GENERATOR_CHANNEL_KINDS[generator_name] + return next(channel_name for channel_name in ChannelName.items() if channel_name in channels) + + def speaks_in_periods(channel_name: ChannelName) -> bool: """Whether this channel reads a pitch-like value as a noise period rather than a semitone. diff --git a/src/sampletones_core/performance/__init__.py b/src/sampletones_core/performance/__init__.py index 4c9f8dedf..2da6180ba 100644 --- a/src/sampletones_core/performance/__init__.py +++ b/src/sampletones_core/performance/__init__.py @@ -1,3 +1,4 @@ +from .audition import audition_audio from .modifiers import apply_modifiers from .progress import ( SILENT_WALK_REPORTER, @@ -20,6 +21,7 @@ "announce", "apply_modifiers", "apply_row", + "audition_audio", "resolve_row", "song_instructions", "sound_tick", diff --git a/src/sampletones_core/performance/audition.py b/src/sampletones_core/performance/audition.py new file mode 100644 index 000000000..58bdc87cb --- /dev/null +++ b/src/sampletones_core/performance/audition.py @@ -0,0 +1,67 @@ +from typing import List, Optional + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.generators.render import render_instructions +from sampletones_core.instructions import InstructionUnion +from sampletones_core.performance.modifiers import apply_modifiers +from sampletones_core.project.voices.instrument import Instrument + + +def audition_instructions( + instrument: Instrument, + channel_name: ChannelName, + *, + pitch: int, +) -> List[InstructionUnion]: + """The frames an instrument sounds on one channel at one note, at full volume. + + An instrument's frames are built at the reference the channel reads, so sounding it at a note + is the step from that reference to the note — the same step a tracker row states, taken here + without a row to state it. The whole envelope is sounded through, which is what a listener + hears of a voice standing on its own. + + Args: + instrument: The voice being sounded. + channel_name: The channel it is sounded on. + pitch: The note it sounds at, read as a period on the noise channel. + + Returns: + List[InstructionUnion]: The frames, empty where the instrument writes no envelope. + """ + transpose = pitch - instrument.reference(channel_name) + return [ + apply_modifiers(instruction, transpose, MAX_VOLUME) for instruction in instrument.instructions(channel_name) + ] + + +def audition_audio( + instrument: Instrument, + channel_name: ChannelName, + config: Config, + *, + pitch: int, +) -> Optional[np.ndarray]: + """The audio an instrument sounds on one channel at one note. + + This is what an audition plays and what a plot of a hand-written voice draws, so both answer + the same generator with the same waveform. + + Args: + instrument: The voice being sounded. + channel_name: The channel it is sounded on. + config: The configuration the frames are rendered at. + pitch: The note it sounds at, read as a period on the noise channel. + + Returns: + Optional[np.ndarray]: The waveform to play, or ``None`` where the instrument + writes no envelope for that channel. + """ + instructions = audition_instructions(instrument, channel_name, pitch=pitch) + if not instructions: + return None + + return render_instructions(instructions, channel_name, config) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_audition.py b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py new file mode 100644 index 000000000..0d0bf473e --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py @@ -0,0 +1,209 @@ +from typing import Final, List, Optional +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.audition import InstrumentAuditionLogic +from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_core.audio import AudioDeviceManager +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, GeneratorName +from sampletones_core.features.envelope import Envelope +from sampletones_core.performance.audition import audition_audio +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.exceptions import PlaybackError + +REFERENCE_PITCH: Final[int] = 60 +REFERENCE_PERIOD: Final[int] = 8 +OCTAVE: Final[int] = 2 +MIDDLE_C: Final[int] = 48 +SEMITONE_G: Final[int] = 7 + + +class _Editor: + """A tab holding one instrument, or none, which is all an audition reads of it.""" + + def __init__(self, instrument: Optional[Instrument]) -> None: + self._instrument = instrument + + @property + def instrument(self) -> Optional[Instrument]: + return self._instrument + + +def _instrument() -> Instrument: + return Instrument( + name="lead", + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=(15, 12)), + arpeggio=Envelope[int](items=(0, 4)), + duty_cycle=Envelope[int](items=(2,)), + ), + initial_pitch=REFERENCE_PITCH, + initial_period=REFERENCE_PERIOD, + ) + + +@pytest.fixture +def controller() -> ProjectController: + return ProjectController(ProjectManager()) + + +@pytest.fixture +def session() -> MagicMock: + manager = MagicMock(spec=SessionManager) + manager.octave = OCTAVE + return manager + + +@pytest.fixture +def device() -> MagicMock: + return MagicMock(spec=AudioDeviceManager) + + +def _logic( + instrument: Optional[Instrument], + controller: ProjectController, + session: MagicMock, + device: MagicMock, +) -> InstrumentAuditionLogic: + return InstrumentAuditionLogic(_Editor(instrument), controller, session, device) + + +def _played(device: MagicMock) -> np.ndarray: + audio = device.play.call_args.args[0] + assert isinstance(audio, np.ndarray) + return audio + + +def _expected( + instrument: Instrument, + controller: ProjectController, + channel_name: ChannelName, + pitch: int, +) -> np.ndarray: + settings = controller.project.settings + config = Config().with_library( + nes_frequency=settings.nes_frequency, + sample_rate=settings.sample_rate, + ) + audio = audition_audio(instrument, channel_name, config, pitch=pitch) + assert audio is not None + return audio + + +class TestWhatAKeySounds: + def test_a_key_sounds_the_voice_at_the_note_it_names( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + instrument = _instrument() + + _logic(instrument, controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + + expected = _expected(instrument, controller, ChannelName.PULSE1, MIDDLE_C + SEMITONE_G) + assert np.array_equal(_played(device), expected) + + def test_the_octave_in_force_moves_the_note_the_same_key_sounds( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + instrument = _instrument() + session.octave = OCTAVE + 1 + + _logic(instrument, controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + + expected = _expected(instrument, controller, ChannelName.PULSE1, MIDDLE_C + 12 + SEMITONE_G) + assert np.array_equal(_played(device), expected) + + def test_the_generator_chosen_is_the_channel_the_voice_is_heard_on( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + instrument = _instrument() + + _logic(instrument, controller, session, device).sound(GeneratorName.TRIANGLE, SEMITONE_G) + + expected = _expected(instrument, controller, ChannelName.TRIANGLE, MIDDLE_C + SEMITONE_G) + assert np.array_equal(_played(device), expected) + + def test_the_noise_generator_sounds_at_the_period_the_voice_states( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + """The noise channel selects one of sixteen periods, so a key there names no note.""" + instrument = _instrument() + logic = _logic(instrument, controller, session, device) + + logic.sound(GeneratorName.NOISE, SEMITONE_G) + first = _played(device) + logic.sound(GeneratorName.NOISE, 0) + second = _played(device) + + expected = _expected(instrument, controller, ChannelName.NOISE, REFERENCE_PERIOD) + assert np.array_equal(first, expected) + assert np.array_equal(second, expected) + + def test_an_audition_yields_to_the_playback_a_reader_asked_for( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + _logic(_instrument(), controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + + assert device.play.call_args.kwargs["priority"] is PlaybackPriority.PREVIEW + + +class TestWhenNothingSounds: + def test_a_tab_holding_no_instrument_plays_nothing( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + _logic(None, controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + + device.play.assert_not_called() + + def test_a_voice_writing_no_envelope_plays_nothing( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + _logic(Instrument(name="silent"), controller, session, device).sound( + GeneratorName.PULSE, + SEMITONE_G, + ) + + device.play.assert_not_called() + + def test_a_device_refusing_the_audition_reports_what_it_refused_with( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + refusal = PlaybackError("no device") + device.play.side_effect = refusal + logic = _logic(_instrument(), controller, session, device) + reported: List[Exception] = [] + logic.on_audition_error = reported.append + + logic.sound(GeneratorName.PULSE, SEMITONE_G) + + assert reported == [refusal] diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index c020870ab..f4445e35e 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -1,7 +1,8 @@ from dataclasses import dataclass -from typing import Dict, Final, List, cast +from typing import Dict, Final, List, Tuple, cast from unittest.mock import MagicMock +import dearpygui.dearpygui as dpg import numpy as np import pytest @@ -32,13 +33,16 @@ ) from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.reconstruction.instruments import ( + InstrumentViewModel, ReconstructionInstrumentsViewModel, ) from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.formats.famitracker.specification.sequences import ( @@ -60,6 +64,13 @@ footprint=None, ) +ONE_INSTRUMENT: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + playing_channels=frozenset((ChannelName.PULSE1,)), + footprint=SampleFootprintViewModel.from_instrument(LARGEST_PULSE), + instrument=InstrumentViewModel(name="lead"), +) + def sequence(item_count: int) -> Envelope[int]: """A dimension of a given length, which is all the length warning reads of it.""" @@ -129,7 +140,17 @@ def configure(tag: str, **kwargs: object) -> None: @pytest.fixture -def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel: +def key_router() -> MagicMock: + router = MagicMock(spec=KeyRouter) + router.is_field_focused = False + return router + + +@pytest.fixture +def panel( + layout_config: LayoutConfig, + key_router: MagicMock, +) -> GUIReconstructionInstrumentsPanel: return GUIReconstructionInstrumentsPanel( pitch_stepper_style=PitchStepperStyle.from_general(layout_config.general), copy_width=layout_config.general.buttons.copy_width, @@ -137,6 +158,8 @@ def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel: layout_graphs=layout_config.graphs, language_manager=LanguageManager(LANG_EN), status_bar=MagicMock(), + key_router=key_router, + tab_active=lambda: True, ) @@ -497,3 +520,106 @@ def test_no_reconstruction_states_no_figures( ) -> None: panel.update_view(NOT_LOADED) assert written == {} + + +class TestTheAuditionSelector: + """The generator a hand-written voice is heard on stands where the pitch stepper does.""" + + def test_an_open_instrument_offers_the_generator_to_hear_it_on( + self, + panel: GUIReconstructionInstrumentsPanel, + shown: Dict[str, bool], + ) -> None: + panel.update_view(ONE_INSTRUMENT) + assert shown[panel.audition_group_tag] is True + + def test_a_loaded_reconstruction_offers_the_pitch_it_was_measured_against_instead( + self, + panel: GUIReconstructionInstrumentsPanel, + shown: Dict[str, bool], + ) -> None: + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) + assert shown[panel.audition_group_tag] is False + + def test_the_pulse_is_the_generator_a_voice_is_first_heard_on( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + assert panel._audition_generator is GeneratorName.PULSE + + def test_choosing_a_generator_by_its_name_is_what_the_keys_then_sound( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + panel._on_audition_generator_changed( + "sender", + panel._generator_labels[GeneratorName.NOISE], + ) + assert panel._audition_generator is GeneratorName.NOISE + + +class TestTheNoteKeys: + """A note key sounds the instrument in front of the panel, and claims the press it used.""" + + def test_a_note_key_sounds_the_open_instrument_on_the_chosen_generator( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + sounded: List[Tuple[GeneratorName, int]] = [] + panel.on_audition_requested = lambda generator, semitone: sounded.append((generator, semitone)) + panel.update_view(ONE_INSTRUMENT) + + assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Z, modifiers=frozenset())) is True + assert sounded == [(GeneratorName.PULSE, PIANO_KEYS[dpg.mvKey_Z])] + + def test_a_key_naming_no_note_is_left_to_the_shortcuts( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + sounded: List[Tuple[GeneratorName, int]] = [] + panel.on_audition_requested = lambda generator, semitone: sounded.append((generator, semitone)) + panel.update_view(ONE_INSTRUMENT) + + assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Spacebar, modifiers=frozenset())) is False + assert sounded == [] + + def test_the_keys_answer_while_an_instrument_is_open( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + panel.update_view(ONE_INSTRUMENT) + assert panel._audition_keys_active() is True + + def test_a_loaded_reconstruction_keeps_the_keys_out_of_the_panel( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) + assert panel._audition_keys_active() is False + + def test_a_field_being_typed_into_keeps_its_own_characters( + self, + panel: GUIReconstructionInstrumentsPanel, + key_router: MagicMock, + ) -> None: + panel.update_view(ONE_INSTRUMENT) + key_router.is_field_focused = True + assert panel._audition_keys_active() is False + + def test_another_tab_in_front_keeps_the_keys_from_the_panel( + self, + layout_config: LayoutConfig, + key_router: MagicMock, + ) -> None: + panel = GUIReconstructionInstrumentsPanel( + pitch_stepper_style=PitchStepperStyle.from_general(layout_config.general), + copy_width=layout_config.general.buttons.copy_width, + feature_colors=layout_config.general.colors.features, + layout_graphs=layout_config.graphs, + language_manager=LanguageManager(LANG_EN), + status_bar=MagicMock(), + key_router=key_router, + tab_active=lambda: False, + ) + panel.update_view(ONE_INSTRUMENT) + assert panel._audition_keys_active() is False diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 9ae125215..71482464f 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -10,6 +10,7 @@ CHANNEL_GENERATOR_KIND, FEATURE_DIMENSION_ORDER, feature_range, + generator_channel, supported_features, supports, ) @@ -66,3 +67,14 @@ def test_feature_dimension_order_matches_famitracker_sequence_slots() -> None: SequenceKind.DUTY, ] assert [FEATURE_KEY_TO_SEQUENCE_KIND[key] for key in FEATURE_DIMENSION_ORDER] == expected + + +def test_a_generator_is_heard_on_the_first_channel_it_drives() -> None: + assert generator_channel(GeneratorName.PULSE) is ChannelName.PULSE1 + assert generator_channel(GeneratorName.TRIANGLE) is ChannelName.TRIANGLE + assert generator_channel(GeneratorName.NOISE) is ChannelName.NOISE + + +def test_every_generator_names_a_channel_that_reads_it_back() -> None: + for generator_name in GeneratorName: + assert CHANNEL_GENERATOR_KIND[generator_channel(generator_name)] is generator_name diff --git a/tests/unit/sampletones_core/performance/test_audition.py b/tests/unit/sampletones_core/performance/test_audition.py new file mode 100644 index 000000000..488ce5111 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_audition.py @@ -0,0 +1,132 @@ +from typing import Final + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.performance.audition import audition_audio, audition_instructions +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument + +REFERENCE_PITCH: Final[int] = 60 +REFERENCE_PERIOD: Final[int] = 8 +TYPED_PITCH: Final[int] = 67 +NES_FREQUENCY: Final[int] = 60 +SAMPLE_RATE: Final[int] = 44100 + + +def _instrument() -> Instrument: + """A voice with a value in every dimension, so each one shows in the frames it makes.""" + return Instrument( + name="lead", + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=(15, 12, 9)), + arpeggio=Envelope[int](items=(0, 4, 7)), + duty_cycle=Envelope[int](items=(2,)), + ), + initial_pitch=REFERENCE_PITCH, + initial_period=REFERENCE_PERIOD, + ) + + +def _config() -> Config: + return Config().with_library( + nes_frequency=NES_FREQUENCY, + sample_rate=SAMPLE_RATE, + ) + + +class TestTheNoteAnAuditionSounds: + def test_the_reference_cancels_so_a_voice_sounds_at_the_note_asked_for(self) -> None: + instructions = audition_instructions( + _instrument(), + ChannelName.PULSE1, + pitch=TYPED_PITCH, + ) + + assert [instruction.pitch for instruction in instructions] == [ + TYPED_PITCH, + TYPED_PITCH + 4, + TYPED_PITCH + 7, + ] + + def test_sounding_at_its_own_reference_leaves_the_frames_as_they_stand(self) -> None: + instrument = _instrument() + instructions = audition_instructions( + instrument, + ChannelName.PULSE1, + pitch=REFERENCE_PITCH, + ) + + assert instructions == instrument.instructions(ChannelName.PULSE1) + + def test_a_voice_sounds_at_full_volume_however_loud_its_envelope_is(self) -> None: + instructions = audition_instructions( + _instrument(), + ChannelName.PULSE1, + pitch=TYPED_PITCH, + ) + + assert [instruction.volume for instruction in instructions] == [15, 12, 9] + + def test_the_noise_channel_walks_the_periods_its_arpeggio_names(self) -> None: + instructions = audition_instructions( + _instrument(), + ChannelName.NOISE, + pitch=REFERENCE_PERIOD, + ) + + assert [instruction.period for instruction in instructions] == [8, 12, 15] + + def test_a_channel_reads_the_dimensions_its_generator_offers(self) -> None: + instructions = audition_instructions( + _instrument(), + ChannelName.TRIANGLE, + pitch=TYPED_PITCH, + ) + + assert all(instruction.on for instruction in instructions) + + +class TestTheAudioAnAuditionPlays: + @pytest.mark.parametrize( + "channel_name", + list(ChannelName.items()), + ids=[channel_name.value for channel_name in ChannelName.items()], + ) + def test_a_voice_renders_one_frame_per_tick_of_its_envelopes( + self, + channel_name: ChannelName, + ) -> None: + config = _config() + audio = audition_audio( + _instrument(), + channel_name, + config, + pitch=TYPED_PITCH, + ) + + assert audio is not None + assert audio.shape == (3 * config.frame_length,) + + def test_a_voice_writing_no_envelope_sounds_nothing(self) -> None: + assert ( + audition_audio( + Instrument(name="silent"), + ChannelName.PULSE1, + _config(), + pitch=TYPED_PITCH, + ) + is None + ) + + def test_two_notes_of_one_voice_render_to_different_audio(self) -> None: + config = _config() + instrument = _instrument() + low = audition_audio(instrument, ChannelName.PULSE1, config, pitch=REFERENCE_PITCH) + high = audition_audio(instrument, ChannelName.PULSE1, config, pitch=TYPED_PITCH) + + assert low is not None and high is not None + assert not np.array_equal(low, high) From de980af9cd0c8981679699dca906979e2c417be9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 19:51:24 +0200 Subject: [PATCH 119/142] Drew: a hand-written voice on the waveform card --- .../coordinators/tabs/reconstruction.py | 6 + .../coordinators/tabs/sequencer.py | 2 + .../layout/general/colors/channel.py | 30 ++++ .../layout/general/colors/colors.py | 2 + .../layout/tabs/sequencer/colors/channel.py | 17 --- .../layout/tabs/sequencer/colors/colors.py | 2 - .../logic/reconstruction/audition.py | 65 ++++++-- .../logic/reconstruction/instruments.py | 9 +- .../parameters/reconstruction.py | 3 + .../parameters/sequencer.py | 3 + .../ui/elements/graphs/waveform.py | 29 ++++ .../reconstruction/instruments/instruments.py | 13 +- .../ui/panels/reconstruction/plot.py | 35 +++++ .../ui/panels/sequencer/columns.py | 14 -- .../ui/panels/sequencer/order.py | 6 +- .../ui/panels/sequencer/tracker.py | 6 +- .../view_model/reconstruction/waveform.py | 25 +++ .../layout/general/colors.yaml | 5 + .../layout/tabs/sequencer/colors.yaml | 5 - .../logic/reconstruction/test_audition.py | 143 ++++++++++++++++-- .../reconstruction/test_instruments_panel.py | 32 ++-- .../ui/panels/reconstruction/test_plot.py | 119 +++++++++++++++ .../panels/sequencer/test_order_channels.py | 4 +- .../panels/sequencer/test_tracker_channels.py | 4 +- 24 files changed, 487 insertions(+), 92 deletions(-) create mode 100644 src/sampletones_application/layout/general/colors/channel.py delete mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/channel.py create mode 100644 src/sampletones_application/view_model/reconstruction/waveform.py diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index db3b4c152..412df97bf 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -217,6 +217,7 @@ def __init__( ) self._reconstruction_plot_panel: GUIReconstructionPlotPanel = GUIReconstructionPlotPanel( layout_graphs=layout.graphs, + channel_colors=layout.channel_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT), language_manager=language_manager, status_bar=status_bar, @@ -310,7 +311,12 @@ def __init__( self._reconstruction_instruments_logic.handle_envelope_changed ) self._reconstruction_instruments_panel.on_audition_requested = self._instrument_audition_logic.sound + self._reconstruction_instruments_panel.on_audition_generator_changed = ( + self._instrument_audition_logic.set_generator + ) self._instrument_audition_logic.on_audition_error = self._on_preview_error + self._instrument_audition_logic.on_waveform_changed = self._reconstruction_plot_panel.update_instrument_view + self._reconstruction_instruments_logic.on_display_refreshed = self._instrument_audition_logic.refresh def _on_export_result(self, result: ExportResult) -> None: """Reports a finished export in the words of the artefact it produced. diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 0d37d6693..74229524f 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -268,6 +268,7 @@ def __init__( self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( self._sequencer_tracker_logic.settings, layout=layout.sequencer, + channel_colors=layout.channel_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL), initial_octave=session_manager.octave, language_manager=language_manager, @@ -285,6 +286,7 @@ def __init__( ) self._sequencer_order_panel: GUISequencerOrderPanel = GUISequencerOrderPanel( layout=layout.sequencer, + channel_colors=layout.channel_colors, plus_minus_layout=layout.plus_minus, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD), language_manager=language_manager, diff --git a/src/sampletones_application/layout/general/colors/channel.py b/src/sampletones_application/layout/general/colors/channel.py new file mode 100644 index 000000000..9da960cb9 --- /dev/null +++ b/src/sampletones_application/layout/general/colors/channel.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel + +from sampletones_application.utils.palette.colors.written import WrittenColor +from sampletones_core.constants.enums import ChannelName + + +class ChannelColors(BaseModel, extra="forbid", frozen=True): + """The per-channel palette shared by every view that names a channel. + + The order table paints each channel's row label in its color, the tracker grid tints each + channel's column background with the same color at a low alpha, and the waveform plots draw a + generator's line in it, so a channel keeps one identity across the application. + """ + + pulse1: WrittenColor + pulse2: WrittenColor + triangle: WrittenColor + noise: WrittenColor + + def for_channel(self, channel_name: ChannelName) -> WrittenColor: + """The color this channel is known by.""" + match channel_name: + case ChannelName.PULSE1: + return self.pulse1 + case ChannelName.PULSE2: + return self.pulse2 + case ChannelName.TRIANGLE: + return self.triangle + case ChannelName.NOISE: + return self.noise diff --git a/src/sampletones_application/layout/general/colors/colors.py b/src/sampletones_application/layout/general/colors/colors.py index 41208d3a7..83e904df4 100644 --- a/src/sampletones_application/layout/general/colors/colors.py +++ b/src/sampletones_application/layout/general/colors/colors.py @@ -1,5 +1,6 @@ from pydantic import BaseModel +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.general.colors.favorite import FavoriteColors from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.general.colors.header import HeaderColors @@ -15,3 +16,4 @@ class GeneralColors(BaseModel, extra="forbid", frozen=True): paths: PathColors headers: HeaderColors features: FeatureColors + channels: ChannelColors diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py b/src/sampletones_application/layout/tabs/sequencer/colors/channel.py deleted file mode 100644 index 1a5df62d9..000000000 --- a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel - -from sampletones_application.utils.palette.colors.written import WrittenColor - - -class ChannelColors(BaseModel, extra="forbid", frozen=True): - """Per-channel identity colors shared by the order table and the tracker grid. - - The order table paints each channel's row label in its color; the tracker grid - tints each channel's column background with the same color at a low alpha, so a - channel keeps one identity across both views. - """ - - pulse1: WrittenColor - pulse2: WrittenColor - triangle: WrittenColor - noise: WrittenColor diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py index ba05e75e8..39b4273bb 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py @@ -1,6 +1,5 @@ from pydantic import BaseModel -from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors from sampletones_application.layout.tabs.sequencer.colors.header import HeaderColors from sampletones_application.layout.tabs.sequencer.colors.history import HistoryColors from sampletones_application.layout.tabs.sequencer.colors.muted import MutedColors @@ -24,4 +23,3 @@ class SequencerColors(BaseModel, extra="forbid", frozen=True): muted: MutedColors history: HistoryColors text: TrackerColors - channels: ChannelColors diff --git a/src/sampletones_application/logic/reconstruction/audition.py b/src/sampletones_application/logic/reconstruction/audition.py index 970d44e72..6bc9e7d7f 100644 --- a/src/sampletones_application/logic/reconstruction/audition.py +++ b/src/sampletones_application/logic/reconstruction/audition.py @@ -3,11 +3,15 @@ import numpy as np from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.instruments import AUDITION_GENERATOR from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.editing import ( InstrumentAuditionProtocol, ) from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.view_model.reconstruction.waveform import ( + InstrumentWaveformViewModel, +) from sampletones_core.audio import AudioDeviceManager from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, GeneratorName @@ -21,12 +25,13 @@ class InstrumentAuditionLogic(CallbackMixin): - """Sounds the instrument the Reconstructions tab is showing, at the note a key names. + """Sounds and draws the instrument the Reconstructions tab is showing, on one generator. - An instrument stands on no recording, so hearing one means playing it: the tab states which - generator to sound it on and a piano key states the note, and the two together name the frames - the voice makes. The audition plays at preview priority, so it yields to playback the reader - asked for and answers Stop the way every other preview does. + An instrument stands on no recording, so hearing one means playing it and seeing one means + rendering it. Both read the same choice — the generator the reader is auditioning it as — so + the choice is held here and what the plot card draws is what the note keys sound. The audition + plays at preview priority, so it yields to playback the reader asked for and answers Stop the + way every other preview does. """ def __init__( @@ -40,21 +45,35 @@ def __init__( self._controller = project_controller self._session_manager = session_manager self._audio_device_manager = audio_device_manager + self._generator_name: GeneratorName = AUDITION_GENERATOR self.on_audition_error: Optional[Callable[[Exception], None]] = None + self.on_waveform_changed: Optional[Callable[[Optional[InstrumentWaveformViewModel]], None]] = None + + def set_generator(self, generator_name: GeneratorName) -> None: + """Takes the generator the voice is auditioned as, redrawing it as the one now chosen.""" + self._generator_name = generator_name + self.refresh() + + def refresh(self) -> None: + """States the waveform of whatever the tab has in front of it, which an instrument alone has. + + A recording draws the audio it was made from, so the card is left to it; an instrument is + redrawn whenever its envelopes change, which is what keeps the picture answering the edit. + """ + self.call(self.on_waveform_changed, self._waveform()) - def sound(self, generator_name: GeneratorName, semitone: int) -> None: - """Sounds the instrument in front of the tab on one generator, at one key of the keyboard. + def sound(self, semitone: int) -> None: + """Sounds the instrument in front of the tab at one key of the keyboard's two octaves. Args: - generator_name: The generator the voice is heard on. semitone: How far the key pressed stands above the C of the octave in force. """ instrument = self._editor.instrument if instrument is None: return - channel_name = generator_channel(generator_name) + channel_name = generator_channel(self._generator_name) audio = audition_audio( instrument, channel_name, @@ -66,6 +85,34 @@ def sound(self, generator_name: GeneratorName, semitone: int) -> None: self._play(audio, instrument.id) + def _waveform(self) -> Optional[InstrumentWaveformViewModel]: + """The audio the open instrument makes at the pitch it stands at, drawn as it sounds. + + The plot shows the voice as it is rather than at a note just pressed, so it is rendered at + the pitch the instrument itself is measured against. + """ + instrument = self._editor.instrument + if instrument is None: + return None + + channel_name = generator_channel(self._generator_name) + config = self._audition_config() + audio = audition_audio( + instrument, + channel_name, + config, + pitch=instrument.reference(channel_name), + ) + if audio is None: + return None + + return InstrumentWaveformViewModel( + name=instrument.name, + channel_name=channel_name, + audio=audio, + frame_length=config.frame_length, + ) + def _sounding_pitch( self, instrument: Instrument, diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 6df9867b6..860b5f918 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -22,6 +22,7 @@ from sampletones_core.exporters import Features, playing_channels from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import features_footprint +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin OnReconstructionInstrumentUpdatedCallback = Callable[ @@ -45,11 +46,17 @@ def __init__( self.on_view_changed: Optional[Callable[[ReconstructionInstrumentsViewModel], None]] = None self.on_feature_data_changed: Optional[Callable[[Optional[Dict[ChannelName, Features]]], None]] = None self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None + self.on_display_refreshed: Optional[VoidCallback] = None def update_display(self) -> None: - """Renders whatever the panel has in front of it, envelopes and figures together.""" + """Renders whatever the panel has in front of it, envelopes and figures together. + + The cards beside the panel describe the same voice, so the render is reported once it has + been made and they settle on it: an edit to an instrument redraws its waveform here. + """ self.call(self.on_view_changed, self._build_view_model(self._current_generators())) self.call(self.on_feature_data_changed, self._displayed_features()) + self.call(self.on_display_refreshed) def _displayed_features(self) -> Optional[Dict[ChannelName, Features]]: """The envelopes the panel draws: a reconstruction's channels, or an instrument's own set. diff --git a/src/sampletones_application/parameters/reconstruction.py b/src/sampletones_application/parameters/reconstruction.py index b4404ff26..573d844a1 100644 --- a/src/sampletones_application/parameters/reconstruction.py +++ b/src/sampletones_application/parameters/reconstruction.py @@ -4,6 +4,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.general.stems import StemsListLayout @@ -30,6 +31,7 @@ class ReconstructionTabParameters: pitch_stepper_style: PitchStepperStyle copy_width: int feature_colors: FeatureColors + channel_colors: ChannelColors path_colors: PathColors path_status_color: BaseColor tree_colors: TreeColors @@ -47,6 +49,7 @@ def from_config(cls, config: LayoutConfig) -> ReconstructionTabParameters: pitch_stepper_style=PitchStepperStyle.from_general(general), copy_width=general.buttons.copy_width, feature_colors=general.colors.features, + channel_colors=general.colors.channels, path_colors=general.colors.paths, path_status_color=general.colors.text.disabled, tree_colors=TreeColors.create( diff --git a/src/sampletones_application/parameters/sequencer.py b/src/sampletones_application/parameters/sequencer.py index d071ee3c6..cfe762eeb 100644 --- a/src/sampletones_application/parameters/sequencer.py +++ b/src/sampletones_application/parameters/sequencer.py @@ -4,6 +4,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.general.inputs import InputsLayout from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout @@ -33,6 +34,7 @@ class SequencerTabParameters: inputs: InputsLayout plus_minus: PlusMinusButtonsLayout feature_colors: FeatureColors + channel_colors: ChannelColors tree_colors: TreeColors muted_color: BaseColor scheduling: SchedulingBehavior @@ -50,6 +52,7 @@ def from_config(cls, config: LayoutConfig) -> SequencerTabParameters: inputs=general.inputs, plus_minus=general.plus_minus_buttons, feature_colors=general.colors.features, + channel_colors=general.colors.channels, tree_colors=TreeColors.create( general.colors, accent=general.colors.headers.reconstruction, diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index bf7d047b0..05044359a 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -188,6 +188,35 @@ def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> No self._update_axes_limits() self._update_position_indicator() + def load_voice_waveform( + self, + audio: np.ndarray, + *, + name: str, + color: BaseColor, + ) -> None: + """Draws one voice's own audio as a single series, in the color its generator is named by. + + A hand-written voice stands on no recording, so there is nothing to hold it against: the + card shows what its envelopes make, labeled with the voice's own name. The series is the + whole of what is drawn, so the controls that read a recording apply to nothing here. + + Args: + audio: The waveform to draw. + name: The name the series is labeled by. + color: The color the line is drawn in. + """ + self._reconstruction_dimmed = False + self.clear_layers() + self.add_layer( + ArrayLayer( + data=audio, + name=name, + color=color, + max_display_points=self._layout.waveform.max_display_points, + ) + ) + def _extract_reconstruction_layer_data( self, waveform_data: WaveformData, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index d2c52c25c..437b2ae1e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -112,7 +112,7 @@ from sampletones_shared.utils.arrays import clamp OnInstrumentExportCallback = Callable[[ChannelName], None] -OnAuditionCallback = Callable[[GeneratorName, int], None] +OnAuditionCallback = Callable[[int], None] OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None] @@ -154,7 +154,6 @@ def __init__( self._graphs: Dict[str, GUIBarGraph] = {} self._sequences: Dict[Tuple[ChannelName, FeatureKey], Envelope[int]] = {} - self._audition_generator: GeneratorName = AUDITION_GENERATOR self._audition_open: bool = False self._pitch_stepper_style = pitch_stepper_style self._copy_width = copy_width @@ -173,6 +172,7 @@ def __init__( self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None self.on_audition_requested: Optional[OnAuditionCallback] = None + self.on_audition_generator_changed: Optional[Callable[[GeneratorName], None]] = None self.on_envelope_changed: Optional[Callable[[ChannelName, FeatureKey, Envelope[int]], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] @@ -547,7 +547,7 @@ def _create_audition_selector(self, window_tag: str) -> None: dpg.add_radio_button( items=[self._generator_labels[generator_name] for generator_name in GeneratorName], tag=self.audition_tag, - default_value=self._generator_labels[self._audition_generator], + default_value=self._generator_labels[AUDITION_GENERATOR], callback=self._on_audition_generator_changed, horizontal=True, ) @@ -564,8 +564,9 @@ def _create_audition_selector(self, window_tag: str) -> None: ) def _on_audition_generator_changed(self, _sender: Sender, app_data: str) -> None: - self._audition_generator = next( - generator_name for generator_name, label in self._generator_labels.items() if label == app_data + self.call( + self.on_audition_generator_changed, + next(generator_name for generator_name, label in self._generator_labels.items() if label == app_data), ) def _show_audition_selector(self, *, shown: bool) -> None: @@ -588,7 +589,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if semitone is None: return False - self.call(self.on_audition_requested, self._audition_generator, semitone) + self.call(self.on_audition_requested, semitone) return True def _apply_playing_state( diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index b7eab2ddf..3d667d71f 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -4,6 +4,7 @@ from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.reconstructions import ( @@ -25,6 +26,9 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.waveform import ( + InstrumentWaveformViewModel, +) from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_shared.types.application import Sender @@ -36,11 +40,13 @@ def __init__( self, *, layout_graphs: GraphsLayout, + channel_colors: ChannelColors, language_manager: LanguageManager, status_bar: GUIStatusBar, initial_collapsed: bool = False, ) -> None: self._layout_graphs = layout_graphs + self._channel_colors = channel_colors self._status_bar = status_bar self._language_manager = language_manager @@ -95,6 +101,35 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: else: dpg.bind_item_theme(tag, 0) + def update_instrument_view( + self, + waveform: Optional[InstrumentWaveformViewModel], + ) -> None: + """Draws a hand-written voice's own audio, or hands the card back to the recording it shows. + + An instrument writes one line and nothing to hold it against, so the controls that read a + recording — the autoscale switch and the per-channel boxes — stand down while one is open + and return with the recording that reads them. + + Args: + waveform: The voice to draw, or ``None`` while the tab holds a recording or nothing. + """ + self._show_recording_controls(shown=waveform is None) + if waveform is None: + return + + self._frame_length = waveform.frame_length + self.waveform_display.load_voice_waveform( + waveform.audio, + name=waveform.name, + color=self._channel_colors.for_channel(waveform.channel_name), + ) + + def _show_recording_controls(self, *, shown: bool) -> None: + """Offers the switches that read a recording, which is what they have to describe.""" + dpg_configure_item(self.autoscale_tag, show=shown) + dpg_configure_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, show=shown) + def load_waveform_data( self, waveform_data: WaveformData, diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 11e403fed..feddc79db 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -1,7 +1,5 @@ from typing import Final, Optional -from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors -from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.constants.enums import ChannelName _LEADING_TABLE_COLUMNS: Final[int] = 2 @@ -15,18 +13,6 @@ HEADER_TABLE_ROWS: Final[int] = HEADER_TABLE_ROW + 1 -def channel_color(colors: ChannelColors, channel: ChannelName) -> BaseColor: - match channel: - case ChannelName.PULSE1: - return colors.pulse1 - case ChannelName.PULSE2: - return colors.pulse2 - case ChannelName.TRIANGLE: - return colors.triangle - case ChannelName.NOISE: - return colors.noise - - def tracker_table_column(channel: Optional[ChannelName]) -> int: """Maps a logical column to its DPG table column index. diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 6c2deee57..e57dddcd8 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -6,6 +6,7 @@ from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.general.plus_minus_buttons import ( PlusMinusButtonsLayout, ) @@ -43,7 +44,6 @@ ChannelSwitch, channel_tooltip, ) -from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.display import cell_title from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( @@ -141,6 +141,7 @@ def __init__( self, *, layout: SequencerLayout, + channel_colors: ChannelColors, plus_minus_layout: PlusMinusButtonsLayout, language_manager: LanguageManager, key_router: KeyRouter, @@ -149,6 +150,7 @@ def __init__( initial_collapsed: bool = False, ) -> None: self._layout = layout + self._channel_colors = channel_colors self._plus_minus_layout = plus_minus_layout self._router = key_router self._tab_active = tab_active @@ -647,7 +649,7 @@ def _channel_row_tint(self, channel: ChannelName) -> ColorRGBA: if self._is_muted(channel): return self._layout.colors.muted.background.rgba - tint_color = channel_color(self._layout.colors.channels, channel) + tint_color = self._channel_colors.for_channel(channel) return FadedColor( color=tint_color, fraction=self._layout.tracker.channel_column_tint, diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index bf87ae574..e1a6a2f28 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -12,6 +12,7 @@ MAX_OCTAVE, MIN_OCTAVE, ) +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( @@ -49,7 +50,6 @@ HEADER_TABLE_ROWS, SAMPLE_TABLE_COLUMN, TRACKER_TABLE_COLUMNS, - channel_color, tracker_table_column, tracker_table_row, ) @@ -230,6 +230,7 @@ def __init__( initial_settings: SequencerSettingsViewModel, *, layout: SequencerLayout, + channel_colors: ChannelColors, language_manager: LanguageManager, key_router: KeyRouter, tab_active: ActivePredicate, @@ -238,6 +239,7 @@ def __init__( initial_collapsed: bool = False, ) -> None: self._layout = layout + self._channel_colors = channel_colors self._octave = initial_octave self._settings = initial_settings self._language_manager = language_manager @@ -795,7 +797,7 @@ def _channel_column_tint(self, channel: ChannelName) -> ColorRGBA: if self._is_muted(channel): return self._layout.colors.muted.background.rgba - tint_color = channel_color(self._layout.colors.channels, channel) + tint_color = self._channel_colors.for_channel(channel) return FadedColor( color=tint_color, fraction=self._layout.tracker.channel_column_tint, diff --git a/src/sampletones_application/view_model/reconstruction/waveform.py b/src/sampletones_application/view_model/reconstruction/waveform.py new file mode 100644 index 000000000..2dabc18ee --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/waveform.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +import numpy as np + +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class InstrumentWaveformViewModel: + """What a hand-written voice sounds like, as the plot card draws it. + + An instrument stands on no recording, so the card shows the audio its envelopes make on the + generator chosen to hear it: one line under the voice's own name, in that generator's color. + + Attributes: + name: The name the series is labeled by, which is the voice's own. + channel_name: The channel the audio was rendered on, naming the color it is drawn in. + audio: The waveform, one frame per tick the envelopes describe. + frame_length: The samples one frame spans, which the per-frame overlay reads. + """ + + name: str + channel_name: ChannelName + audio: np.ndarray + frame_length: int diff --git a/src/sampletones_config/layout/general/colors.yaml b/src/sampletones_config/layout/general/colors.yaml index afb8048a2..bc47a016c 100644 --- a/src/sampletones_config/layout/general/colors.yaml +++ b/src/sampletones_config/layout/general/colors.yaml @@ -20,3 +20,8 @@ features: arpeggio: .feature_arpeggio pitch: .feature_pitch duty_cycle: .feature_duty_cycle +channels: + pulse1: .channel_pulse1 + pulse2: .channel_pulse2 + triangle: .channel_triangle + noise: .channel_noise diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index 02ff04055..c2fd30026 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -37,8 +37,3 @@ text: frame: .tracker_frame row: .tracker_row order: .tracker_order -channels: - pulse1: .channel_pulse1 - pulse2: .channel_pulse2 - triangle: .channel_triangle - noise: .channel_noise diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_audition.py b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py index 0d0bf473e..949cf2196 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_audition.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py @@ -5,10 +5,14 @@ import pytest from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.instruments import AUDITION_GENERATOR from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.reconstruction.audition import InstrumentAuditionLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.view_model.reconstruction.waveform import ( + InstrumentWaveformViewModel, +) from sampletones_core.audio import AudioDeviceManager from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, GeneratorName @@ -75,6 +79,20 @@ def _logic( return InstrumentAuditionLogic(_Editor(instrument), controller, session, device) +def _sounded( + instrument: Optional[Instrument], + controller: ProjectController, + session: MagicMock, + device: MagicMock, + generator_name: GeneratorName, + semitone: int, +) -> None: + """Sounds one key of a voice on one generator, which is a choice and then a press.""" + logic = _logic(instrument, controller, session, device) + logic.set_generator(generator_name) + logic.sound(semitone) + + def _played(device: MagicMock) -> np.ndarray: audio = device.play.call_args.args[0] assert isinstance(audio, np.ndarray) @@ -106,7 +124,7 @@ def test_a_key_sounds_the_voice_at_the_note_it_names( ) -> None: instrument = _instrument() - _logic(instrument, controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + _sounded(instrument, controller, session, device, GeneratorName.PULSE, SEMITONE_G) expected = _expected(instrument, controller, ChannelName.PULSE1, MIDDLE_C + SEMITONE_G) assert np.array_equal(_played(device), expected) @@ -120,7 +138,7 @@ def test_the_octave_in_force_moves_the_note_the_same_key_sounds( instrument = _instrument() session.octave = OCTAVE + 1 - _logic(instrument, controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + _sounded(instrument, controller, session, device, GeneratorName.PULSE, SEMITONE_G) expected = _expected(instrument, controller, ChannelName.PULSE1, MIDDLE_C + 12 + SEMITONE_G) assert np.array_equal(_played(device), expected) @@ -133,7 +151,7 @@ def test_the_generator_chosen_is_the_channel_the_voice_is_heard_on( ) -> None: instrument = _instrument() - _logic(instrument, controller, session, device).sound(GeneratorName.TRIANGLE, SEMITONE_G) + _sounded(instrument, controller, session, device, GeneratorName.TRIANGLE, SEMITONE_G) expected = _expected(instrument, controller, ChannelName.TRIANGLE, MIDDLE_C + SEMITONE_G) assert np.array_equal(_played(device), expected) @@ -147,10 +165,11 @@ def test_the_noise_generator_sounds_at_the_period_the_voice_states( """The noise channel selects one of sixteen periods, so a key there names no note.""" instrument = _instrument() logic = _logic(instrument, controller, session, device) + logic.set_generator(GeneratorName.NOISE) - logic.sound(GeneratorName.NOISE, SEMITONE_G) + logic.sound(SEMITONE_G) first = _played(device) - logic.sound(GeneratorName.NOISE, 0) + logic.sound(0) second = _played(device) expected = _expected(instrument, controller, ChannelName.NOISE, REFERENCE_PERIOD) @@ -163,7 +182,7 @@ def test_an_audition_yields_to_the_playback_a_reader_asked_for( session: MagicMock, device: MagicMock, ) -> None: - _logic(_instrument(), controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + _sounded(_instrument(), controller, session, device, GeneratorName.PULSE, SEMITONE_G) assert device.play.call_args.kwargs["priority"] is PlaybackPriority.PREVIEW @@ -175,7 +194,7 @@ def test_a_tab_holding_no_instrument_plays_nothing( session: MagicMock, device: MagicMock, ) -> None: - _logic(None, controller, session, device).sound(GeneratorName.PULSE, SEMITONE_G) + _sounded(None, controller, session, device, GeneratorName.PULSE, SEMITONE_G) device.play.assert_not_called() @@ -185,10 +204,7 @@ def test_a_voice_writing_no_envelope_plays_nothing( session: MagicMock, device: MagicMock, ) -> None: - _logic(Instrument(name="silent"), controller, session, device).sound( - GeneratorName.PULSE, - SEMITONE_G, - ) + _sounded(Instrument(name="silent"), controller, session, device, GeneratorName.PULSE, SEMITONE_G) device.play.assert_not_called() @@ -204,6 +220,109 @@ def test_a_device_refusing_the_audition_reports_what_it_refused_with( reported: List[Exception] = [] logic.on_audition_error = reported.append - logic.sound(GeneratorName.PULSE, SEMITONE_G) + logic.sound(SEMITONE_G) assert reported == [refusal] + + +class TestTheWaveformTheCardDraws: + def test_a_voice_is_first_heard_and_drawn_on_the_pulse( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + assert AUDITION_GENERATOR is GeneratorName.PULSE + + logic = _logic(_instrument(), controller, session, device) + drawn: List[Optional[InstrumentWaveformViewModel]] = [] + logic.on_waveform_changed = drawn.append + + logic.refresh() + + assert drawn[-1] is not None + assert drawn[-1].channel_name is ChannelName.PULSE1 + + def test_the_open_voice_is_drawn_at_the_pitch_it_stands_at( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + """The card shows the voice as it is, so no key just pressed moves what it draws.""" + instrument = _instrument() + logic = _logic(instrument, controller, session, device) + drawn: List[Optional[InstrumentWaveformViewModel]] = [] + logic.on_waveform_changed = drawn.append + + logic.sound(SEMITONE_G) + logic.refresh() + + expected = _expected(instrument, controller, ChannelName.PULSE1, REFERENCE_PITCH) + assert drawn[-1] is not None + assert np.array_equal(drawn[-1].audio, expected) + + def test_choosing_a_generator_redraws_the_voice_as_that_one( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + logic = _logic(_instrument(), controller, session, device) + drawn: List[Optional[InstrumentWaveformViewModel]] = [] + logic.on_waveform_changed = drawn.append + + logic.set_generator(GeneratorName.NOISE) + + assert drawn[-1] is not None + assert drawn[-1].channel_name is ChannelName.NOISE + + def test_the_voice_is_drawn_under_its_own_name_and_frame_length( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + logic = _logic(_instrument(), controller, session, device) + drawn: List[Optional[InstrumentWaveformViewModel]] = [] + logic.on_waveform_changed = drawn.append + + logic.refresh() + + settings = controller.project.settings + config = Config().with_library( + nes_frequency=settings.nes_frequency, + sample_rate=settings.sample_rate, + ) + assert drawn[-1] is not None + assert drawn[-1].name == "lead" + assert drawn[-1].frame_length == config.frame_length + assert drawn[-1].audio.shape == (2 * config.frame_length,) + + def test_a_tab_holding_a_recording_leaves_the_card_to_it( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + logic = _logic(None, controller, session, device) + drawn: List[Optional[InstrumentWaveformViewModel]] = [] + logic.on_waveform_changed = drawn.append + + logic.refresh() + + assert drawn == [None] + + def test_a_voice_writing_no_envelope_draws_nothing( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + logic = _logic(Instrument(name="silent"), controller, session, device) + drawn: List[Optional[InstrumentWaveformViewModel]] = [] + logic.on_waveform_changed = drawn.append + + logic.refresh() + + assert drawn == [None] diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index f4445e35e..765c7d31f 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Final, List, Tuple, cast +from typing import Dict, Final, List, cast from unittest.mock import MagicMock import dearpygui.dearpygui as dpg @@ -541,43 +541,39 @@ def test_a_loaded_reconstruction_offers_the_pitch_it_was_measured_against_instea panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE})) assert shown[panel.audition_group_tag] is False - def test_the_pulse_is_the_generator_a_voice_is_first_heard_on( + def test_choosing_a_generator_reports_the_one_its_name_stands_for( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - assert panel._audition_generator is GeneratorName.PULSE + chosen: List[GeneratorName] = [] + panel.on_audition_generator_changed = chosen.append - def test_choosing_a_generator_by_its_name_is_what_the_keys_then_sound( - self, - panel: GUIReconstructionInstrumentsPanel, - ) -> None: - panel._on_audition_generator_changed( - "sender", - panel._generator_labels[GeneratorName.NOISE], - ) - assert panel._audition_generator is GeneratorName.NOISE + for generator_name in GeneratorName: + panel._on_audition_generator_changed("sender", panel._generator_labels[generator_name]) + + assert chosen == list(GeneratorName) class TestTheNoteKeys: """A note key sounds the instrument in front of the panel, and claims the press it used.""" - def test_a_note_key_sounds_the_open_instrument_on_the_chosen_generator( + def test_a_note_key_asks_for_the_note_it_names( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - sounded: List[Tuple[GeneratorName, int]] = [] - panel.on_audition_requested = lambda generator, semitone: sounded.append((generator, semitone)) + sounded: List[int] = [] + panel.on_audition_requested = sounded.append panel.update_view(ONE_INSTRUMENT) assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Z, modifiers=frozenset())) is True - assert sounded == [(GeneratorName.PULSE, PIANO_KEYS[dpg.mvKey_Z])] + assert sounded == [PIANO_KEYS[dpg.mvKey_Z]] def test_a_key_naming_no_note_is_left_to_the_shortcuts( self, panel: GUIReconstructionInstrumentsPanel, ) -> None: - sounded: List[Tuple[GeneratorName, int]] = [] - panel.on_audition_requested = lambda generator, semitone: sounded.append((generator, semitone)) + sounded: List[int] = [] + panel.on_audition_requested = sounded.append panel.update_view(ONE_INSTRUMENT) assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Spacebar, modifiers=frozenset())) is False diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py index 736a1a0d5..c6700a81d 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -1,12 +1,18 @@ from dataclasses import dataclass from typing import Dict, FrozenSet, List +import numpy as np import pytest +from sampletones_application.layout.general.colors.channel import ChannelColors +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, +) from sampletones_application.ui.panels.reconstruction import plot as plot_module from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) +from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_application.view_model.reconstruction.paths.path import ( ReconstructionPathViewModel, ) @@ -16,6 +22,9 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.waveform import ( + InstrumentWaveformViewModel, +) from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -252,3 +261,113 @@ def test_switching_a_slice_twice_returns_the_waveform_it_started_from( harness.panel.toggle_channel(ChannelName.PULSE2) assert harness.selected() == ALL_CHANNELS + + +CHANNEL_COLORS = ChannelColors( + pulse1=LiteralColor((240, 146, 86, 255)), + pulse2=LiteralColor((242, 209, 95, 255)), + triangle=LiteralColor((140, 193, 237, 255)), + noise=LiteralColor((187, 184, 194, 255)), +) + +AUTOSCALE_TAG = "autoscale" +FRAME_LENGTH = 735 + + +class _WaveformRecorder: + """Stands in for the waveform graph, recording the one voice line it is asked to draw.""" + + def __init__(self) -> None: + self.drawn: List[Dict[str, object]] = [] + + def load_voice_waveform(self, audio: np.ndarray, *, name: str, color: object) -> None: + self.drawn.append({"audio": audio, "name": name, "color": color}) + + +class InstrumentHarness: + """The panel over the card an instrument is drawn on, with the recording controls it hides.""" + + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + self.shown: Dict[str, bool] = {} + monkeypatch.setattr(plot_module, "dpg_configure_item", self._configure) + + self.waveform = _WaveformRecorder() + self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel) + self.panel._channel_colors = CHANNEL_COLORS + self.panel.autoscale_tag = AUTOSCALE_TAG + self.panel._frame_length = None + self.panel.waveform_display = self.waveform + + def _configure(self, tag: str, **kwargs: object) -> None: + show = kwargs.get("show") + if isinstance(show, bool): + self.shown[tag] = show + + +def _waveform(channel_name: ChannelName) -> InstrumentWaveformViewModel: + return InstrumentWaveformViewModel( + name="lead", + channel_name=channel_name, + audio=np.zeros(2 * FRAME_LENGTH), + frame_length=FRAME_LENGTH, + ) + + +class TestTheCardAnInstrumentIsDrawnOn: + def test_an_instrument_is_drawn_under_its_own_name( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = InstrumentHarness(monkeypatch) + + harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1)) + + assert harness.waveform.drawn[-1]["name"] == "lead" + + def test_an_instrument_is_drawn_in_the_color_of_the_channel_it_sounds_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = InstrumentHarness(monkeypatch) + + harness.panel.update_instrument_view(_waveform(ChannelName.NOISE)) + + assert harness.waveform.drawn[-1]["color"] == CHANNEL_COLORS.noise + + def test_the_frame_length_reaches_the_overlay_the_bars_move( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = InstrumentHarness(monkeypatch) + + harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1)) + + assert harness.panel._frame_length == FRAME_LENGTH + + def test_the_controls_that_read_a_recording_stand_down( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = InstrumentHarness(monkeypatch) + + harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1)) + + assert harness.shown == { + AUTOSCALE_TAG: False, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS: False, + } + + def test_a_recording_takes_the_card_and_its_controls_back( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = InstrumentHarness(monkeypatch) + harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1)) + + harness.panel.update_instrument_view(None) + + assert harness.shown == { + AUTOSCALE_TAG: True, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS: True, + } + assert len(harness.waveform.drawn) == 1 diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py index b0e52c3ee..f7233bc04 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py @@ -5,7 +5,7 @@ import pytest from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.paths import LANG_EN from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer import channels as channels_module @@ -142,7 +142,6 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerOrderPanel: panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) panel._layout = SimpleNamespace( colors=SimpleNamespace( - channels=CHANNEL_COLORS, muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)), ), tracker=SimpleNamespace( @@ -150,6 +149,7 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerOrderPanel: muted_text_fraction=MUTED_TEXT_FRACTION, ), ) + panel._channel_colors = CHANNEL_COLORS panel._current_channels = SequencerChannelsViewModel(muted=muted) panel._position_count = POSITION_COUNT panel._row_labels = dict(ROW_LABELS) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index 0f277e5bf..0c9494053 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.paths import LANG_EN from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer import channels as channels_module @@ -96,7 +96,6 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = SimpleNamespace( colors=SimpleNamespace( - channels=CHANNEL_COLORS, muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)), ), tracker=SimpleNamespace( @@ -104,6 +103,7 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: muted_text_fraction=MUTED_TEXT_FRACTION, ), ) + panel._channel_colors = CHANNEL_COLORS panel._current_channels = SequencerChannelsViewModel(muted=muted) panel._current_row_count = ROW_COUNT panel._cell_kinds = {} From f7b4f99c3770d43f1c14e74dac62f3ca3cc122cc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 20:01:07 +0200 Subject: [PATCH 120/142] Colored: the Instructions plots by their generator --- .../coordinators/tabs/instructions.py | 2 + .../layout/graphs/spectrum.py | 1 - .../parameters/instructions.py | 3 + .../ui/elements/graphs/spectrum.py | 11 ++- .../ui/elements/graphs/waveform.py | 14 +++- .../ui/panels/instruction/colors.py | 28 +++++++ .../ui/panels/instruction/spectrum.py | 12 ++- .../ui/panels/instruction/waveform.py | 10 ++- .../layout/graphs/spectrum.yaml | 1 - src/sampletones_core/generators/__init__.py | 2 + src/sampletones_core/generators/maps.py | 5 ++ .../ui/panels/instruction/test_colors.py | 83 +++++++++++++++++++ 12 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 src/sampletones_application/ui/panels/instruction/colors.py create mode 100644 tests/unit/sampletones_application/ui/panels/instruction/test_colors.py diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 96e0a5b94..d544ff6b4 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -178,12 +178,14 @@ def __init__( self._waveform_panel = GUIInstructionWaveformPanel( initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM), layout=layout.graphs, + channel_colors=layout.channel_colors, language_manager=language_manager, status_bar=status_bar, ) self._spectrum_panel = GUIInstructionSpectrumPanel( initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_SPECTRUM), layout=layout.graphs, + channel_colors=layout.channel_colors, language_manager=language_manager, status_bar=status_bar, ) diff --git a/src/sampletones_application/layout/graphs/spectrum.py b/src/sampletones_application/layout/graphs/spectrum.py index 6757fdf39..5c4ae48a0 100644 --- a/src/sampletones_application/layout/graphs/spectrum.py +++ b/src/sampletones_application/layout/graphs/spectrum.py @@ -6,4 +6,3 @@ class SpectrumLayout(BaseModel, extra="forbid", frozen=True): max_display_bins: int color_dim: WrittenColor - color_bright: WrittenColor diff --git a/src/sampletones_application/parameters/instructions.py b/src/sampletones_application/parameters/instructions.py index 10c592ead..b2c932c5b 100644 --- a/src/sampletones_application/parameters/instructions.py +++ b/src/sampletones_application/parameters/instructions.py @@ -4,6 +4,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.general.colors.table import TableColors from sampletones_application.layout.general.tables import TablesLayout from sampletones_application.layout.graphs import GraphsLayout @@ -34,6 +35,7 @@ class InstructionsTabParameters: graphs: GraphsLayout pitch_stepper_style: PitchStepperStyle table_colors: TableColors + channel_colors: ChannelColors tables: TablesLayout tree_colors: TreeColors scheduling: SchedulingBehavior @@ -52,6 +54,7 @@ def from_config(cls, config: LayoutConfig) -> InstructionsTabParameters: graphs=config.graphs, pitch_stepper_style=PitchStepperStyle.from_general(general), table_colors=general.colors.tables, + channel_colors=general.colors.channels, tables=general.tables, tree_colors=TreeColors.create( general.colors, diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py index d36cc0a22..e1a9e89bd 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -97,7 +97,16 @@ def load_library_fragment( fragment: InstructionLibraryFragment[Any], _sample_rate: int, _frame_length: int, + color: BaseColor, ) -> None: + """Draws one library fragment's bands, brightening toward the color of its generator. + + Args: + fragment: The fragment to draw. + _sample_rate: The rate the fragment was sampled at. + _frame_length: The samples one frame spans. + color: The bright end of the gradient a band's shade sits on. + """ self.clear_layers() self.add_layer( @@ -106,7 +115,7 @@ def load_library_fragment( name=self._language_manager["global.graph.label.spectrum_name"], max_display_bins=self._layout.spectrum.max_display_bins, color_dim=self._layout.spectrum.color_dim, - color_bright=self._layout.spectrum.color_bright, + color_bright=color, ) ) diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 05044359a..9365a1693 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -172,7 +172,17 @@ def _set_overlay_rectangle(self, x_start: float = 0.0, x_end: float = 0.0) -> No y2=[_max_y, _max_y], ) - def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> None: + def load_library_fragment( + self, + fragment: InstructionLibraryFragment[Any], + color: BaseColor, + ) -> None: + """Draws one library fragment, in the color the generator that made it is known by. + + Args: + fragment: The fragment to draw. + color: The color the line is drawn in. + """ self.clear_layers() self.current_data = fragment self.current_position = 0 @@ -181,7 +191,7 @@ def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> No InstructionLayer( data=fragment, name=self._language_manager["global.graph.label.waveform_sample_name"], - color=self._layout.colors.waveform_sample, + color=color, ) ) diff --git a/src/sampletones_application/ui/panels/instruction/colors.py b/src/sampletones_application/ui/panels/instruction/colors.py new file mode 100644 index 000000000..19c19854f --- /dev/null +++ b/src/sampletones_application/ui/panels/instruction/colors.py @@ -0,0 +1,28 @@ +from typing import Any + +from sampletones_application.layout.general.colors.channel import ChannelColors +from sampletones_application.utils.palette.colors.written import WrittenColor +from sampletones_core.features import generator_channel +from sampletones_core.generators import CLASS_NAME_TO_GENERATOR_MAP +from sampletones_core.library import InstructionLibraryFragment + + +def fragment_color( + channel_colors: ChannelColors, + fragment: InstructionLibraryFragment[Any], +) -> WrittenColor: + """The color one library fragment is drawn in, which is its generator's own. + + A fragment names the generator that made it, and a generator is heard on a channel the + application already paints by, so the waveform and the spectrum of a pulse fragment read as + the pulse wherever they are shown. + + Args: + channel_colors: The palette every view naming a channel paints from. + fragment: The fragment being drawn. + + Returns: + WrittenColor: The color its generator is known by. + """ + generator_name = CLASS_NAME_TO_GENERATOR_MAP[fragment.generator_class] + return channel_colors.for_channel(generator_channel(generator_name)) diff --git a/src/sampletones_application/ui/panels/instruction/spectrum.py b/src/sampletones_application/ui/panels/instruction/spectrum.py index cd4619bb9..c673ca1b9 100644 --- a/src/sampletones_application/ui/panels/instruction/spectrum.py +++ b/src/sampletones_application/ui/panels/instruction/spectrum.py @@ -1,6 +1,7 @@ from typing import Any from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.instructions import ( TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, @@ -9,6 +10,7 @@ from sampletones_application.ui.elements.graphs.spectrum import GUISpectrumGraph from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.instruction.colors import fragment_color from sampletones_core.library import InstructionLibraryFragment @@ -17,11 +19,13 @@ def __init__( self, *, layout: GraphsLayout, + channel_colors: ChannelColors, language_manager: LanguageManager, status_bar: GUIStatusBar, initial_collapsed: bool = False, ) -> None: self._layout = layout + self._channel_colors = channel_colors self._language_manager = language_manager self._status_bar = status_bar self.display: GUISpectrumGraph @@ -59,7 +63,13 @@ def load_library_fragment( sample_rate: int, frame_length: int, ) -> None: - self.display.load_library_fragment(fragment, sample_rate, frame_length) + """Draws one fragment's bands, brightening toward the color of the generator that made it.""" + self.display.load_library_fragment( + fragment, + sample_rate, + frame_length, + fragment_color(self._channel_colors, fragment), + ) def clear_layers(self) -> None: self.display.clear_layers() diff --git a/src/sampletones_application/ui/panels/instruction/waveform.py b/src/sampletones_application/ui/panels/instruction/waveform.py index 4a085368c..dd1ec0e50 100644 --- a/src/sampletones_application/ui/panels/instruction/waveform.py +++ b/src/sampletones_application/ui/panels/instruction/waveform.py @@ -1,6 +1,7 @@ from typing import Any from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.channel import ChannelColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.instructions import ( TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM, @@ -9,6 +10,7 @@ from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.instruction.colors import fragment_color from sampletones_core.library import InstructionLibraryFragment @@ -17,11 +19,13 @@ def __init__( self, *, layout: GraphsLayout, + channel_colors: ChannelColors, language_manager: LanguageManager, status_bar: GUIStatusBar, initial_collapsed: bool = False, ) -> None: self._layout = layout + self._channel_colors = channel_colors self._language_manager = language_manager self._status_bar = status_bar self.display: GUIWaveformGraph @@ -57,7 +61,11 @@ def load_library_fragment( self, fragment: InstructionLibraryFragment[Any], ) -> None: - self.display.load_library_fragment(fragment) + """Draws one fragment's waveform, in the color the generator that made it is known by.""" + self.display.load_library_fragment( + fragment, + fragment_color(self._channel_colors, fragment), + ) def clear_layers(self) -> None: self.display.clear_layers() diff --git a/src/sampletones_config/layout/graphs/spectrum.yaml b/src/sampletones_config/layout/graphs/spectrum.yaml index 38a4ac1a6..e4731b30b 100644 --- a/src/sampletones_config/layout/graphs/spectrum.yaml +++ b/src/sampletones_config/layout/graphs/spectrum.yaml @@ -1,3 +1,2 @@ max_display_bins: 512 color_dim: .spectrum_dim -color_bright: .contrast diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py index 140c0f801..3bf74800d 100644 --- a/src/sampletones_core/generators/__init__.py +++ b/src/sampletones_core/generators/__init__.py @@ -4,6 +4,7 @@ from .implementation.triangle import TriangleGenerator from .maps import ( CHANNEL_CLASSES, + CLASS_NAME_TO_GENERATOR_MAP, GENERATOR_CLASS_MAP, GENERATOR_TO_CLASS_NAME_MAP, GENERATOR_TO_INSTRUCTION_MAP, @@ -30,6 +31,7 @@ "GENERATOR_CLASS_MAP", "GENERATOR_TO_INSTRUCTION_MAP", "INSTRUCTION_TO_GENERATOR_MAP", + "CLASS_NAME_TO_GENERATOR_MAP", "GENERATOR_TO_CLASS_NAME_MAP", "MIXER_LEVELS", "Generator", diff --git a/src/sampletones_core/generators/maps.py b/src/sampletones_core/generators/maps.py index a91cf5435..e7e1952d0 100644 --- a/src/sampletones_core/generators/maps.py +++ b/src/sampletones_core/generators/maps.py @@ -29,6 +29,11 @@ } +CLASS_NAME_TO_GENERATOR_MAP: Final[Dict[GeneratorClassName, GeneratorName]] = { + class_name: generator_name for generator_name, class_name in GENERATOR_TO_CLASS_NAME_MAP.items() +} + + CHANNEL_CLASSES: Final[Dict[ChannelName, GeneratorTypeUnion]] = { ChannelName.PULSE1: PulseGenerator, ChannelName.PULSE2: PulseGenerator, diff --git a/tests/unit/sampletones_application/ui/panels/instruction/test_colors.py b/tests/unit/sampletones_application/ui/panels/instruction/test_colors.py new file mode 100644 index 000000000..dd061ad81 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/instruction/test_colors.py @@ -0,0 +1,83 @@ +from typing import Final + +import pytest + +from sampletones_application.layout.general.colors.channel import ChannelColors +from sampletones_application.ui.panels.instruction.colors import fragment_color +from sampletones_application.utils.palette.colors.written import LiteralColor, WrittenColor +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorClassName +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import get_generators_map +from sampletones_core.instructions import InstructionUnion +from sampletones_core.instructions.implementation.noise import NoiseInstruction +from sampletones_core.instructions.implementation.pulse import PulseInstruction +from sampletones_core.instructions.implementation.triangle import TriangleInstruction +from sampletones_core.library import InstructionLibraryFragment +from sampletones_core.library.creator.creation import generate_instruction + +CHANNEL_COLORS: Final[ChannelColors] = ChannelColors( + pulse1=LiteralColor((240, 146, 86, 255)), + pulse2=LiteralColor((242, 209, 95, 255)), + triangle=LiteralColor((140, 193, 237, 255)), + noise=LiteralColor((187, 184, 194, 255)), +) + + +def _fragment( + generator_class: GeneratorClassName, + instruction: InstructionUnion, +) -> InstructionLibraryFragment[InstructionUnion]: + """One fragment as the library makes it, which is what the Instructions tab draws.""" + config = Config() + window = Window.from_config(config) + _, fragment = generate_instruction( + get_generators_map(config), + generator_class, + instruction, + get_feature_extractor(config, window), + ) + return fragment + + +class TestTheColorAFragmentIsDrawnIn: + @pytest.mark.parametrize( + ("generator_class", "instruction", "expected"), + [ + ( + GeneratorClassName.PULSE_GENERATOR, + PulseInstruction.default_instruction(), + CHANNEL_COLORS.pulse1, + ), + ( + GeneratorClassName.TRIANGLE_GENERATOR, + TriangleInstruction.default_instruction(), + CHANNEL_COLORS.triangle, + ), + ( + GeneratorClassName.NOISE_GENERATOR, + NoiseInstruction.default_instruction(), + CHANNEL_COLORS.noise, + ), + ], + ids=["pulse", "triangle", "noise"], + ) + def test_a_fragment_takes_the_color_of_the_channel_its_generator_is_heard_on( + self, + generator_class: GeneratorClassName, + instruction: InstructionUnion, + expected: WrittenColor, + ) -> None: + assert fragment_color(CHANNEL_COLORS, _fragment(generator_class, instruction)) == expected + + def test_the_three_generators_are_drawn_in_three_different_colors(self) -> None: + colors = { + fragment_color(CHANNEL_COLORS, _fragment(generator_class, instruction)) + for generator_class, instruction in ( + (GeneratorClassName.PULSE_GENERATOR, PulseInstruction.default_instruction()), + (GeneratorClassName.TRIANGLE_GENERATOR, TriangleInstruction.default_instruction()), + (GeneratorClassName.NOISE_GENERATOR, NoiseInstruction.default_instruction()), + ) + } + assert len(colors) == 3 From 0dbcf0b890318e6f10ba6e075a909bd0d258e8da Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 20:09:12 +0200 Subject: [PATCH 121/142] Recorded: an instrument edit in the history --- src/sampletones_application/application.py | 10 ++ .../coordinators/tabs/reconstruction.py | 10 +- .../coordinators/tabs/sequencer.py | 8 ++ .../logic/reconstruction/editor.py | 24 +++- .../logic/sequencer/history_detail.py | 16 +++ .../logic/reconstruction/test_editor.py | 18 ++- .../reconstruction/test_instrument_history.py | 128 ++++++++++++++++++ .../logic/reconstruction/test_instruments.py | 22 ++- .../logic/sequencer/test_history_detail.py | 38 ++++++ 9 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ef00c3a05..f4960b59f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -444,6 +444,8 @@ def __init__( on_reconstruction_stem_removed=self._reconstruction_coordinator.apply_edit, original_audio_locator=self._original_audio_locator, instrument_exports=self._instrument_exports, + history=self.history, + instrument_edit_detail=self._instrument_edit_detail, key_router=self.key_router, tab_active=self._is_reconstructions_tab_current, layout=ReconstructionTabParameters.from_config(self.layout), @@ -1131,6 +1133,14 @@ def _on_reconstruction_updated( edit.reconstruction, ) + def _instrument_edit_detail( + self, + voice_id: str, + feature_key: FeatureKey, + ) -> HistoryDetail: + """The history line an instrument edit reads as: the voice and the dimension it moved.""" + return self._sequencer_tab.instrument_edit_detail(voice_id, feature_key) + def _edit_detail(self, voice_id: str, edit: ReconstructionEdit) -> HistoryDetail: """The history line an edit reads as: the feature it moved, or the recording it took out.""" match edit: diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 412df97bf..4d395a16c 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -16,6 +16,7 @@ from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol +from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.audition import ( InstrumentAuditionLogic, @@ -23,7 +24,10 @@ from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.edit import StemRemoval -from sampletones_application.logic.reconstruction.editor import InstrumentEditor +from sampletones_application.logic.reconstruction.editor import ( + InstrumentEditDetail, + InstrumentEditor, +) from sampletones_application.logic.reconstruction.instruments import ( OnReconstructionInstrumentUpdatedCallback, ReconstructionInstrumentsLogic, @@ -134,6 +138,8 @@ def __init__( on_reconstruction_stem_removed: Callable[[StemRemoval], None], original_audio_locator: OriginalAudioLocator, instrument_exports: InstrumentExportCoordinator, + history: HistoryManager, + instrument_edit_detail: InstrumentEditDetail, *, key_router: KeyRouter, tab_active: ActivePredicate, @@ -147,6 +153,8 @@ def __init__( self._instrument_editor: InstrumentEditor = InstrumentEditor( reconstruction_manager, project_controller, + history, + instrument_edit_detail, ) self._session_manager = session_manager self._export_backends = export_backends diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 74229524f..a0534c4fd 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1065,6 +1065,14 @@ def reconstruction_edit_detail( feature_key, ) + def instrument_edit_detail( + self, + voice_id: str, + feature_key: FeatureKey, + ) -> HistoryDetail: + """Describes a hand-written voice's edited dimension for the project history.""" + return self._history_detail.edit_instrument(voice_id, feature_key) + def reconstruction_stem_detail( self, voice_id: str, diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index bede1cc75..31c148b34 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -1,5 +1,7 @@ -from typing import Optional +from typing import Callable, Optional +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.editing import ( EditedVoice, @@ -7,10 +9,13 @@ ReconstructionEdit, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_core.constants.enums import FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.instrument import Instrument +InstrumentEditDetail = Callable[[str, FeatureKey], HistoryDetail] + class InstrumentEditor: """Which voice the Reconstructions tab has in front of it, and where an edit to it goes. @@ -24,9 +29,13 @@ def __init__( self, reconstruction_manager: ReconstructionManager, project_controller: ProjectController, + history: HistoryManager, + instrument_edit_detail: InstrumentEditDetail, ) -> None: self._reconstruction_manager = reconstruction_manager self._controller = project_controller + self._history = history + self._instrument_edit_detail = instrument_edit_detail self._voice_id: Optional[str] = None def edit_instrument(self, voice_id: str) -> None: @@ -61,7 +70,11 @@ def edited_instrument(self) -> Optional[EditedVoice]: return None if feature_data is None else ReconstructionEdit(channels=feature_data.channels) def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None: - """Writes one dimension of the instrument in front of the tab. + """Writes one dimension of the instrument in front of the tab, as one history entry. + + The entry names the voice and the dimension it moved, and consecutive writes of that same + pair run together, so a bar dragged across a plot is undone in one step rather than in one + step per value it passed through. Raises: TypeError: If the tab holds no instrument to write into. @@ -70,4 +83,9 @@ def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> No if instrument is None: raise TypeError("The tab holds no instrument to write an envelope into") - self._controller.set_instrument_envelope(instrument.id, feature_key, envelope) + with self._history.transaction( + HistoryAction.EDIT_INSTRUMENT, + detail=self._instrument_edit_detail(instrument.id, feature_key), + coalesce=(instrument.id, feature_key), + ): + self._controller.set_instrument_envelope(instrument.id, feature_key, envelope) diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 5dbd0c634..ecdd3ec34 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -323,6 +323,22 @@ def edit_reconstruction( self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]), ) + def edit_instrument( + self, + voice_id: str, + feature_key: FeatureKey, + ) -> Segments: + """Describes a hand-written voice's edited dimension: its position and the dimension. + + An instrument is one set of envelopes every channel reads what it can of, so the line + names the dimension alone, as the feature's one-letter code in the color the details tab + plots it with. + """ + return ( + self._voice(voice_id, colon=True), + self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]), + ) + def remove_stem(self, voice_id: str, stem_name: str) -> Segments: """Describes a recording taken out of a sample's reconstruction: its position and name.""" return ( diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index 3ec55b179..2e71574eb 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -3,6 +3,7 @@ import pytest +from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.reconstruction.editing import InstrumentEdit, ReconstructionEdit @@ -41,9 +42,22 @@ def reconstruction_manager() -> MagicMock: return manager +HISTORY_BUDGET: Final[int] = 16 + + +@pytest.fixture +def history(controller: ProjectController) -> HistoryManager: + """A strict history, so an edit landing outside a transaction is reported rather than healed.""" + return HistoryManager(controller, budget=HISTORY_BUDGET, strict=True) + + @pytest.fixture -def editor(reconstruction_manager: MagicMock, controller: ProjectController) -> InstrumentEditor: - return InstrumentEditor(reconstruction_manager, controller) +def editor( + reconstruction_manager: MagicMock, + controller: ProjectController, + history: HistoryManager, +) -> InstrumentEditor: + return InstrumentEditor(reconstruction_manager, controller, history, lambda _voice_id, _feature_key: ()) class TestWhatTheTabHasInFront: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py new file mode 100644 index 000000000..9e0dac3aa --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py @@ -0,0 +1,128 @@ +from typing import Final, List +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.editor import InstrumentEditor +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.view_model.shared.history import ( + HistoryDetail, + HistoryDetailRole, + HistoryDetailSegment, +) +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.instrument import Instrument + +HISTORY_BUDGET: Final[int] = 16 +VOLUME_LETTER: Final[str] = "V" + + +class _Harness: + """The tab's write boundary over a strict history, which is how the application builds it.""" + + def __init__(self) -> None: + self.controller = ProjectController(ProjectManager()) + self.history = HistoryManager(self.controller, budget=HISTORY_BUDGET, strict=True) + self.controller.on_mutation = self.history.handle_mutation + self.controller.new() + + self.details: List[str] = [] + reconstruction_manager = MagicMock(spec=ReconstructionManager) + reconstruction_manager.current_features = None + self.editor = InstrumentEditor( + reconstruction_manager, + self.controller, + self.history, + self._detail, + ) + + def _detail(self, voice_id: str, feature_key: FeatureKey) -> HistoryDetail: + self.details.append(f"{voice_id}:{feature_key.value}") + return ( + HistoryDetailSegment(text=voice_id, role=HistoryDetailRole.INSTRUMENT), + HistoryDetailSegment(text=VOLUME_LETTER, role=HistoryDetailRole.FEATURE_VOLUME), + ) + + def open_instrument(self, name: str = "lead") -> Instrument: + """Adds a voice and puts it in front of the tab, as the pool and the tab each do.""" + with self.history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = self.controller.add_instrument(new_instrument(name)) + + self.editor.edit_instrument(instrument.id) + return instrument + + def write(self, feature_key: FeatureKey, *items: int) -> None: + self.editor.write_envelope(feature_key, Envelope[int](items=items)) + + +@pytest.fixture +def harness() -> _Harness: + return _Harness() + + +class TestAnInstrumentEditInTheHistory: + def test_an_edit_is_recorded_as_the_edit_it_is(self, harness: _Harness) -> None: + """A write outside a transaction would be reported by the strict history instead.""" + harness.open_instrument() + + harness.write(FeatureKey.VOLUME, 15, 12, 9) + + assert [entry.action for entry in harness.history.entries[1:]] == [HistoryAction.EDIT_INSTRUMENT] + + def test_the_entry_names_the_voice_and_the_dimension_it_moved(self, harness: _Harness) -> None: + instrument = harness.open_instrument() + + harness.write(FeatureKey.VOLUME, 15) + + assert harness.details == [f"{instrument.id}:{FeatureKey.VOLUME.value}"] + assert harness.history.entries[-1].detail[-1].text == VOLUME_LETTER + + def test_a_drag_across_one_dimension_is_undone_in_one_step(self, harness: _Harness) -> None: + harness.open_instrument() + + for level in (15, 14, 13, 12): + harness.write(FeatureKey.VOLUME, level) + + assert [entry.action for entry in harness.history.entries[1:]] == [HistoryAction.EDIT_INSTRUMENT] + + def test_undoing_that_drag_returns_the_envelope_it_started_from(self, harness: _Harness) -> None: + instrument = harness.open_instrument() + started_from = harness.controller.project.voices[instrument.id].envelopes.volume + + for level in (15, 14, 13, 12): + harness.write(FeatureKey.VOLUME, level) + + harness.history.undo() + + assert harness.controller.project.voices[instrument.id].envelopes.volume == started_from + + def test_two_dimensions_are_two_entries(self, harness: _Harness) -> None: + """Each dimension is its own target, so moving one leaves the other's entry standing.""" + harness.open_instrument() + + harness.write(FeatureKey.VOLUME, 15) + harness.write(FeatureKey.ARPEGGIO, 0, 4, 7) + + assert [entry.action for entry in harness.history.entries[1:]] == [ + HistoryAction.EDIT_INSTRUMENT, + HistoryAction.EDIT_INSTRUMENT, + ] + + def test_two_voices_are_two_entries(self, harness: _Harness) -> None: + harness.open_instrument("lead") + harness.write(FeatureKey.VOLUME, 15) + + harness.open_instrument("bass") + harness.write(FeatureKey.VOLUME, 15) + + assert [entry.action for entry in harness.history.entries[1:]].count(HistoryAction.EDIT_INSTRUMENT) == 2 + + def test_writing_with_no_instrument_open_is_refused(self, harness: _Harness) -> None: + with pytest.raises(TypeError): + harness.write(FeatureKey.VOLUME, 15) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 101ad6304..ca8168fe7 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -1,10 +1,11 @@ -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, Final, List, Optional from unittest.mock import MagicMock import pytest from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.reconstruction.editor import InstrumentEditor @@ -26,6 +27,21 @@ from sampletones_core.project.voices.creation import new_instrument from sampletones_core.reconstructions import Reconstruction +HISTORY_BUDGET: Final[int] = 16 + + +def _editor( + reconstruction_manager: MagicMock, + controller: ProjectController, +) -> InstrumentEditor: + """The editor over a strict history, which is how the application builds it.""" + return InstrumentEditor( + reconstruction_manager, + controller, + HistoryManager(controller, budget=HISTORY_BUDGET, strict=True), + lambda _voice_id, _feature_key: (), + ) + @pytest.fixture def mock_reconstruction_manager() -> MagicMock: @@ -35,7 +51,7 @@ def mock_reconstruction_manager() -> MagicMock: @pytest.fixture def instrument_editor(mock_reconstruction_manager: MagicMock) -> InstrumentEditor: """The real source the panel reads, over a stand-in for the document it opens.""" - return InstrumentEditor(mock_reconstruction_manager, ProjectController(ProjectManager())) + return _editor(mock_reconstruction_manager, ProjectController(ProjectManager())) @pytest.fixture @@ -316,7 +332,7 @@ def instrument_logic( scheduling: SchedulingBehavior, ) -> ReconstructionInstrumentsLogic: mock_reconstruction_manager.current_features = None - editor = InstrumentEditor(mock_reconstruction_manager, project_controller) + editor = _editor(mock_reconstruction_manager, project_controller) instrument = project_controller.add_instrument(new_instrument("lead")) project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12))) editor.edit_instrument(instrument.id) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 8dff3afc5..8bfae68af 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -433,6 +433,44 @@ def test_edit_reconstruction_names_position_channel_and_feature(self) -> None: ("v", HistoryDetailRole.FEATURE_VOLUME), ] + def test_edit_instrument_names_position_and_feature(self) -> None: + """An instrument is one set every channel reads, so the line names no channel.""" + controller = _controller() + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + segments = formatter.edit_instrument(instrument.id, FeatureKey.VOLUME) + + assert _pairs(segments) == [ + ("00:", HistoryDetailRole.INSTRUMENT), + ("v", HistoryDetailRole.FEATURE_VOLUME), + ] + + @pytest.mark.parametrize( + ("feature_key", "letter", "role"), + [ + (FeatureKey.INITIAL_PITCH, "i", HistoryDetailRole.FEATURE_PITCH), + (FeatureKey.VOLUME, "v", HistoryDetailRole.FEATURE_VOLUME), + (FeatureKey.ARPEGGIO, "a", HistoryDetailRole.FEATURE_ARPEGGIO), + (FeatureKey.PITCH, "p", HistoryDetailRole.FEATURE_PITCH), + (FeatureKey.HI_PITCH, "h", HistoryDetailRole.FEATURE_PITCH), + (FeatureKey.DUTY_CYCLE, "d", HistoryDetailRole.FEATURE_DUTY_CYCLE), + ], + ) + def test_an_instrument_edit_names_its_dimension_by_the_same_letter( + self, + feature_key: FeatureKey, + letter: str, + role: HistoryDetailRole, + ) -> None: + controller = _controller() + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + segments = formatter.edit_instrument(instrument.id, feature_key) + + assert (segments[-1].text, segments[-1].role) == (letter, role) + @pytest.mark.parametrize( ("feature_key", "letter", "role"), [ From 3bebf02b003026d8ff2f4a72cddca57498a882ec Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 20:19:17 +0200 Subject: [PATCH 122/142] Told: what a voice row holds, on hover --- .../coordinators/tabs/sequencer.py | 1 + .../ui/panels/sequencer/voices/footprint.py | 69 ++++++++++++++ .../ui/panels/sequencer/voices/menu.py | 41 +++----- .../ui/panels/sequencer/voices/panel.py | 47 ++++++++++ src/sampletones_config/lang/en.yaml | 3 + .../ui/panels/sequencer/voices/test_hover.py | 94 +++++++++++++++++++ .../ui/panels/sequencer/voices/test_menu.py | 4 +- 7 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/voices/footprint.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index a0534c4fd..a1c96342d 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -299,6 +299,7 @@ def __init__( detail_color=layout.muted_color, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_VOICES_PANEL), language_manager=language_manager, + status_bar=status_bar, key_router=key_router, tab_active=tab_active, shortcut_source=shortcut_source, diff --git a/src/sampletones_application/ui/panels/sequencer/voices/footprint.py b/src/sampletones_application/ui/panels/sequencer/voices/footprint.py new file mode 100644 index 000000000..bc0245083 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/voices/footprint.py @@ -0,0 +1,69 @@ +from typing import List, Optional, Tuple + +from sampletones_application.categories.context import ( + channel_label, + context_label, + context_text, +) +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.hierarchy import TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_core.constants.enums import ChannelName + + +class VoiceFootprintText: + """The words a voice's byte figures are printed in, wherever a surface states them. + + The right-click menu prints a figure per line and the status bar states one sentence, so both + read the same measurement through the same templates and a reader meets one figure for one + voice however they ask for it. + """ + + def __init__(self, language_manager: LanguageManager) -> None: + self._language_manager = language_manager + self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) + self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) + + def size(self, byte_count: int) -> str: + """One byte figure, as every surface prints it.""" + return self._tpl_size_bytes.format(bytes=byte_count) + + def items( + self, + footprint: Optional[SampleFootprintViewModel], + ) -> List[Tuple[str, str]]: + """The byte figures a menu prints: the voice's total, then each channel that plays. + + A channel standing by is written by no export, so it costs nothing and the figures name + the channels that do. + + Args: + footprint: The measurement to print, or ``None`` where the pool holds no such voice. + + Returns: + List[Tuple[str, str]]: Each figure as the label it is printed under and its value. + """ + if footprint is None: + return [] + + items = [(self._lbl_sample_size, self.size(footprint.total_bytes))] + for channel_name in ChannelName.items(): + instrument_bytes = footprint.bytes_for(channel_name) + if instrument_bytes is not None: + items.append( + ( + channel_label(self._language_manager, channel_name), + self.size(instrument_bytes), + ) + ) + + return items + + def channels(self, footprint: SampleFootprintViewModel) -> List[str]: + """The channels a voice plays, each named as every display naming a channel names it.""" + return [ + channel_label(self._language_manager, instrument.channel) + for instrument in footprint.instruments + if instrument.channel is not None + ] diff --git a/src/sampletones_application/ui/panels/sequencer/voices/menu.py b/src/sampletones_application/ui/panels/sequencer/voices/menu.py index 3512a6656..ea14a5587 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/menu.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/menu.py @@ -20,6 +20,9 @@ ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.sequencer.voices.footprint import ( + VoiceFootprintText, +) from sampletones_application.ui.panels.sequencer.voices.moves import ( VOICE_MOVES, VoiceMove, @@ -91,8 +94,7 @@ def __init__( self._language_manager = language_manager self._shortcuts = shortcut_source self._detail_color = detail_color - self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) - self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) + self._footprint_text = VoiceFootprintText(language_manager) self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) def show_for(self, target: VoiceSelection) -> None: @@ -306,35 +308,18 @@ def _footprint_items( self, voice_id: str, ) -> List[Tuple[str, str]]: - """The byte figures the menu prints for a sample: its total, then each channel that plays. + """The byte figures the menu prints for a voice, asked for as the menu opens. - The figures are asked for as the menu opens, so they name what the sample occupies at the - moment a reader looks. A channel standing by is written by no export, so it costs nothing - and the menu names the channels that do. + Reading them at that moment keeps them naming what the voice occupies while a reader is + looking at it. """ - footprint = self.query( - self._panel.sample_footprint, - voice_id, - default=None, + return self._footprint_text.items( + self.query( + self._panel.sample_footprint, + voice_id, + default=None, + ) ) - if footprint is None: - return [] - - items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))] - for channel_name in ChannelName.items(): - instrument_bytes = footprint.bytes_for(channel_name) - if instrument_bytes is not None: - items.append( - ( - channel_label(self._language_manager, channel_name), - self._format_size(instrument_bytes), - ) - ) - - return items - - def _format_size(self, byte_count: int) -> str: - return self._tpl_size_bytes.format(bytes=byte_count) def _label(self, element: SequencerVoicesElements) -> str: return self._language_manager[ diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 0c8b38bc3..778db7aae 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -21,6 +21,10 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.sequencer.voices.footprint import ( + VoiceFootprintText, +) from sampletones_application.ui.panels.sequencer.voices.menu import VoicesMenu from sampletones_application.ui.panels.sequencer.voices.moves import MOVE_DIRECTIONS from sampletones_application.ui.themes.registry import ThemeRegistry @@ -59,12 +63,14 @@ def __init__( layout: SequencerLayout, detail_color: BaseColor, language_manager: LanguageManager, + status_bar: GUIStatusBar, key_router: KeyRouter, tab_active: ActivePredicate, shortcut_source: ShortcutSource, initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager + self._status_bar = status_bar self._layout = layout self._router = key_router self._tab_active = tab_active @@ -80,6 +86,10 @@ def __init__( self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT) self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT) + self._footprint_text = VoiceFootprintText(language_manager) + self._tpl_status_sample = language_manager["sequencer.voices.template.status_sample"] + self._tpl_status_instrument = language_manager["sequencer.voices.template.status_instrument"] + self._channel_separator = language_manager["sequencer.voices.template.status_channel_separator"] self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] = None self.instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] = None @@ -127,6 +137,43 @@ def _create_row_handlers(self) -> None: with dpg.item_handler_registry(tag=self._row_handler_tag): dpg.add_item_clicked_handler(callback=self._on_sample_clicked) dpg.add_item_double_clicked_handler(callback=self._on_sample_double_clicked) + dpg.add_item_hover_handler(callback=self._on_row_hovered) + + def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: + """Says what the hovered row holds, which the id cell and the name cell both address. + + Both cells carry the row's position and its voice, so one handler covers the whole row and + a reader reads the same line wherever the pointer rests on it. + """ + user_data = dpg.get_item_user_data(app_data) + if not isinstance(user_data, tuple): + return + + _position, voice_id = user_data + self._status_bar.set(self._voice_status_message(voice_id)) + + def _voice_status_message(self, voice_id: str) -> str: + """What a voice is, what it plays and what it costs, as one sentence a reader reads. + + A recording plays the channels its conversion found and exports an instrument for each of + them; a hand-written voice is one set of envelopes every channel reads, so it names no + channel and carries a single figure. + """ + entry = self._entry_for(voice_id) + footprint = self.query(self.sample_footprint, voice_id, default=None) + if entry is None or footprint is None: + return "" + + size = self._footprint_text.size(footprint.total_bytes) + match entry.kind: + case VoiceKind.SAMPLE: + return self._tpl_status_sample.format( + name=entry.name, + channels=self._channel_separator.join(self._footprint_text.channels(footprint)), + bytes=size, + ) + case VoiceKind.INSTRUMENT: + return self._tpl_status_instrument.format(name=entry.name, bytes=size) def _create_list_handler(self) -> None: """Answers a press that lands on the list itself rather than on one of its rows.""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index a88804f4c..b5c8b853d 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -629,6 +629,9 @@ sequencer.voices.title.add_sample_dialog: "Add sample" sequencer.voices.title.import_instrument_dialog: "Import instrument" sequencer.voices.title.instrument_imported: "Instrument imported" sequencer.voices.template.instrument_name: "Instrument {position}" +sequencer.voices.template.status_sample: "{name} is a sample playing {channels}. It takes {bytes} as FamiTracker instruments." +sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes} as a FamiTracker instrument." +sequencer.voices.template.status_channel_separator: ", " sequencer.voices.template.instrument_omissions: "\"{name}\" plays the volume, arpeggio and duty envelopes the file states.\nThe file also holds, on the instrument's own terms:" sequencer.history.label.history_text: "History" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py new file mode 100644 index 000000000..8ba1ce0d4 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py @@ -0,0 +1,94 @@ +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.paths import LANG_EN +from sampletones_application.ui.panels.sequencer.voices.footprint import ( + VoiceFootprintText, +) +from sampletones_application.ui.panels.sequencer.voices.panel import ( + GUISequencerVoicesPanel, +) +from sampletones_application.view_model.sequencer.voices import ( + VoiceEntryViewModel, + VoiceKind, +) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint + +SAMPLE_ID: Final[str] = "kick-id" +INSTRUMENT_ID: Final[str] = "lead-id" +UNKNOWN_ID: Final[str] = "gone-id" + +ENTRIES: Final[Tuple[VoiceEntryViewModel, ...]] = ( + VoiceEntryViewModel(voice_id=SAMPLE_ID, name="Kick", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id=INSTRUMENT_ID, name="Lead", kind=VoiceKind.INSTRUMENT), +) + +PULSE_1_FOOTPRINT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32) +NOISE_FOOTPRINT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12) +SAMPLE_FOOTPRINT: Final[SampleFootprintViewModel] = SampleFootprintViewModel.from_footprints( + { + ChannelName.PULSE1: PULSE_1_FOOTPRINT, + ChannelName.NOISE: NOISE_FOOTPRINT, + } +) +INSTRUMENT_FOOTPRINT: Final[SampleFootprintViewModel] = SampleFootprintViewModel.from_instrument(PULSE_1_FOOTPRINT) + + +def _panel(footprint: Optional[SampleFootprintViewModel]) -> GUISequencerVoicesPanel: + """The panel over the facts a hovered row reads, with no DearPyGui context behind it.""" + language_manager = LanguageManager(LANG_EN) + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) + panel._entries = ENTRIES + panel._footprint_text = VoiceFootprintText(language_manager) + panel._tpl_status_sample = language_manager["sequencer.voices.template.status_sample"] + panel._tpl_status_instrument = language_manager["sequencer.voices.template.status_instrument"] + panel._channel_separator = language_manager["sequencer.voices.template.status_channel_separator"] + panel.sample_footprint = lambda _voice_id: footprint + return panel + + +class TestWhatARowSaysAboutItsVoice: + def test_a_sample_names_the_channels_it_plays(self) -> None: + message = _panel(SAMPLE_FOOTPRINT)._voice_status_message(SAMPLE_ID) + + assert "Pulse 1" in message + assert "Noise" in message + assert "Pulse 2" not in message + + def test_a_sample_states_what_it_costs_as_one_figure(self) -> None: + message = _panel(SAMPLE_FOOTPRINT)._voice_status_message(SAMPLE_ID) + + assert f"{SAMPLE_FOOTPRINT.total_bytes} B" in message + + def test_a_sample_is_named_and_called_a_sample(self) -> None: + message = _panel(SAMPLE_FOOTPRINT)._voice_status_message(SAMPLE_ID) + + assert message.startswith("Kick") + assert "sample" in message + + def test_an_instrument_names_no_channel_and_says_every_one_can_play_it(self) -> None: + message = _panel(INSTRUMENT_FOOTPRINT)._voice_status_message(INSTRUMENT_ID) + + assert message.startswith("Lead") + assert "instrument" in message + assert "Pulse 1" not in message + assert f"{PULSE_1_FOOTPRINT.total_bytes} B" in message + + @pytest.mark.parametrize( + ("voice_id", "footprint"), + [ + (UNKNOWN_ID, SAMPLE_FOOTPRINT), + (SAMPLE_ID, None), + ], + ids=["voice_the_pool_no_longer_holds", "voice_nothing_measures"], + ) + def test_a_voice_with_nothing_to_state_says_nothing( + self, + voice_id: str, + footprint: Optional[SampleFootprintViewModel], + ) -> None: + assert _panel(footprint)._voice_status_message(voice_id) == "" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index 09bb8f300..a77d8062b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -187,8 +187,8 @@ def _panel( shortcut_source=shipped_source(), detail_color=DETAIL_COLOR, ) - menu._lbl_sample_size = SAMPLE_SIZE_LABEL - menu._tpl_size_bytes = SIZE_TEMPLATE + menu._footprint_text._lbl_sample_size = SAMPLE_SIZE_LABEL + menu._footprint_text._tpl_size_bytes = SIZE_TEMPLATE menu._tip_size_bytes = SIZE_TOOLTIP panel._menu = menu return VoicesPanelFixture(panel=panel, menu=menu, requests=requests) From e7d5dfedfd4bbd3b3eb207e91328bfad6fddf4f8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 20:30:13 +0200 Subject: [PATCH 123/142] Restated: the documentation for per-envelope loops and the audition --- docs/development/bugs-and-todos.md | 10 ++-- docs/development/packages.md | 3 +- docs/development/playback.md | 11 ++++- docs/formats/bitphase.md | 17 +++---- docs/formats/famitracker.md | 47 +++++++++---------- docs/formats/projects.md | 11 +++-- docs/guide/interface.md | 17 ++++--- docs/guide/sequencer.md | 20 ++++---- .../formats/famitracker/voice.py | 10 ++-- 9 files changed, 82 insertions(+), 64 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 5d58aa503..bc37d4d62 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -27,9 +27,6 @@ starts carrying. one. * Arpeggio modes: a sequence's `setting` byte states absolute. Fixed, relative and scheme need an enum of their own, and scheme needs the item bit-packing FamiTracker gives it. -* A loop point per envelope: a voice states one point, applied to every populated sequence. -* A sample's loop point is offered as a switch in the voice list, though the model carries the - point for both kinds of voice. * A transpose or a volume typed in the sample column of a row holding no sample reaches every channel. The column summarizes the channels its samples cover, and a row covering none falls back to all four so a value typed there lands somewhere; the reference slot keeps the narrower @@ -65,6 +62,13 @@ starts carrying. and the panels beside them. The language-keys check expands such a helper over the whole enum, so a member no call names is reached all the same and stands unnoticed. Spelling those keys literally at the call site would make each entry exactly checkable and retire the enums that remain. +* An edit path reaching the project outside a transaction records itself as `UNTRACKED`: the + label reads "Edit", the detail line is empty and the entry coalesces with nothing, so a drag + becomes one entry per value. `HistoryManager.handle_mutation` names that gap the moment it + happens, but only where `strict_history` is on, and the shipped deployment leaves it off + (`sampletones_config/application/deployment.yaml`), so a new path ships self-healed and + silent. Turning it on for the test run — the suite builds the whole application — would hold + every path to a transaction at the point one is added. * Respecting FamiTracker limitations * Per-tab undo routing * In-application console diff --git a/docs/development/packages.md b/docs/development/packages.md index 260751b3b..3e0cf8371 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -65,7 +65,8 @@ reading answers for both kinds of voice: a sample plays the frames its conversio channel, a hand-written instrument the frames its envelopes make of it. The sequencer renders those instructions to audio and the player encodes them into register values, so what a listener hears and what the console plays are the same walk read two ways rather than two implementations of -one rule. +one rule. A voice sounded on its own — a preview, an audition at a note a key names — takes the +same two steps a row takes, so it lives there too rather than beside whichever surface asked. **Equal temperament sits at the bottom.** The MIDI pitch limits and the A4 reference are `sampletones_shared/constants/music.py`, and the pitch-to-frequency conversion they govern is diff --git a/docs/development/playback.md b/docs/development/playback.md index ac1c9883c..88102df9f 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -39,11 +39,18 @@ a control over what is heard. The contracts here bind every tab and every player ## Two kinds of sound -**Preview** — a quick audition fired by a click: a file or reconstruction in a browser tree, a -sample in the sequencer. A preview is ephemeral. It sounds once, holds the device without claiming +**Preview** — a quick audition fired by a click or a key: a file or reconstruction in a browser +tree, a voice in the sequencer, or the instrument the Reconstructions tab has open, sounded at the +note a piano key names. A preview is ephemeral. It sounds once, holds the device without claiming ownership of it, and is meant to be heard and forgotten. It yields the device to intentional playback, and it answers to Stop. +An instrument's audition is a preview of that kind. It reads the generator chosen on the +instruments card, takes the note from the keyboard's two octaves above the octave the tracker +types in, and renders the voice through the same two steps a tracker row takes — the step from the +instrument's own pitch to the note, at full volume. The plot card draws the same rendering at the +pitch the instrument stands at, so what is seen and what is heard name one generator. + **Intentional playback** — the audio a tab is built around: a reconstruction's audio, an instruction's audio, or the sequencer song. It is owned by the source that started it, and it is resumable, seekable, and stoppable. One intentional source at most is engaged at any moment. diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 362ea8cce..7a6ddc57a 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -78,16 +78,17 @@ carries every register value the channel takes for that tick. From | `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled | **Looping.** Playback returns to the instrument's `loop` row once it runs off the end, -which is the only mode there is. A slice with a loop point therefore sets `loop` to that -row so its envelopes repeat from there while the note is held; one playing its rows once -sets `loop = len - 1` and rests on the level that row carries — silence where the volume +which is the only mode there is. Bitphase reads every dimension out of one row, so the +instrument returns to the earliest row any dimension repeats from and each dimension goes +on sounding what it would have sounded. A slice whose dimensions all halt sets +`loop = len - 1` and rests on the level that row carries — silence where the volume envelope ends on a note-off item, the channel's own level where the slice holds its -volume. A voice's loop point drives this, the same point the FamiTracker exporter reads. +volume. **A hand-written instrument's slices.** Bitphase bakes a channel's registers tick by tick, so an [instrument](../glossary.md#instrument) written by hand reaches a document as a slice per channel it sounds on, each reading the dimensions that channel offers -and moving around the root it states. The envelopes are one set whatever the channel, +and moving around the pitch it states. The envelopes are one set whatever the channel, so the slices differ only in what each channel reads of them. **A held volume.** A slice whose volume envelope carries no item leaves its level to the @@ -100,9 +101,9 @@ silent row, the smallest instrument Bitphase plays. **Equal lengths.** Instrument rows and table rows advance on independent per-tick counters, so they share a length and a loop point and stay in step for as long as the -note sounds. `equalize_lengths` in `exporters/lengths.py` supplies that shared length — -the same rule the FamiTracker exporter applies, with the item limit left unbounded -here (section F). +note sounds. The slice's longest dimension supplies that shared length, and every +shorter one holds the value it ended on for the rest of it (`Envelope.resized`), which +is what the sequences of a FamiTracker instrument each do on a counter of their own. ## C. Pitch diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index f14d7ce73..a8507c556 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -142,19 +142,18 @@ Each sequence carries: - **setting** — the sequence mode; for arpeggio, `0` selects absolute (the offsets are added to the played note). -**Looping.** A voice's loop point sets every populated sequence to repeat from that -item, so its envelopes sustain a held note from there on; a voice playing its -envelopes once leaves every loop point at `-1`. A point beyond a sequence's own items -repeats its final item, which is the value it would hold anyway. +**Looping.** Each sequence states the item it repeats from, so a held note sustains from +that item on. A dimension written without one leaves its loop point at `-1` and plays its +items once. Every envelope carries its own point, so a two-item duty cycle circles on its +own period beside a longer volume envelope. A point beyond a sequence's own items repeats +its final item, which is the value it would hold anyway. **Lengths.** FamiTracker advances each sequence on its own per-tick counter. A sequence that reaches its last item halts and leaves the value it wrote applied, which the driver -holds for as long as the note sounds (`CSeqInstHandler::UpdateInstrument`). A one-shot -instrument therefore carries every dimension at the length it was written: a two-item -volume envelope beside a one-item duty envelope plays exactly as a padded pair would, and -costs the padding less. A looping instrument brings its populated dimensions to the -shortest length instead, so the envelopes repeat in step and the trailing zero that -releases the note is dropped from the cycle. +holds for as long as the note sounds (`CSeqInstHandler::UpdateInstrument`). Every +dimension therefore carries the length it was written at: a two-item volume envelope +beside a one-item duty envelope plays exactly as a padded pair would, and costs the +padding less. Every length stays within the 252 items a FamiTracker sequence holds, so a reconstruction longer than 252 frames — 8.4 s at the default 30 fps — exports its opening 252 frames and @@ -208,17 +207,15 @@ one into the voice pool as a hand-written [instrument](../glossary.md#instrument `instrument.py::read_fti` parses the layout in section A.1, and `voice.py::instrument_to_voice` makes a voice of the 2A03 instrument it holds. -A voice carries three of the five dimensions — volume, arpeggio and duty — and one loop -point every dimension follows, so those come across as they stand. The voice takes the -name the file states, and a file naming nothing leaves the voice named after the file -itself. The arpeggio is read as offsets from the roots a hand-written voice rests on, -since a tracker instrument sounds at whatever note a row names it with. +A voice carries three of the five dimensions — volume, arpeggio and duty — each with the +item it repeats from, so those come across as they stand. The voice takes the name the +file states, and a file naming nothing leaves the voice named after the file itself. The +arpeggio is read as offsets from the pitch a hand-written voice rests at, since a tracker +instrument sounds at whatever note a row names it with. -**Which loop point the voice adopts.** One sequence governs and the rest follow it: the -volume sequence wherever it is written, since that is the one shaping a held note, and -otherwise the first sequence the instrument carries. A governing sequence looping from -one of its items gives the voice that point; one halting at its end leaves the voice -playing its envelopes once. +A sequence looping from one of its items gives that dimension the point; one halting at +its end leaves the dimension playing its items once, holding the last of them for as long +as the note sounds. A point outside the items the sequence carries is read as no point. **What the voice leaves to the file.** A tracker instrument states more than a voice holds, and each of those is reported once the import lands, so a reader learns what the @@ -230,7 +227,6 @@ file carried (`InstrumentOmission` in `voice.py`): | a hi-pitch envelope | the same | | a release point | a note the pattern cuts with a note-off | | an arpeggio in fixed, relative or scheme mode | absolute offsets | -| a loop point per envelope | one point every dimension follows | Each of these is a dimension the project model will grow to hold; `bugs-and-todos.md` under **Tracker** owns that list. @@ -309,8 +305,7 @@ chunk once. A per-instrument or per-sample figure states that instrument's own c module total is therefore at most the sum of them. Within one instrument each kind appears once, so its own sequences are charged once each. -**Looping levels the sequences.** A looping instrument brings its populated dimensions to the -shortest length, while a one-shot keeps each dimension as written (section B), so the two forms -of one set of envelopes cost differently. A voice carries the loop point that decides which -applies; a reconstruction standing on its own is measured as a one-shot, matching the instrument -its **Export instrument** writes. +**Every dimension is charged at its own length.** A sequence is written at the length it holds +(section B), so a figure counts each dimension as it stands and the loop point one of them +carries adds a byte, not a padding. What a voice is measured at is therefore what its +**Export instrument...** writes. diff --git a/docs/formats/projects.md b/docs/formats/projects.md index ff9bec8f1..d11b4d613 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -32,14 +32,17 @@ while the larger audio data travels alongside it in the same archive. A ### `voices` -Every voice carries an `id`, a `name`, and the `loop_point` its envelopes repeat -from while a note is held, or `null` where they play once. The `kind` says what -else it carries: +Every voice carries an `id` and a `name`. The `kind` says what else it carries: | `kind` | Contents | | --- | --- | | `sample` | the `reconstruction_id` of its audio member | -| `instrument` | its `envelopes` — the `volume`, `arpeggio` and `duty_cycle` values it writes, each a list of one item per tick — and the `root_pitch` and `root_period` those values are measured against | +| `instrument` | its `envelopes` — `volume`, `arpeggio` and `duty_cycle` — and the `initial_pitch` and `initial_period` those values are measured against | + +Each envelope holds its `items`, one per tick, and the `loop_point` those items repeat +from while a note is held, or `null` where they play once and the last item stands for as +long as the note sounds. Every envelope states its own point, so a two-item duty cycle +circles on its own period beside a longer volume envelope. ### `song` diff --git a/docs/guide/interface.md b/docs/guide/interface.md index e5aacb689..e01a5d67d 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -137,9 +137,12 @@ choose **Add to Sequencer** (see the [sequencer guide](sequencer.md)). For finer control, the **Instruments** panel on the right shows each channel's instrument — its pitch, volume, arpeggio, and duty sequences — which you can -edit by dragging the bars or typing values. Clearing a sequence hands that -dimension back to the channel, so an instrument with its volume sequence cleared -plays at whatever volume the channel is set to. +edit by dragging the bars or typing values. Typing `|` before an item marks where +that sequence repeats from while a note is held, so `15 14 | 12 10` attacks and then +circles the last two values. Each sequence keeps its own point, so a short duty cycle +can circle beside a longer volume envelope. Clearing a sequence hands that dimension +back to the channel, so an instrument with its volume sequence cleared plays at +whatever volume the channel is set to. Beside each channel is the room its instrument takes on the NES, with the whole sample's above them, so you can see what an edit costs. The figures are in bytes @@ -152,9 +155,11 @@ An **instrument** — a voice you wrote by hand rather than converted, see the [sequencer guide](sequencer.md#voices-samples-and-instruments) — opens here too, from the **Voices** list's right-click ▸ **Edit**. It stands on no recording, so the tab shows its envelopes alone: one set every channel reads, under the instrument's own -name. **Root pitch** is the note its arpeggio is measured against on the melodic -channels and **Root period** the one on **Noise**, and **Loop point** is the tick its -envelopes repeat from while a note is held — an attack followed by a sustained tail. +name. A row of the tracker states the note it sounds at, so the pitch steppers stand +down and **Audition** takes their place: pick **Pulse**, **Triangle** or **Noise**, and +the note keys — `Z` to `M` for one octave and `Q` to `U` for the one above it, at the +octave the tracker types in — play the instrument on that generator. The waveform card +draws what you would hear, in that generator's own color, and redraws as you edit. Editing an instrument puts away whatever reconstruction the tab held. ## Instructions diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index f9f053adc..73a98d3c9 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -47,15 +47,17 @@ file](../formats/famitracker.md#c-reading-an-instrument-file). plays into an instrument of its own, so a recorded part becomes envelopes you edit by hand. -Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder -it, and toggle its **Loop** flag. **Export instrument...** writes the voice out as a -`.fti` another tracker reads — a sample holds one instrument per channel it plays, so -it asks which. The **Edit** menu carries the same actions for the voice you have -picked. The right-click menu also names how much room the voice takes on the NES — a -sample's total and then each channel it plays, and an instrument's single figure — -measured as its **Loop** flag has it. The figures are in bytes, and they count what a -FamiTracker export saves. Removing a voice that patterns still use asks first, because -it clears every row that references it. +Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it. +**Export instrument...** writes the voice out as a `.fti` another tracker reads — a +sample holds one instrument per channel it plays, so it asks which. The **Edit** menu +carries the same actions for the voice you have picked. The right-click menu also names +how much room the voice takes on the NES — a sample's total and then each channel it +plays, and an instrument's single figure. The figures are in bytes, and they count what a +FamiTracker export saves. Removing a voice that patterns still use asks first, because it +clears every row that references it. + +Hovering a row says what that voice is in one line: its name, whether it is a sample or +an instrument, the channels a sample plays, and the room it takes. ## Writing a pattern diff --git a/src/sampletones_core/formats/famitracker/voice.py b/src/sampletones_core/formats/famitracker/voice.py index 315107c83..30e9adeb2 100644 --- a/src/sampletones_core/formats/famitracker/voice.py +++ b/src/sampletones_core/formats/famitracker/voice.py @@ -45,12 +45,12 @@ class ImportedVoice: def instrument_to_voice(instrument: Instrument2A03) -> ImportedVoice: """Makes a voice from a FamiTracker instrument, and names what the instrument stated past it. - A voice carries a volume, an arpeggio and a duty-cycle envelope, and one loop point every - dimension follows, so those come across as they stand. A tracker instrument states more than - that — a pitch bend, a release segment, an arpeggio mode, a loop point of its own per - sequence — and each of those is reported, so a reader learns what the file held. + A voice carries a volume, an arpeggio and a duty-cycle envelope, each with the item it + repeats from, so those come across as they stand. A tracker instrument states more than that + — a pitch bend, a release segment, an arpeggio mode — and each of those is reported, so a + reader learns what the file held. - The voice measures its arpeggio against the roots a voice added by hand rests on, since a + The voice measures its arpeggio against the pitch a voice added by hand rests at, since a tracker instrument sounds at whatever note a row names it with. Args: From 6b64f006434781d629aee0f5559e4091cfa36037 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 21:15:13 +0200 Subject: [PATCH 124/142] Fixed: a waveform series keeping the first color it was drawn in --- .../ui/elements/graphs/waveform.py | 49 +++++++---- .../ui/elements/graphs/test_waveform.py | 2 + .../elements/graphs/test_waveform_colors.py | 81 +++++++++++++++++++ 3 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 9365a1693..c308a08bb 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union import dearpygui.dearpygui as dpg import numpy as np @@ -79,6 +79,7 @@ def __init__( self.overlay_theme = ThemeRegistry.get(TAG_GLOBAL_GRAPH_THEME_OVERLAY) self.current_data: Optional[Union[InstructionLibraryFragment[Any], WaveformData]] = None + self._series_themes: Dict[BaseColor, str] = {} self.current_position: int = 0 _min_x = layout.graph.min_x @@ -462,23 +463,39 @@ def _bind_series_theme( series_tag: str, layer: Union[ArrayLayer, InstructionLayer], ) -> None: - """Binds a line-color theme to a series, holding one theme per shade the series takes. + """Binds the theme drawing this layer in the shade it currently takes.""" + shade = self._series_shade(layer) + dpg_bind_item_theme(series_tag, self._series_theme(self._series_color(layer, shade))) + + def _series_theme(self, color: BaseColor) -> str: + """The theme drawing a line in one color, built once per color the graph has shown. + + A layer keeps its name across loads while its color follows what it draws — one + generator's fragment after another's, the reconstruction line graying as its audio is + recomputed — so the theme is held against the color rather than against the series that + carries it, and a layer arriving in a new color binds the theme built for that color. Each + theme holds the color token itself, so every color the graph has drawn follows a palette + swap. - A series switches between its full and dimmed shades — the reconstruction line grays while - its audio is recomputed — by binding the theme built for that shade, and each theme carries - the color token behind its shade, so both follow a palette swap. + Args: + color: The color the line is drawn in. + + Returns: + str: The tag of the theme carrying it. """ - shade = self._series_shade(layer) - theme_tag = compose_tag(series_tag, SUF_GRAPH_THEME, shade) - if not dpg.does_item_exist(theme_tag): - with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvLineSeries): - dpg_add_palette_theme_color( - dpg.mvPlotCol_Line, - self._series_color(layer, shade), - category=dpg.mvThemeCat_Plots, - ) - - dpg_bind_item_theme(series_tag, theme_tag) + if color in self._series_themes: + return self._series_themes[color] + + theme_tag = compose_tag(self.tag, SUF_GRAPH_THEME, str(len(self._series_themes))) + with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvLineSeries): + dpg_add_palette_theme_color( + dpg.mvPlotCol_Line, + color, + category=dpg.mvThemeCat_Plots, + ) + + self._series_themes[color] = theme_tag + return theme_tag def _add_position_indicator(self) -> None: dpg_delete_item(self.position_indicator_tag) diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index 06cbe9ad2..2b51775f9 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -103,6 +103,8 @@ def _graph() -> GUIWaveformGraph: graph._lbl_waveform_reconstruction = "Reconstruction" graph._status_bar = MagicMock() graph._msg_regenerating = "Regenerating reconstruction..." + graph.tag = "waveform" + graph._series_themes = {} return graph diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py new file mode 100644 index 000000000..734c116d5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py @@ -0,0 +1,81 @@ +from typing import Final, Generator, List +from unittest.mock import MagicMock + +import dearpygui.dearpygui as dpg +import numpy as np +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_shared.types.application import ColorRGBA + +PULSE: Final[ColorRGBA] = (240, 146, 86, 255) +TRIANGLE: Final[ColorRGBA] = (140, 193, 237, 255) +NOISE: Final[ColorRGBA] = (187, 184, 194, 255) + +VOICE_NAME: Final[str] = "lead" +AUDIO: Final[np.ndarray] = np.zeros(8) + + +@pytest.fixture +def graph() -> Generator[GUIWaveformGraph, None, None]: + """A waveform graph on a live DearPyGui context, which is what holds the series themes.""" + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + layout: LayoutConfig = load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + dpg.create_context() + try: + setup_themes(THEME_DIRECTORY, source) + FontRegistry.setup(layout.fonts) + FontRegistry.register_fonts() + with dpg.window(tag="root"): + yield GUIWaveformGraph( + tag="waveform", + parent="root", + layout=layout.graphs, + language_manager=LanguageManager(LANG_EN), + status_bar=MagicMock(), + ) + finally: + dpg.destroy_context() + + +def _drawn_color(graph: GUIWaveformGraph) -> ColorRGBA: + """The color DearPyGui holds on the one series the graph is showing.""" + layer = next(iter(graph.layers.values())) + theme = dpg.get_item_theme(graph._series_tag(layer.name)) + component = dpg.get_item_children(theme, 1)[0] + return tuple(round(value) for value in dpg.get_value(dpg.get_item_children(component, 1)[0])) + + +class TestASeriesTakesTheColorItWasGiven: + def test_a_voice_reloaded_in_another_color_is_drawn_in_that_color( + self, + graph: GUIWaveformGraph, + ) -> None: + """A layer keeps its name across loads, so the theme follows the color, not the name.""" + drawn: List[ColorRGBA] = [] + for color in (PULSE, TRIANGLE, NOISE): + graph.load_voice_waveform(AUDIO, name=VOICE_NAME, color=LiteralColor(color)) + drawn.append(_drawn_color(graph)) + + assert drawn == [PULSE, TRIANGLE, NOISE] + + def test_a_color_shown_twice_is_built_once(self, graph: GUIWaveformGraph) -> None: + for color in (PULSE, TRIANGLE, PULSE): + graph.load_voice_waveform(AUDIO, name=VOICE_NAME, color=LiteralColor(color)) + + assert len(graph._series_themes) == 2 From 72507fb3029634b51b91ed66f69e84cfe511637f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 21:15:13 +0200 Subject: [PATCH 125/142] Moved: the release deployment values out of the source tree --- Makefile | 4 ++-- docs/development/bugs-and-todos.md | 7 ------- docs/development/undo.md | 8 ++++++++ .../config/deployment/deployment.py | 15 ++++++++++----- .../application/deployment.yaml | 4 ++-- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 5e9480176..18874d3e0 100644 --- a/Makefile +++ b/Makefile @@ -66,8 +66,8 @@ help: @echo $(Q) make setup - Set up development environment (uv); GPU auto-detected, GPU=0 forces CPU$(Q) @echo $(Q) make pre-commit - Install pre-commit hooks$(Q) @echo $(Q) make system-deps - Install system packages required to build and run (Debian-based, or Homebrew on macOS)$(Q) - @echo $(Q) make build - Compile standalone executable (respects current deployment config)$(Q) - @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) + @echo $(Q) make build - Compile standalone executable (development deployment config: DEBUG, strict history)$(Q) + @echo $(Q) make release - Compile standalone executable with the release deployment config (INFO, self-healing history)$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make benchmarks - Run the measured-duration suite on its own$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bc37d4d62..f56d1380b 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -62,13 +62,6 @@ starts carrying. and the panels beside them. The language-keys check expands such a helper over the whole enum, so a member no call names is reached all the same and stands unnoticed. Spelling those keys literally at the call site would make each entry exactly checkable and retire the enums that remain. -* An edit path reaching the project outside a transaction records itself as `UNTRACKED`: the - label reads "Edit", the detail line is empty and the entry coalesces with nothing, so a drag - becomes one entry per value. `HistoryManager.handle_mutation` names that gap the moment it - happens, but only where `strict_history` is on, and the shipped deployment leaves it off - (`sampletones_config/application/deployment.yaml`), so a new path ships self-healed and - silent. Turning it on for the test run — the suite builds the whole application — would hold - every path to a transaction at the point one is added. * Respecting FamiTracker limitations * Per-tab undo routing * In-application console diff --git a/docs/development/undo.md b/docs/development/undo.md index e056846be..21fc240fc 100644 --- a/docs/development/undo.md +++ b/docs/development/undo.md @@ -83,5 +83,13 @@ authoritative from YAML with no field defaults. The history panel renders a window of `layout.sequencer.history.max_rendered_entries` rows around the cursor and repaints rows in place via an index-keyed diff. +**Strict checking is on where the code is written.** `deployment.yaml` carries the development +values, so an edit path reaching the project outside a transaction raises +`UntrackedMutationError` at once — in a development run and in the test suite alike, since +several tests build the whole application and read that file. A user build takes the opposite +values from `scripts/release_env_hook.py`, so a gap that reaches a release is healed into an +`UNTRACKED` entry rather than shown to the user. The gap therefore surfaces where it can be +fixed and stays quiet where it cannot. + Standalone reconstruction documents (a reconstruction loaded from disk that is not a project sample) will gain their own history later, reusing the same engine. diff --git a/src/sampletones_application/config/deployment/deployment.py b/src/sampletones_application/config/deployment/deployment.py index c86e23821..a89c411f8 100644 --- a/src/sampletones_application/config/deployment/deployment.py +++ b/src/sampletones_application/config/deployment/deployment.py @@ -19,11 +19,16 @@ class DeploymentConfig(BaseModel, frozen=True): the history self-heals by recording the mutation as its own entry. ``log_level`` sets the verbosity of the application logger at startup. - Every field is required, and the shipped ``deployment.yaml`` supplies the - authoritative baseline for each one. The ``SAMPLETONES_LOG_LEVEL`` and - ``SAMPLETONES_STRICT_HISTORY`` environment variables override individual - fields when set, letting a development run raise verbosity or enable strict - history while the shipped file keeps user builds quiet and self-healing. + Every field is required, and ``deployment.yaml`` supplies the baseline. It carries the + development values — verbose logging, and strict history so a missing transaction is + reported the moment it happens rather than healed in silence — since the source tree is + where an edit is written and where that report is worth having. A release build injects the + user-facing values through ``scripts/release_env_hook.py``, so a shipped artifact is quiet + and self-healing whatever the tree it was built from said. + + The ``SAMPLETONES_LOG_LEVEL`` and ``SAMPLETONES_STRICT_HISTORY`` environment variables set + either field, which is how the release hook states its values and how a run of either kind + takes the other's. """ log_level: LogLevel diff --git a/src/sampletones_config/application/deployment.yaml b/src/sampletones_config/application/deployment.yaml index 40e802381..fe0022b18 100644 --- a/src/sampletones_config/application/deployment.yaml +++ b/src/sampletones_config/application/deployment.yaml @@ -1,2 +1,2 @@ -log_level: INFO -strict_history: false +log_level: DEBUG +strict_history: true From 11207e2448b93af947e4a3210b96915613bd0b0e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 21:29:59 +0200 Subject: [PATCH 126/142] Corrected: the documentation left standing by per-envelope loops and initial pitch --- docs/formats/famitracker.md | 12 ++++++------ docs/guide/sequencer.md | 5 +++-- src/sampletones_core/formats/bitphase/envelopes.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index a8507c556..a8ec43fb4 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -179,17 +179,17 @@ key-assignment table is empty by design. An [instrument](../glossary.md#instrument) written by hand is one set of envelopes every channel reads, which is the instrument model FamiTracker itself uses, so it -becomes a single instrument however many channels play it. Its dimensions are written -at one length, each holding its final value where it is the shorter, so a tracker -advancing every sequence on a counter of its own sounds it the way the engine here -plays it. Every channel that names it reaches that one instrument, each against the -root it reads — its note on the tonal channels, its period on noise. +becomes a single instrument however many channels play it. Each dimension is written at +the length it was typed at, and each states its own loop point, so a tracker advancing +every sequence on a counter of its own sounds it the way the engine here plays it. Every +channel that names it reaches that one instrument, each against the initial pitch it +reads — its note on the tonal channels, its period on noise. **Where a row's note comes from.** A voice states where its zero is and a row states the step from it, so a pattern cell holds `reference + transpose`, held inside the range a tonal channel plays and wrapped into the sixteen periods on noise. A sample's reference is the offset origin its conversion chose; a hand-written instrument's is the -root it states. +initial pitch it states. That origin is chosen once, when the reconstruction is built, and stored with it as that channel's reference pitch (see [Reconstructions](reconstructions.md#contents)). diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 73a98d3c9..601abd5d6 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -84,8 +84,9 @@ those colors, so a muted column stays as readable as the rest. A pitch cell holds one number, and it reads in the terms of the voice the channel is carrying. A sample was converted at a pitch of its own, so its cells read as steps -from it — `+00` plays it as recorded, `+0C` an octave up. An instrument was written -against a root you chose, so its cells read as the notes they sound — `C-4`, `A#3`. +from it — `+00` plays it as recorded, `+0C` an octave up. An instrument sounds at +whatever note a row names it with, so its cells read as the notes they sound — `C-4`, +`A#3`. A row that only bends a note reads the same way as the row that started it. Type a note into an instrument's cell piano-style: the bottom two rows of the keyboard are diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 0cf4308c0..4984383ee 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -148,7 +148,7 @@ def _loop_row(envelopes: Iterable[Envelope[int]], rows: int) -> int: rows: How many rows the instrument holds. Returns: - int: The row to return to, the last one where every dimension halts. + int: The row to return to, held inside the rows the instrument carries. """ points = [envelope.loop_point for envelope in envelopes if envelope.loop_point is not None] if not points: From 7f6d6c355cead50b6a958675402e24eb2aaec5da Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 22:01:09 +0200 Subject: [PATCH 127/142] Aligned: the cancellation vocabulary with the rest of the repository --- docs/development/architecture.md | 4 ++-- docs/development/progress.md | 4 ++-- src/sampletones_application/application.py | 6 +++--- .../categories/elements/instructions.py | 2 +- .../coordinators/reconstruction.py | 6 +++--- .../coordinators/render.py | 4 ++-- .../coordinators/tabs/instructions.py | 6 +++--- .../coordinators/tabs/main.py | 4 ++-- .../logic/export/logic.py | 4 ++-- .../logic/instruction/library.py | 12 +++++------ .../logic/instruction/library_manager.py | 6 +++--- .../logic/main/converter.py | 12 +++++------ .../logic/render/logic.py | 14 ++++++------- .../services/__init__.py | 4 ++-- .../services/conversion/result.py | 4 ++-- .../services/conversion/service.py | 8 ++++---- .../services/export/result.py | 4 ++-- .../services/export/service.py | 10 +++++----- .../services/regeneration/result.py | 4 ++-- .../services/regeneration/service.py | 12 +++++------ .../services/render/result.py | 4 ++-- .../services/render/service.py | 6 +++--- .../services/result.py | 4 ++-- .../services/retune/result.py | 4 ++-- .../ui/elements/tree/tree.py | 4 ++-- .../ui/panels/dialogs/keybindings.py | 4 ++-- .../ui/panels/instruction/library.py | 2 +- .../utils/file_dialogs/result.py | 6 +++--- .../utils/gui/dialogs/renderer.py | 2 +- .../gui/dialogs/windows/save_confirmation.py | 2 +- .../utils/gui/keyboard/capture.py | 4 ++-- .../utils/parallelization/thread.py | 6 +++--- .../view_model/main/converter.py | 2 +- .../view_model/shared/render.py | 2 +- src/sampletones_config/lang/en.yaml | 6 +++--- src/sampletones_core/exports/backend.py | 6 +++--- src/sampletones_core/exports/progress.py | 6 +++--- .../parallelization/processor.py | 16 +++++++-------- src/sampletones_core/parallelization/task.py | 2 +- src/sampletones_core/performance/progress.py | 6 +++--- src/sampletones_core/performance/song.py | 2 +- .../reconstructions/converter/conversion.py | 2 +- .../reconstructions/progress.py | 6 +++--- .../reconstructor/reconstructor.py | 2 +- src/sampletones_core/scripts/library.py | 8 ++++---- .../scripts/reconstruction.py | 8 ++++---- src/sampletones_player/builder.py | 6 +++--- src/sampletones_player/compression/encode.py | 2 +- .../compression/parse/song.py | 4 ++-- .../compression/progress/monitor.py | 8 ++++---- src/sampletones_player/compression/search.py | 2 +- src/sampletones_player/compression/song.py | 2 +- src/sampletones_player/export.py | 6 +++--- src/sampletones_player/song.py | 2 +- src/sampletones_shared/exceptions/__init__.py | 4 ++-- .../exceptions/operation.py | 4 ++-- src/sampletones_shared/utils/progress.py | 2 +- .../reconstruction/test_conversion_jobs.py | 4 ++-- .../parallelization/test_progress_channel.py | 2 +- tests/suite/conversion.py | 2 +- tests/suite/parallelization.py | 8 ++++---- tests/suite/render.py | 4 ++-- .../coordinators/export/test_instrument.py | 6 +++--- .../coordinators/tabs/test_sequencer.py | 6 +++--- .../coordinators/test_render.py | 8 ++++---- .../logic/export/test_logic.py | 6 +++--- .../logic/instruction/test_library_logic.py | 18 ++++++++--------- .../logic/main/test_converter.py | 10 +++++----- .../logic/render/test_logic.py | 12 +++++------ .../services/export/test_service.py | 10 +++++----- .../services/render/test_service.py | 12 +++++------ .../services/test_conversion.py | 10 +++++----- .../services/test_regeneration.py | 20 +++++++++---------- .../services/test_result.py | 14 ++++++------- .../test_application_retune.py | 6 +++--- .../ui/panels/dialogs/test_keybindings.py | 2 +- .../panels/sequencer/input/test_grid_input.py | 8 ++++---- .../sequencer/input/test_order_input.py | 6 +++--- .../sequencer/input/test_tracker_input.py | 6 +++--- .../ui/panels/sequencer/voices/test_keys.py | 6 +++--- .../gui/dialogs/windows/test_confirmation.py | 6 +++--- .../dialogs/windows/test_save_confirmation.py | 2 +- .../utils/gui/keyboard/test_capture.py | 14 ++++++------- .../utils/parallelization/test_thread.py | 6 +++--- .../view_model/main/test_converter.py | 4 ++-- .../view_model/shared/test_render.py | 2 +- .../exports/test_famitracker.py | 4 ++-- .../sampletones_core/exports/test_progress.py | 6 +++--- .../sampletones_core/performance/test_song.py | 4 ++-- .../converter/test_progress.py | 4 ++-- .../reconstructions/test_progress.py | 4 ++-- .../compression/progress/test_monitor.py | 8 ++++---- .../compression/test_encode.py | 6 +++--- tests/unit/sampletones_player/test_export.py | 8 ++++---- 94 files changed, 279 insertions(+), 279 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index e0f00b432..fac794628 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -302,7 +302,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m **Contracts:** - Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)`, and `_emit(result)`. - `_emit` always posts the result to `CallbackQueue`; it never calls a handler directly from the background thread. -- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCancelled`, enabling exhaustive `match` handling by subscribers. +- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCanceled`, enabling exhaustive `match` handling by subscribers. - A service is one subpackage holding `service.py` and `result.py`, so its implementation and the contract its subscribers type against are reached separately; the generic contracts every service reports through are `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws, counting the item under way for the part of it that is done — see `docs/development/progress.md`. - Services hold no references to panels, view models, or logic objects. @@ -325,7 +325,7 @@ There are two coordinator kinds: **Contracts:** - A coordinator touches DPG only on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog that must wait for the next frame is deferred through `FrameCallbackManager`. All other presentation goes through `DialogsRenderer`. -- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a cancelled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform. +- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a canceled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform. - A coordinator holds no domain state. It delegates reads and writes to the managers and controllers it was given; what it caches is presentation wiring — resolved language strings, panels, logic objects, callbacks. - Callbacks received from `Application` as constructor parameters are stored and forwarded as-is. The one sanctioned wrapper is an intent-level guard that a contract requires — e.g. a busy-authority start-time guard (principle 10) wrapping an operation's entry point. - Error dialogs, confirmations, and notices are presented here, with text resolved from `LanguageManager` here (see the Error Handling Policy). diff --git a/docs/development/progress.md b/docs/development/progress.md index 704ec78b0..6d43652ce 100644 --- a/docs/development/progress.md +++ b/docs/development/progress.md @@ -26,7 +26,7 @@ ExportReporter = Callable[[ExportProgress], bool] Each domain names its own progress type — `ExportProgress`, `WalkProgress`, `CodecProgress`, `ReconstructionProgress` — and its own `announce`, which builds that type, offers it, and raises -`OperationCancelled` where the answer is no. One reporter therefore carries both directions: an +`OperationCanceled` where the answer is no. One reporter therefore carries both directions: an operation is watched and withdrawn over the same line, and a caller that wants neither passes `silent_reporter` (`sampletones_shared/utils/progress.py`) and hears the run through to its end. @@ -65,7 +65,7 @@ of one for its whole length. Both layers therefore carry the same pair: | Core | `TaskProgress` | `completed` / `total` | `steps`, one per running task | | Application | `ServiceProgress` | `completed` / `total` | `partial`, in items | -and both derive `fraction` from them. The counts keep naming the items a reader recognises — a +and both derive `fraction` from them. The counts keep naming the items a reader recognizes — a status line still reads *Progress: 2/5 files* — while every bar in the application draws `fraction`, so one reading answers for a batch of files and for a single reconstruction alike. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index a89c3d95a..a8e43c555 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -84,7 +84,7 @@ RetunedSample, RetuneResult, SampleRetuneService, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceSuccess, @@ -486,7 +486,7 @@ def __init__( status_bar=self.status_bar, on_load_file=self._on_converted_reconstruction_loaded, on_load_directory=self._navigate_to_reconstructions, - on_cancelled=self._refresh_reconstruction_trees, + on_canceled=self._refresh_reconstruction_trees, on_refresh_trees=self._refresh_reconstruction_trees, on_generate_library=self._instructions_tab.ensure_library_loaded, stem_selection_window=self.stem_selection_window, @@ -1176,7 +1176,7 @@ def _on_retune_result(self, result: RetuneResult) -> None: self._apply_retuned_sample(retuned) case ServiceError(exception=exception): logger.error_with_traceback(exception, "Sample retune failed") - case ServiceCancelled(): + case ServiceCanceled(): pass if not self.retune_service.is_running(): diff --git a/src/sampletones_application/categories/elements/instructions.py b/src/sampletones_application/categories/elements/instructions.py index 7414a97bf..feb7b13d1 100644 --- a/src/sampletones_application/categories/elements/instructions.py +++ b/src/sampletones_application/categories/elements/instructions.py @@ -23,7 +23,7 @@ class InstructionsLibraryElements(AbstractElement): STATUS_SAVING = "status_saving" STATUS_GENERATION_SUCCESS = "status_generation_success" STATUS_WINDOW_NOT_AVAILABLE = "status_window_not_available" - STATUS_GENERATION_CANCELLED = "status_generation_cancelled" + STATUS_GENERATION_CANCELED = "status_generation_canceled" STATUS_GENERATION_FAILED = "status_generation_failed" STATUS_FILE_NOT_FOUND = "status_file_not_found" STATUS_FILE_LOAD_ERROR = "status_file_load_error" diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 62a8813dc..bde8f0b94 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -16,7 +16,7 @@ RegeneratedInstrument, RegenerationResult, RegenerationService, - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -333,8 +333,8 @@ def _on_regeneration_result(self, result: RegenerationResult) -> None: case ServiceError(exception=exception): logger.error_with_traceback(exception, "Regeneration failed") self._dialogs.show_error(exception) - case ServiceCancelled(): - logger.info("Regeneration cancelled") + case ServiceCanceled(): + logger.info("Regeneration canceled") self._set_reconstruction_dimmed(self._regeneration_service.is_running()) diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py index b2ed163a5..5b024108f 100644 --- a/src/sampletones_application/coordinators/render.py +++ b/src/sampletones_application/coordinators/render.py @@ -57,7 +57,7 @@ def __init__( self._logic.on_choose_destination = self._choose_destination self._logic.on_success = self._on_success self._logic.on_error = self._on_error - self._logic.on_cancelled = self._on_cancelled + self._logic.on_canceled = self._on_canceled self._window.on_settings_changed = self._logic.apply self._window.on_browse = self._logic.request_destination @@ -138,7 +138,7 @@ def _on_error(self, exception: Exception) -> None: self._close() self._present(partial(self._dialogs.show_error, exception, self._msg_failed)) - def _on_cancelled(self) -> None: + def _on_canceled(self) -> None: """Closes the dialog of a render that was stopped, which leaves no file to report.""" self._close() diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 9864e16e2..b663791f7 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -156,7 +156,7 @@ def __init__( self._library_logic.on_view_changed = self._library_panel.update_view self._library_logic.on_generation_completed = self._on_generation_completed self._library_logic.on_generation_error = self._on_generation_error - self._library_logic.on_generation_cancelled = self._on_generation_cancelled + self._library_logic.on_generation_canceled = self._on_generation_canceled self._library_logic.on_load_file_not_found = self._on_library_file_not_found self._library_logic.on_load_error = self._on_library_load_error @@ -288,10 +288,10 @@ def _on_generation_error(self, exception: Exception) -> None: self._language_manager["instructions.library.message.status_generation_failed"], ) - def _on_generation_cancelled(self) -> None: + def _on_generation_canceled(self) -> None: self._dialogs.show_info( TAG_INSTRUCTIONS_LIBRARY_PANEL, - self._language_manager["instructions.library.message.status_generation_cancelled"], + self._language_manager["instructions.library.message.status_generation_canceled"], self._ttl_generation_status, modal=True, ) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 3a417f074..66125638a 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -107,7 +107,7 @@ def __init__( status_bar: GUIStatusBar, on_load_file: PathCallback, on_load_directory: VoidCallback, - on_cancelled: VoidCallback, + on_canceled: VoidCallback, on_refresh_trees: VoidCallback, on_generate_library: VoidCallback, stem_selection_window: GUIStemSelectionWindow, @@ -259,7 +259,7 @@ def __init__( self._converter_logic.cancel_library_generation = library_manager.cancel_generation self._converter_logic.on_load_file = on_load_file self._converter_logic.on_load_directory = on_load_directory - self._converter_logic.on_cancelled = on_cancelled + self._converter_logic.on_canceled = on_canceled self._converter_logic.generate_library = on_generate_library config_manager.add_config_change_callback(self._converter_logic.refresh_view) library_manager.on_generation_progress_extra = conversion_service.forward_library_progress diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index 85e418c82..95453db65 100644 --- a/src/sampletones_application/logic/export/logic.py +++ b/src/sampletones_application/logic/export/logic.py @@ -6,7 +6,7 @@ ExportSuccess, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -94,7 +94,7 @@ def _on_service_result(self, result: ExportResult) -> None: self._on_started() case ServiceProgress() as progress: self._on_progress(progress) - case ExportSuccess() | ExportError() | ServiceCancelled(): + case ExportSuccess() | ExportError() | ServiceCanceled(): self._on_finished() def _on_started(self) -> None: diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 3ec235d67..9aa309338 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -73,7 +73,7 @@ def __init__( self.on_apply_library_config: Optional[OnApplyLibraryConfigCallback] = None self.on_generation_completed: Optional[VoidCallback] = None self.on_generation_error: Optional[Callable[[Exception], None]] = None - self.on_generation_cancelled: Optional[VoidCallback] = None + self.on_generation_canceled: Optional[VoidCallback] = None self.on_load_file_not_found: Optional[Callable[[Path, str], None]] = None self.on_load_error: Optional[Callable[[Exception, str], None]] = None @@ -85,7 +85,7 @@ def __init__( on_generation_progress=self._on_generation_progress, on_generation_completed=self._on_generation_completed, on_generation_error=self._on_generation_error, - on_generation_cancelled=self._on_generation_cancelled, + on_generation_canceled=self._on_generation_canceled, ) def configure_lock( @@ -373,8 +373,8 @@ def _on_generation_progress( self._emit_view(self._language_manager["instructions.library.message.status_saving"], progress=1.0) case TaskStatus.FAILED: self._emit_view(self._language_manager["instructions.library.message.status_generation_failed"]) - case TaskStatus.CANCELLED: - self._emit_view(self._language_manager["instructions.library.message.status_generation_cancelled"]) + case TaskStatus.CANCELED: + self._emit_view(self._language_manager["instructions.library.message.status_generation_canceled"]) case TaskStatus.RUNNING: self._update_progress_state(task_progress) @@ -405,8 +405,8 @@ def _on_generation_error(self, exception: Exception) -> None: self.call(self.on_generation_error, exception) self._finalize_generation_error() - def _on_generation_cancelled(self) -> None: - self.call(self.on_generation_cancelled) + def _on_generation_canceled(self) -> None: + self.call(self.on_generation_canceled) self._finalize_generation() def _finalize_generation(self) -> None: diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 291f42516..40df1830a 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -57,7 +57,7 @@ def __init__( self.on_generation_progress: Optional[OnGenerationProgressCallback] = None self.on_generation_progress_extra: Optional[OnGenerationProgressCallback] = None self.on_generation_error: Optional[OnGenerationErrorCallback] = None - self.on_generation_cancelled: Optional[VoidCallback] = None + self.on_generation_canceled: Optional[VoidCallback] = None def set_library_directory(self, directory: Path) -> None: self._library = InstructionLibrary(directory=str(directory)) @@ -191,7 +191,7 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: on_start=self.on_generation_start, on_completed=self._complete_generation, on_error=self.on_generation_error, - on_cancelled=self.on_generation_cancelled, + on_canceled=self.on_generation_canceled, on_progress=_on_progress, ) @@ -248,7 +248,7 @@ def shutdown(self) -> None: """Tears the library creator's process pool down synchronously for application exit. A conversion generates its library first, so this pool is the one still spawning - workers when a run is cancelled and the window is closed; this blocks until it has + workers when a run is canceled and the window is closed; this blocks until it has stopped so the process reaps its workers before releasing shared resources.""" if self._creator: self._creator.shutdown() diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 258c72843..f599c72f4 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -15,7 +15,7 @@ ) from sampletones_application.services.conversion.result import ConversionItem, ConversionResult from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -133,7 +133,7 @@ def __init__( self.on_target_exists: Optional[PathCallback] = None self.on_load_file: Optional[PathCallback] = None self.on_load_directory: Optional[VoidCallback] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None self.generate_library: Optional[VoidCallback] = None self.cancel_library_generation: Optional[VoidCallback] = None self.is_library_available: Optional[Callable[[], bool]] = None @@ -340,7 +340,7 @@ def _on_service_result(self, result: ConversionResult) -> None: self._on_conversion_complete(written) case ServiceError(exception=exception): self._on_conversion_error(exception) - case ServiceCancelled(): + case ServiceCanceled(): self._on_cancellation_complete() def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None: @@ -585,10 +585,10 @@ def _on_conversion_error(self, exception: Exception) -> None: self.call(self.on_error, exception) def _on_cancellation_complete(self) -> None: - self._phase = ConversionPhase.CANCELLED - self._emit_view_model(self._language_manager["main.converter.message.status_cancelled"], 0.0) + self._phase = ConversionPhase.CANCELED + self._emit_view_model(self._language_manager["main.converter.message.status_canceled"], 0.0) self._schedule_return_to_idle() - self.call(self.on_cancelled) + self.call(self.on_canceled) def _schedule_return_to_idle(self) -> None: CallbackQueue.add( diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py index 90dbc1d48..051f5e2ec 100644 --- a/src/sampletones_application/logic/render/logic.py +++ b/src/sampletones_application/logic/render/logic.py @@ -13,7 +13,7 @@ from sampletones_application.logic.shared.project_source import ProjectSnapshot from sampletones_application.services.render.result import RenderResult, RenderStage from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceStarted, @@ -69,7 +69,7 @@ def __init__( self._service = render_service self._is_operation_active = is_operation_active self._msg_cancelling = language_manager["settings.render.message.status_cancelling"] - self._msg_cancelled = language_manager["settings.render.message.status_cancelled"] + self._msg_canceled = language_manager["settings.render.message.status_canceled"] self._msg_completed = language_manager["settings.render.message.status_completed"] self._msg_failed = language_manager["settings.render.message.status_failed"] self._eta_template = language_manager["global.dialog.template.time_estimation"] @@ -91,7 +91,7 @@ def __init__( self.on_choose_destination: Optional[Callable[[Path, AudioFormat], None]] = None self.on_success: Optional[PathCallback] = None self.on_error: Optional[Callable[[Exception], None]] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None @property def is_active(self) -> bool: @@ -206,7 +206,7 @@ def _on_service_result(self, result: RenderResult) -> None: self._on_render_complete(destination) case ServiceError(exception=exception): self._on_render_error(exception) - case ServiceCancelled(): + case ServiceCanceled(): self._on_cancellation_complete() def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None: @@ -239,9 +239,9 @@ def _on_render_error(self, exception: Exception) -> None: self.call(self.on_error, exception) def _on_cancellation_complete(self) -> None: - self._phase = RenderPhase.CANCELLED - self._report(self._msg_cancelled, 0.0) - self.call(self.on_cancelled) + self._phase = RenderPhase.CANCELED + self._report(self._msg_canceled, 0.0) + self.call(self.on_canceled) def _report(self, status_text: str, progress: float) -> None: self._status_text = status_text diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index 87e2f6f68..0eebac09c 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -21,7 +21,7 @@ SongRenderService, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -51,7 +51,7 @@ "RowSynthesizerProtocol", "SampleRetuneService", "ServiceBase", - "ServiceCancelled", + "ServiceCanceled", "ServiceError", "ServiceIntermediate", "ServiceProgress", diff --git a/src/sampletones_application/services/conversion/result.py b/src/sampletones_application/services/conversion/result.py index bd94fc7c9..8037303f5 100644 --- a/src/sampletones_application/services/conversion/result.py +++ b/src/sampletones_application/services/conversion/result.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -50,5 +50,5 @@ class ConversionItem(BaseModel): ServiceIntermediate[TaskProgress], ServiceSuccess[Tuple[Path, ...]], ServiceError, - ServiceCancelled, + ServiceCanceled, ] diff --git a/src/sampletones_application/services/conversion/service.py b/src/sampletones_application/services/conversion/service.py index fa555ee24..102622e4b 100644 --- a/src/sampletones_application/services/conversion/service.py +++ b/src/sampletones_application/services/conversion/service.py @@ -8,7 +8,7 @@ ReconstructionStep, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -50,7 +50,7 @@ def start(self, config: Config, plan: ConversionPlan) -> None: on_progress=self._on_progress, on_completed=self._on_completed, on_error=self._on_error, - on_cancelled=self._on_cancelled, + on_canceled=self._on_canceled, ) self._converter.start() @@ -151,8 +151,8 @@ def _on_completed(self, written: Tuple[Path, ...]) -> None: def _on_error(self, exception: Exception) -> None: self._emit(ServiceError(exception=exception)) - def _on_cancelled(self) -> None: - self._emit(ServiceCancelled()) + def _on_canceled(self) -> None: + self._emit(ServiceCanceled()) def forward_library_progress( self, diff --git a/src/sampletones_application/services/export/result.py b/src/sampletones_application/services/export/result.py index e88ee5674..56e08c3a1 100644 --- a/src/sampletones_application/services/export/result.py +++ b/src/sampletones_application/services/export/result.py @@ -3,7 +3,7 @@ from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.success import ExportSuccess from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -14,7 +14,7 @@ ServiceProgress[ExportStage], ExportSuccess, ExportError, - ServiceCancelled, + ServiceCanceled, ] __all__ = [ diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index e7c3e4136..2ffc23402 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -11,7 +11,7 @@ from sampletones_application.services.export.reporter import ExportProgressReporter from sampletones_application.services.export.result import ExportResult from sampletones_application.services.export.success import ExportSuccess -from sampletones_application.services.result import ServiceCancelled, ServiceStarted +from sampletones_application.services.result import ServiceCanceled, ServiceStarted from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.audio import write_wave from sampletones_core.exports.artifact import ExportArtifact @@ -23,7 +23,7 @@ ProjectExport, SampleExport, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.logger import logger NO_EXPORT_FORMAT: None = None @@ -176,9 +176,9 @@ def _run( try: self._emit(ServiceStarted(total=UNMEASURED_AT_THE_START)) self._report_written(kind, destination, export_format, write(self._reporter())) - except OperationCancelled: - logger.info(f"The export to {logger.format_path(destination)} was cancelled") - self._emit(ServiceCancelled()) + except OperationCanceled: + logger.info(f"The export to {logger.format_path(destination)} was canceled") + self._emit(ServiceCanceled()) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"Failed to export to: {destination}") self._emit( diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py index fc3a5362e..0ec931a67 100644 --- a/src/sampletones_application/services/regeneration/result.py +++ b/src/sampletones_application/services/regeneration/result.py @@ -2,7 +2,7 @@ from typing import Union from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -26,5 +26,5 @@ class RegeneratedInstrument: RegenerationResult = Union[ ServiceSuccess[RegeneratedInstrument], ServiceError, - ServiceCancelled, + ServiceCanceled, ] diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py index 2874f2c10..546beb3c3 100644 --- a/src/sampletones_application/services/regeneration/service.py +++ b/src/sampletones_application/services/regeneration/service.py @@ -8,7 +8,7 @@ RegenerationResult, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -38,7 +38,7 @@ class RegenerationService(ServiceBase[RegenerationResult]): def __init__(self, priority: int = 0) -> None: super().__init__(priority) self._executor = LatestWinsExecutor() - self._cancelled: bool = False + self._canceled: bool = False def start( self, @@ -48,7 +48,7 @@ def start( feature_key: FeatureKey, value: FeatureValue, ) -> bool: - if self._cancelled: + if self._canceled: return False return self._executor.submit( @@ -65,7 +65,7 @@ def is_running(self) -> bool: return self._executor.is_running def cancel(self) -> None: - self._cancelled = True + self._canceled = True def _run( self, @@ -75,8 +75,8 @@ def _run( feature_key: FeatureKey, value: FeatureValue, ) -> None: - if self._cancelled: - self._emit(ServiceCancelled()) + if self._canceled: + self._emit(ServiceCanceled()) return try: exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] diff --git a/src/sampletones_application/services/render/result.py b/src/sampletones_application/services/render/result.py index 9ee105e5e..1b18accb8 100644 --- a/src/sampletones_application/services/render/result.py +++ b/src/sampletones_application/services/render/result.py @@ -3,7 +3,7 @@ from typing import Union from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceStarted, @@ -27,5 +27,5 @@ class RenderStage(StrEnum): ServiceProgress[RenderStage], ServiceSuccess[Path], ServiceError, - ServiceCancelled, + ServiceCanceled, ] diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py index 840fc4eed..1a4839273 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -11,7 +11,7 @@ build_render_sink, ) from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceStarted, ServiceSuccess, @@ -32,7 +32,7 @@ class SongRenderService(ServiceBase[RenderResult]): pass or two without knowing which format waits on the other side. A render is one at a time. Cancelling is honoured between rows and between encoded blocks, - and the file a cancelled or failed run was writing is removed, so a result names a path only + and the file a canceled or failed run was writing is removed, so a result names a path only where a finished file stands. """ @@ -160,7 +160,7 @@ def _report_encoded(self, progress: StageProgress[RenderStage], encoded: int) -> def _report_outcome(self, sink: RenderSink, completed: bool) -> None: if not completed: sink.discard() - self._emit(ServiceCancelled()) + self._emit(ServiceCanceled()) return logger.info(f"Rendered the song to: {logger.format_path(sink.destination)}") diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index 635b53203..431689d61 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -18,7 +18,7 @@ class ServiceProgress(Generic[T]): An operation whose items report their own progress states what the one under way has covered as ``partial``, so the run reads as a whole while the counts keep naming the items a reader - recognises. + recognizes. Attributes: completed: The items the operation has finished. @@ -54,7 +54,7 @@ class ServiceError: @dataclass(frozen=True) -class ServiceCancelled: +class ServiceCanceled: pass diff --git a/src/sampletones_application/services/retune/result.py b/src/sampletones_application/services/retune/result.py index 98ceccbbd..ef91919af 100644 --- a/src/sampletones_application/services/retune/result.py +++ b/src/sampletones_application/services/retune/result.py @@ -1,8 +1,8 @@ from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) from sampletones_application.services.retune.sample import RetunedSample -RetuneResult = ServiceSuccess[RetunedSample] | ServiceError | ServiceCancelled +RetuneResult = ServiceSuccess[RetunedSample] | ServiceError | ServiceCanceled diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 563e55683..ac0b7dbe2 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -80,7 +80,7 @@ ) from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.parallelization.thread import ( - BackgroundWorkCancelled, + BackgroundWorkCanceled, SingleThreadExecutor, ) from sampletones_core.configs.display import ( @@ -414,7 +414,7 @@ def _append_spec( decision covers the whole subtree and the traversal walks on. """ if SingleThreadExecutor.is_shutting_down(): - raise BackgroundWorkCancelled + raise BackgroundWorkCanceled if not self._is_node_drawn(node): return diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py index c5db0248f..b5ca658e5 100644 --- a/src/sampletones_application/ui/panels/dialogs/keybindings.py +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -256,13 +256,13 @@ def _create_action_buttons(self) -> None: ) def _install_capture(self) -> None: - """Readies the capture that reads a press, cancelled by whatever a dialog is cancelled by.""" + """Readies the capture that reads a press, canceled by whatever a dialog is canceled by.""" self._capture = KeyCapture( key_router=self._router, cancel=self._shortcuts.shortcut(ShortcutId.DIALOG_CANCEL).combinations(), ) self._capture.on_captured = self._report_captured - self._capture.on_cancelled = self._render + self._capture.on_canceled = self._render def _teardown(self) -> None: """Stops the capture this appearance armed before the keyboard claim is released.""" diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 75215a95d..af1369122 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -279,7 +279,7 @@ def refresh_action_buttons(self) -> None: Called whenever a long operation starts or finishes. The button stays enabled only while the panel is unlocked and no conversion or library generation is running, leaving the rest of the panel usable during such an operation. The cancel button stays enabled so a generation can - always be cancelled.""" + always be canceled.""" self._apply_action_button_states() def _apply_action_button_states(self) -> None: diff --git a/src/sampletones_application/utils/file_dialogs/result.py b/src/sampletones_application/utils/file_dialogs/result.py index 590c4b217..36b32d9f5 100644 --- a/src/sampletones_application/utils/file_dialogs/result.py +++ b/src/sampletones_application/utils/file_dialogs/result.py @@ -43,11 +43,11 @@ def ignore_none_path( Callable[Concatenate[T, Optional[Path], P], R], ], ]: - """Wraps a path handler so a cancelled dialog resolves to ``default``. + """Wraps a path handler so a canceled dialog resolves to ``default``. Applied bare (``@ignore_none_path``) the wrapped method runs with the selected ``Path`` and - yields ``None`` when the dialog was cancelled. Applied with a ``default`` - (``@ignore_none_path(default=...)``) the cancelled case yields that value instead, so a handler + yields ``None`` when the dialog was canceled. Applied with a ``default`` + (``@ignore_none_path(default=...)``) the canceled case yields that value instead, so a handler that reports an outcome — such as a save returning whether it wrote — carries a truthful result through the cancellation. Each handler body runs only with a real path. """ diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py index 7a73d487e..d5b4bd140 100644 --- a/src/sampletones_application/utils/gui/dialogs/renderer.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -421,7 +421,7 @@ def show_save_confirmation( """Modal save-or-proceed prompt for an unsaved document. ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a cancelled save keeps the + ``on_confirm`` and closes once the save reports success, so a canceled save keeps the prompt open for another attempt. The middle button discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt. """ diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py index d3b2d8bf9..f100c6e3c 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -24,7 +24,7 @@ class GUISaveConfirmationWindow(GUIDialogWindow): """A modal save-or-proceed prompt for an unsaved document. ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a cancelled save keeps the + ``on_confirm`` and closes once the save reports success, so a canceled save keeps the prompt open for another attempt. The middle button discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt. diff --git a/src/sampletones_application/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py index 1bbb001ef..804c681f1 100644 --- a/src/sampletones_application/utils/gui/keyboard/capture.py +++ b/src/sampletones_application/utils/gui/keyboard/capture.py @@ -38,7 +38,7 @@ def __init__( self._listening = False self.on_captured: Optional[Callback] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None @property def is_listening(self) -> bool: @@ -69,7 +69,7 @@ def handle_key(self, event: KeyEvent) -> None: combination = KeyCombination(event.key, event.modifiers) self.stop() if combination in self._cancel: - self.call(self.on_cancelled) + self.call(self.on_canceled) return self.call(self.on_captured, combination) diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py index d13f101c6..3c75aab52 100644 --- a/src/sampletones_application/utils/parallelization/thread.py +++ b/src/sampletones_application/utils/parallelization/thread.py @@ -11,7 +11,7 @@ CONCURRENT_EXECUTOR_NAME: Final[str] = "_concurrent_executor" -class BackgroundWorkCancelled(Exception): +class BackgroundWorkCanceled(Exception): """Unwinds a background task promptly once shutdown has been requested. Long-running tasks poll :meth:`SingleThreadExecutor.is_shutting_down` at their @@ -64,7 +64,7 @@ def request_shutdown(cls) -> None: """Signal running background tasks to wind down at their next cancellation point. Set before :meth:`join_all` at teardown so an in-flight task raises - :class:`BackgroundWorkCancelled` and finishes promptly, letting the join + :class:`BackgroundWorkCanceled` and finishes promptly, letting the join return quickly. """ cls._shutdown.set() @@ -140,7 +140,7 @@ def task() -> None: try: function(self, *args, **kwargs) - except BackgroundWorkCancelled: + except BackgroundWorkCanceled: return except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback( diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 757e4b418..4e6a3a87d 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -18,7 +18,7 @@ class ConversionPhase(StrEnum): RUNNING = "running" CANCELLING = "cancelling" COMPLETED = "completed" - CANCELLED = "cancelled" + CANCELED = "canceled" FAILED = "failed" diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py index 5e8928c7d..926e145ae 100644 --- a/src/sampletones_application/view_model/shared/render.py +++ b/src/sampletones_application/view_model/shared/render.py @@ -28,7 +28,7 @@ class RenderPhase(StrEnum): RENDERING = "rendering" CANCELLING = "cancelling" COMPLETED = "completed" - CANCELLED = "cancelled" + CANCELED = "canceled" FAILED = "failed" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 7139b6fde..8c0993faa 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -366,7 +366,7 @@ main.converter.message.stage_loading: "reading the recordings" main.converter.message.stage_matching: "matching frames" main.converter.message.stage_decoding: "reading the channels" main.converter.message.stage_rendering: "rendering the frames" -main.converter.message.status_cancelled: "Conversion cancelled." +main.converter.message.status_canceled: "Conversion canceled." main.converter.message.status_input_label: "Input:" main.converter.message.status_output_label: "Output:" main.converter.message.status_empty_hint: "Select a WAV file or a folder in the browser to begin." @@ -699,7 +699,7 @@ instructions.library.message.status_generating: "Generating library..." instructions.library.message.status_saving: "Saving generated library..." instructions.library.message.status_generation_success: "Library generated successfully." instructions.library.message.status_window_not_available: "Window not available." -instructions.library.message.status_generation_cancelled: "Library generation cancelled." +instructions.library.message.status_generation_canceled: "Library generation canceled." instructions.library.message.status_generation_failed: "Error generating library." instructions.library.message.status_file_not_found: "The library file could not be found." instructions.library.message.status_file_load_error: "Error while loading the library file." @@ -814,7 +814,7 @@ settings.render.template.bitrate: "{bitrate} kbps" settings.render.message.status_synthesis: "Rendering the song..." settings.render.message.status_encoding: "Writing the file..." settings.render.message.status_cancelling: "Stopping the render..." -settings.render.message.status_cancelled: "Render cancelled." +settings.render.message.status_canceled: "Render canceled." settings.render.message.status_completed: "Render complete." settings.render.message.status_failed: "Render failed." settings.render.message.rendered: "The song was rendered successfully." diff --git a/src/sampletones_core/exports/backend.py b/src/sampletones_core/exports/backend.py index b8d6bd1ad..a3f9049b1 100644 --- a/src/sampletones_core/exports/backend.py +++ b/src/sampletones_core/exports/backend.py @@ -61,7 +61,7 @@ def write_instrument( ExportArtifact: The paths written and what the format's limits left out. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. OSError: If the destination cannot be written. """ @@ -84,7 +84,7 @@ def write_sample( ExportArtifact: The paths written and what the format's limits left out. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. OSError: If the destination cannot be written. """ @@ -105,7 +105,7 @@ def write_project( ExportArtifact: The paths written and what the format's limits left out. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. OSError: If the destination cannot be written. ValueError: If the project holds more than the format has room for. """ diff --git a/src/sampletones_core/exports/progress.py b/src/sampletones_core/exports/progress.py index cd18def09..572105556 100644 --- a/src/sampletones_core/exports/progress.py +++ b/src/sampletones_core/exports/progress.py @@ -2,7 +2,7 @@ from typing import Callable, Optional from sampletones_core.exports.stage import ExportStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled @dataclass(frozen=True) @@ -39,7 +39,7 @@ def announce( total: What the stage counts up to, and ``None`` where only the data decides. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ if not report(ExportProgress(stage=stage, completed=completed, total=total)): - raise OperationCancelled(f"the export was withdrawn while {stage}") + raise OperationCanceled(f"the export was withdrawn while {stage}") diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index f47f0edf6..489318230 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -15,7 +15,7 @@ TaskProgress, TaskStatus, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.logger import LoggerProtocol from sampletones_shared.logger import logger as default_logger from sampletones_shared.types.callback import Callback, VoidCallback @@ -59,7 +59,7 @@ def __init__( self.on_progress: Optional[Callable[[TaskStatus, TaskProgress], None]] = None self.on_completed: Optional[Callable[[T], None]] = None self.on_error: Optional[Callable[[Exception], None]] = None - self.on_cancelled: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None def start(self) -> None: self.monitor_thread = threading.Thread( @@ -121,8 +121,8 @@ def is_running(self) -> bool: def is_completed(self) -> bool: return self.status == TaskStatus.COMPLETED - def is_cancelled(self) -> bool: - return self.status == TaskStatus.CANCELLED + def is_canceled(self) -> bool: + return self.status == TaskStatus.CANCELED def is_cancelling(self) -> bool: return self.status == TaskStatus.CANCELLING @@ -250,7 +250,7 @@ def _process_tasks(self) -> None: pass except KeyboardInterrupt as exception: raise CancelledError() from exception - except OperationCancelled: + except OperationCanceled: self.cancelling = True self._finalize_cancellation() return @@ -284,12 +284,12 @@ def _finalize_cancellation(self) -> None: if not self.cancelling: return - self.logger.info("Task processing was cancelled.") - self.status = TaskStatus.CANCELLED + self.logger.info("Task processing was canceled.") + self.status = TaskStatus.CANCELED self.cancelling = False self.running = False self._notify_progress() - self.call(self.on_cancelled) + self.call(self.on_canceled) def _finalize_completion(self, results: List[T]) -> None: self.logger.info("Conversion completed successfully") diff --git a/src/sampletones_core/parallelization/task.py b/src/sampletones_core/parallelization/task.py index 7ce053605..14a0b0983 100644 --- a/src/sampletones_core/parallelization/task.py +++ b/src/sampletones_core/parallelization/task.py @@ -13,7 +13,7 @@ class TaskStatus(Enum): COMPLETED = "COMPLETED" FAILED = "FAILED" CANCELLING = "CANCELLING" - CANCELLED = "CANCELLED" + CANCELED = "CANCELED" CLEANING_UP = "CLEANING_UP" diff --git a/src/sampletones_core/performance/progress.py b/src/sampletones_core/performance/progress.py index a779c98bd..ecd5ed589 100644 --- a/src/sampletones_core/performance/progress.py +++ b/src/sampletones_core/performance/progress.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Callable -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled @dataclass(frozen=True) @@ -30,7 +30,7 @@ def announce(report: WalkReporter, ticks: int, total: int) -> None: total: The engine ticks the whole order lasts. Raises: - OperationCancelled: If the walk is no longer wanted. + OperationCanceled: If the walk is no longer wanted. """ if not report(WalkProgress(ticks=ticks, total=total)): - raise OperationCancelled(f"the walk was withdrawn having sounded {ticks} of {total} ticks") + raise OperationCanceled(f"the walk was withdrawn having sounded {ticks} of {total} ticks") diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py index 8ca1a7c75..d1ba39fa1 100644 --- a/src/sampletones_core/performance/song.py +++ b/src/sampletones_core/performance/song.py @@ -40,7 +40,7 @@ def song_instructions( Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, tick by tick. Raises: - OperationCancelled: If ``report`` withdraws the walk. + OperationCanceled: If ``report`` withdraws the walk. """ song = project.song groove = SongTiming.from_project(project).groove() diff --git a/src/sampletones_core/reconstructions/converter/conversion.py b/src/sampletones_core/reconstructions/converter/conversion.py index baa4e1eee..0306ee774 100644 --- a/src/sampletones_core/reconstructions/converter/conversion.py +++ b/src/sampletones_core/reconstructions/converter/conversion.py @@ -22,7 +22,7 @@ def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob, Reconstructio Raises: KeyboardInterrupt: If the run is interrupted, so the pool stops. - OperationCancelled: If the run is withdrawn while the job is under way. + OperationCanceled: If the run is withdrawn while the job is under way. """ reconstructor, job, report = arguments job.output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/sampletones_core/reconstructions/progress.py b/src/sampletones_core/reconstructions/progress.py index 4652dddbd..73fedf19f 100644 --- a/src/sampletones_core/reconstructions/progress.py +++ b/src/sampletones_core/reconstructions/progress.py @@ -2,7 +2,7 @@ from typing import Callable, Final from sampletones_core.reconstructions.stage import TOTAL_STAGE_WEIGHT, ReconstructionStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.arrays import clamp STAGE_BEGUN: Final[int] = 0 @@ -65,7 +65,7 @@ def announce( total: What the stage counts up to. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ if not report(ReconstructionProgress(stage=stage, completed=completed, total=total)): - raise OperationCancelled(f"the reconstruction was withdrawn while {stage}") + raise OperationCanceled(f"the reconstruction was withdrawn while {stage}") diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index a24c042be..c20c2d7ad 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -127,7 +127,7 @@ def reconstruct( Raises: ValueError: If the entries count differently than ``paths``. TypeError: If a path is not a string or ``Path``. - OperationCancelled: If the run is withdrawn while it is under way. + OperationCanceled: If the run is withdrawn while it is under way. """ checked_paths = self._check_stem_paths(paths, stems_config) announce(report, ReconstructionStage.LOADING, STAGE_BEGUN, PREPARATIONS) diff --git a/src/sampletones_core/scripts/library.py b/src/sampletones_core/scripts/library.py index 9c2c44859..068e44a4d 100644 --- a/src/sampletones_core/scripts/library.py +++ b/src/sampletones_core/scripts/library.py @@ -50,13 +50,13 @@ def on_progress( if task_status in ( TaskStatus.COMPLETED, - TaskStatus.CANCELLED, + TaskStatus.CANCELED, TaskStatus.FAILED, ): progress_bar.close() - def on_cancelled() -> None: - logger.info("Library generation cancelled by user") + def on_canceled() -> None: + logger.info("Library generation canceled by user") progress_bar.close() def on_error(_exception: Exception) -> None: @@ -66,7 +66,7 @@ def on_error(_exception: Exception) -> None: on_start=on_start, on_completed=on_completed, on_progress=on_progress, - on_cancelled=on_cancelled, + on_canceled=on_canceled, on_error=on_error, ) diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py index 22753ad88..6cdf7c7a6 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -103,13 +103,13 @@ def on_progress( if task_status in ( TaskStatus.COMPLETED, - TaskStatus.CANCELLED, + TaskStatus.CANCELED, TaskStatus.FAILED, ): progress_bar.close() - def on_cancelled() -> None: - logger.info("Reconstruction cancelled by user") + def on_canceled() -> None: + logger.info("Reconstruction canceled by user") progress_bar.close() def on_error(_exception: Exception) -> None: @@ -125,7 +125,7 @@ def on_error(_exception: Exception) -> None: on_start=on_start, on_completed=on_completed, on_progress=on_progress, - on_cancelled=on_cancelled, + on_canceled=on_canceled, on_error=on_error, ) diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py index baf0d3685..e19f2a3e6 100644 --- a/src/sampletones_player/builder.py +++ b/src/sampletones_player/builder.py @@ -77,7 +77,7 @@ def song_from_reconstruction( Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCanceled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks. """ @@ -164,7 +164,7 @@ def song_from_sample( Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCanceled: If ``report`` withdraws the compression. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If two slices name the same channel. """ @@ -209,7 +209,7 @@ def song_from_project( Song: The streams, the clock and the loop point as the player holds them. Raises: - OperationCancelled: If ``report`` or ``walk`` withdraws the run. + OperationCanceled: If ``report`` or ``walk`` withdraws the run. TypeError: If a channel's stream holds an instruction another channel sounds. ValueError: If ``loop_tick`` lies outside the song's ticks, or the project's samples were reconstructed against tunings that differ. diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index c7e9b2f81..17a9b5383 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -141,7 +141,7 @@ def encode_planes( CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. Raises: - OperationCancelled: If ``report`` withdraws the run. + OperationCanceled: If ``report`` withdraws the run. """ cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes.planes) monitor = CodecMonitor(report) diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py index e9dbfee00..dded5b55f 100644 --- a/src/sampletones_player/compression/parse/song.py +++ b/src/sampletones_player/compression/parse/song.py @@ -33,7 +33,7 @@ def parse_planes( Tuple[Parse, ...]: One parse per plane, in the order the planes were given. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ parses: List[Parse] = [] for plane in range(len(cache.indices)): @@ -73,7 +73,7 @@ def parse_planes_offered( Tuple[Parse, ...]: One parse per plane, in the order the planes were given. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ trial: List[Parse] = [] for plane in range(len(cache.indices)): diff --git a/src/sampletones_player/compression/progress/monitor.py b/src/sampletones_player/compression/progress/monitor.py index 10ce80f01..58670943b 100644 --- a/src/sampletones_player/compression/progress/monitor.py +++ b/src/sampletones_player/compression/progress/monitor.py @@ -1,7 +1,7 @@ from typing import Final from sampletones_player.compression.progress.report import CodecProgress, CodecReporter -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled NOTHING_FOUND: Final[int] = 0 NOTHING_LAID_DOWN: Final[int] = 0 @@ -35,7 +35,7 @@ def reached(self, phrases: int, size: int) -> None: size: The bytes the dictionary and the eight streams now take together. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ self._progress = CodecProgress(phrases=phrases, size=size) self.poll() @@ -44,10 +44,10 @@ def poll(self) -> None: """Offers what the run last reached, which is how a long stretch answers a withdrawal. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ if not self._report(self._progress): - raise OperationCancelled( + raise OperationCanceled( f"the encoding was withdrawn holding {self._progress.phrases} phrases " f"and {self._progress.size} bytes" ) diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py index a30167f70..830e350cf 100644 --- a/src/sampletones_player/compression/search.py +++ b/src/sampletones_player/compression/search.py @@ -166,7 +166,7 @@ def search_phrases( PhraseTable: The seeded phrases alongside the ones the search earned. Raises: - OperationCancelled: If the run is no longer wanted. + OperationCanceled: If the run is no longer wanted. """ indices = cache.indices parses = parse_planes(cache, table, options, boundaries, monitor) diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py index c757b7e07..039364b2a 100644 --- a/src/sampletones_player/compression/song.py +++ b/src/sampletones_player/compression/song.py @@ -45,7 +45,7 @@ def compress_song( CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. Raises: - OperationCancelled: If ``report`` withdraws the run. + OperationCanceled: If ``report`` withdraws the run. ValueError: If a stream sounds a timer the pitch table states no index for. """ return encode_planes( diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py index e354e0c1c..e2532a767 100644 --- a/src/sampletones_player/export.py +++ b/src/sampletones_player/export.py @@ -137,7 +137,7 @@ def write_instrument( """Writes a program playing one channel slice. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. SongTooLargeError: If the slice runs longer than the program area holds. OSError: If the destination cannot be written. """ @@ -162,7 +162,7 @@ def write_sample( seconds reads as the work it is doing. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. SongTooLargeError: If the reconstruction runs longer than the program area holds. OSError: If the destination cannot be written. """ @@ -200,7 +200,7 @@ def write_project( what an NSF player expects of a song that has reached its end. Raises: - OperationCancelled: If ``report`` withdraws the write. + OperationCanceled: If ``report`` withdraws the write. SongTooLargeError: If the song holds more than the program area has room for. OSError: If the destination cannot be written. ValueError: If the project's samples were reconstructed against tunings that differ. diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index d2eb84e20..246dcd874 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -64,7 +64,7 @@ def from_streams( Song: The song as the console holds it. Raises: - OperationCancelled: If ``report`` withdraws the compression. + OperationCanceled: If ``report`` withdraws the compression. ValueError: If ``loop_tick`` lies outside the song's ticks, or a channel sounds a timer the pitch table states no index for. """ diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 62cae0b58..05f0972fe 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -24,7 +24,7 @@ NoLibraryDataError, UnhandledLibraryError, ) -from .operation import OperationCancelled +from .operation import OperationCanceled from .player import ( DriverBuildError, PlayerError, @@ -95,7 +95,7 @@ "NoLibraryDataError", "NotAValidArchiveError", "NotAnInstrumentFileError", - "OperationCancelled", + "OperationCanceled", "PlaybackError", "PlayerError", "ReconstructionError", diff --git a/src/sampletones_shared/exceptions/operation.py b/src/sampletones_shared/exceptions/operation.py index 6d8918e0b..9abd09bcc 100644 --- a/src/sampletones_shared/exceptions/operation.py +++ b/src/sampletones_shared/exceptions/operation.py @@ -1,10 +1,10 @@ from .base import SampleToNESError -class OperationCancelled(SampleToNESError): +class OperationCanceled(SampleToNESError): """Raised when work in progress is withdrawn by whoever asked for it. Long operations look up between the steps they are made of and ask the caller whether the answer is still wanted. A caller that says no leaves the work unwound at that point, so the - boundary that started it reports a cancelled run rather than a finished or failed one. + boundary that started it reports a canceled run rather than a finished or failed one. """ diff --git a/src/sampletones_shared/utils/progress.py b/src/sampletones_shared/utils/progress.py index d2a49806c..bdb0e96eb 100644 --- a/src/sampletones_shared/utils/progress.py +++ b/src/sampletones_shared/utils/progress.py @@ -35,7 +35,7 @@ class ReportRate: What a stage counts moves by its own rules: a render's samples rise toward the song's length, while a compression's bytes fall as the dictionary earns its keep. A step is therefore a change of either sign, and a stage landing exactly on its total is always due, so a reading arrives at - the end of every stage however it travelled there. + the end of every stage however it traveled there. The first reading a stage offers is due whatever it says, since a stage announcing where it begins is news to whoever is watching for it. diff --git a/tests/integration/reconstruction/test_conversion_jobs.py b/tests/integration/reconstruction/test_conversion_jobs.py index feef7731c..c004b35e7 100644 --- a/tests/integration/reconstruction/test_conversion_jobs.py +++ b/tests/integration/reconstruction/test_conversion_jobs.py @@ -15,7 +15,7 @@ ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.stage import ReconstructionStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.progress import silent_reporter from tests.integration.assets.reconstruction import ( build_mini_library, @@ -141,7 +141,7 @@ def test_a_withdrawn_job_unwinds_and_writes_nothing(self, tmp_path: Path) -> Non jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): reconstruct_job((reconstructor, jobs[0], reporter)) assert not jobs[0].output_path.exists() diff --git a/tests/integration/sampletones_core/parallelization/test_progress_channel.py b/tests/integration/sampletones_core/parallelization/test_progress_channel.py index df19dfc32..4fae8b94b 100644 --- a/tests/integration/sampletones_core/parallelization/test_progress_channel.py +++ b/tests/integration/sampletones_core/parallelization/test_progress_channel.py @@ -128,7 +128,7 @@ class TestAWithdrawalReachesTheTasks: pool being torn down afterwards is the backstop rather than the mechanism. """ - def test_a_withdrawn_run_ends_cancelled(self, release_path: Path) -> None: + def test_a_withdrawn_run_ends_canceled(self, release_path: Path) -> None: with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder): assert recorder.wait_for(stands_partway, READING_TIMEOUT) diff --git a/tests/suite/conversion.py b/tests/suite/conversion.py index 1f74c573d..0c29d667e 100644 --- a/tests/suite/conversion.py +++ b/tests/suite/conversion.py @@ -52,7 +52,7 @@ def reconstruct( way a reconstruction does however that order comes to change. Raises: - OperationCancelled: If the walk is withdrawn while it is under way. + OperationCanceled: If the walk is withdrawn while it is under way. """ for stage in ReconstructionStage: if stage not in COUNTED_STAGES: diff --git a/tests/suite/parallelization.py b/tests/suite/parallelization.py index ee534e608..4d451522c 100644 --- a/tests/suite/parallelization.py +++ b/tests/suite/parallelization.py @@ -6,7 +6,7 @@ from sampletones_core.parallelization.channel.protocol import StepReporter from sampletones_core.parallelization.processor import TaskProcessor from sampletones_core.parallelization.task import TaskProgress, TaskStatus, TaskStep -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.release import wait_for_release COUNTING_STAGE: Final[str] = "counting" @@ -39,10 +39,10 @@ class CountingTask: def count_task(task: CountingTask) -> int: """Counts to ``STEP_COUNT``, reporting each count, and answers which task did the counting. - Runs in a pool worker, so the line it reports on travelled here with it. + Runs in a pool worker, so the line it reports on traveled here with it. Raises: - OperationCancelled: If the run is withdrawn while the counting is under way. + OperationCanceled: If the run is withdrawn while the counting is under way. """ for completed in range(1, STEP_COUNT + 1): step = TaskStep( @@ -52,7 +52,7 @@ def count_task(task: CountingTask) -> int: fraction=completed / STEP_COUNT, ) if not task.report(step): - raise OperationCancelled(f"task {task.index} was withdrawn at {completed}") + raise OperationCanceled(f"task {task.index} was withdrawn at {completed}") if completed == HALFWAY: wait_for_release(task.release_path) diff --git a/tests/suite/render.py b/tests/suite/render.py index 5831f05ff..8f51ba81a 100644 --- a/tests/suite/render.py +++ b/tests/suite/render.py @@ -5,7 +5,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_application.services.render.result import RenderResult from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -74,7 +74,7 @@ def shutdown(self) -> None: def emit(self, result: RenderResult) -> None: assert self._handler is not None, "The logic subscribes to the service it is given" - self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) + self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCanceled)) self._handler(result) @property diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py index a57e4919b..49d85c0b6 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_instrument.py +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -62,7 +62,7 @@ def _save(**kwargs: object) -> Path: @pytest.fixture -def cancelled(monkeypatch: pytest.MonkeyPatch) -> None: +def canceled(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(instrument_module, "save_file_dialog", lambda **_kwargs: None) @@ -187,11 +187,11 @@ def test_the_destination_reaches_the_write( logic.export.assert_called_once_with(DESTINATION, source) - def test_a_cancelled_dialog_writes_nothing( + def test_a_canceled_dialog_writes_nothing( self, coordinator: InstrumentExportCoordinator, logic: MagicMock, - cancelled: None, + canceled: None, ) -> None: coordinator.request(_source(), SUGGESTED_NAME) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d4f210ae4..9e88b328e 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -156,7 +156,7 @@ def _open(**kwargs: object) -> Path: @pytest.fixture -def cancelled_dialog(monkeypatch: pytest.MonkeyPatch) -> None: +def canceled_dialog(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sequencer_module, "open_file_dialog", lambda **_kwargs: None) @@ -194,10 +194,10 @@ def test_the_folder_the_file_came_from_is_remembered( instrument_coordinator._session_manager.set_instrument_path.assert_called_once_with(INSTRUMENT_FILE.parent) - def test_a_cancelled_dialog_leaves_the_pool_as_it_stands( + def test_a_canceled_dialog_leaves_the_pool_as_it_stands( self, instrument_coordinator: SequencerTabCoordinator, - cancelled_dialog: None, + canceled_dialog: None, ) -> None: instrument_coordinator.import_instrument() diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py index 8e642d443..e973f4e6d 100644 --- a/tests/unit/sampletones_application/coordinators/test_render.py +++ b/tests/unit/sampletones_application/coordinators/test_render.py @@ -10,7 +10,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.render.logic import SongRenderLogic from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -331,7 +331,7 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) - render.start() render.stop() - render.service.emit(ServiceCancelled()) + render.service.emit(ServiceCanceled()) render.advance_frame() assert render.window.hides == 1 @@ -343,9 +343,9 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) - [ ServiceSuccess(value=CHOSEN), ServiceError(exception=OSError("no room on the device")), - ServiceCancelled(), + ServiceCanceled(), ], - ids=["completed", "failed", "cancelled"], + ids=["completed", "failed", "canceled"], ) def test_every_outcome_hands_the_application_back( self, diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py index 3877dd4a1..3a8b5eea5 100644 --- a/tests/unit/sampletones_application/logic/export/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -6,7 +6,7 @@ from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.result import ExportResult, ExportSuccess from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -266,7 +266,7 @@ def test_a_finished_run_takes_the_dialog_off_screen( service.deliver(finished()) assert closed == [True] - def test_a_cancelled_run_takes_the_dialog_off_screen( + def test_a_canceled_run_takes_the_dialog_off_screen( self, logic: SongExportLogic, service: FakeExportService, @@ -274,7 +274,7 @@ def test_a_cancelled_run_takes_the_dialog_off_screen( closed: List[bool] = [] logic.on_finished = lambda: closed.append(True) service.deliver(ServiceStarted(total=NOTHING_MEASURED)) - service.deliver(ServiceCancelled()) + service.deliver(ServiceCanceled()) assert closed == [True] def test_a_run_that_ended_holds_the_screen_no_longer( diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index e1ac1669a..9a43e6715 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -27,13 +27,13 @@ INVALID_DATA_KEY: Final[str] = "instructions.library.message.status_invalid_data" DESERIALIZATION_ERROR_KEY: Final[str] = "instructions.library.message.status_deserialization_error" INCOMPATIBLE_VERSION_KEY: Final[str] = "instructions.library.template.incompatible_version_template" -GENERATION_CANCELLED_KEY: Final[str] = "instructions.library.message.status_generation_cancelled" +GENERATION_CANCELED_KEY: Final[str] = "instructions.library.message.status_generation_canceled" TEXTS: Final[Dict[str, str]] = { INCOMPATIBLE_VERSION_KEY: "got {} expected {}", "instructions.library.message.status_saving": "saving", "instructions.library.message.status_generation_failed": "failed", - GENERATION_CANCELLED_KEY: "cancelled", + GENERATION_CANCELED_KEY: "canceled", "instructions.library.label.generate_library_button": "Generate", "instructions.library.label.regenerate_library_button": "Regenerate", "instructions.library.template.library_loaded_template": "{} loaded.", @@ -191,13 +191,13 @@ class TestGenerationEmits: """Every emit passes its status and progress explicitly, so the logic retains no presentation state between emissions and each view model is complete on its own.""" - def test_cancelled_emits_the_language_managed_status(self) -> None: + def test_canceled_emits_the_language_managed_status(self) -> None: logic = _generation_logic() - logic._on_generation_progress(TaskStatus.CANCELLED, MagicMock()) + logic._on_generation_progress(TaskStatus.CANCELED, MagicMock()) view_model = logic.on_view_changed.call_args.args[0] - assert view_model.status_text == "cancelled" + assert view_model.status_text == "canceled" def test_completed_emits_saving_at_full_progress(self) -> None: logic = _generation_logic() @@ -268,11 +268,11 @@ def test_update_status_reports_an_existing_unloaded_library(self) -> None: assert view_model.generate_button_label == "Generate" -class TestCancelledStatusLanguageKey: - """The cancelled status resolves through ``LanguageManager`` at construction, so the language +class TestCanceledStatusLanguageKey: + """The canceled status resolves through ``LanguageManager`` at construction, so the language file must carry the key.""" - def test_cancelled_status_resolves_from_the_language_file(self) -> None: + def test_canceled_status_resolves_from_the_language_file(self) -> None: language_manager = LanguageManager(LANG_EN) - assert language_manager[GENERATION_CANCELLED_KEY] + assert language_manager[GENERATION_CANCELED_KEY] diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 831fc3eef..f01e9aa72 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -90,9 +90,9 @@ def test_cancel_while_waiting_cancels_generation_and_finishes( converter_logic: ConverterLogic, ) -> None: cancel_generation = MagicMock() - on_cancelled = MagicMock() + on_canceled = MagicMock() converter_logic.cancel_library_generation = cancel_generation - converter_logic.on_cancelled = on_cancelled + converter_logic.on_canceled = on_canceled with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): converter_logic.start_conversion() @@ -101,8 +101,8 @@ def test_cancel_while_waiting_cancels_generation_and_finishes( converter_logic.cancel() cancel_generation.assert_called_once() - on_cancelled.assert_called_once() - assert converter_logic._phase == ConversionPhase.CANCELLED + on_canceled.assert_called_once() + assert converter_logic._phase == ConversionPhase.CANCELED def test_wait_loop_aborts_once_no_longer_waiting( self, @@ -268,7 +268,7 @@ def test_active_during_non_terminal_phases( [ ConversionPhase.IDLE, ConversionPhase.COMPLETED, - ConversionPhase.CANCELLED, + ConversionPhase.CANCELED, ConversionPhase.FAILED, ], ) diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py index df2ee2414..633526a31 100644 --- a/tests/unit/sampletones_application/logic/render/test_logic.py +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -13,7 +13,7 @@ ) from sampletones_application.services.render.result import RenderStage from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceSuccess, @@ -275,14 +275,14 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture def test_a_stopped_render_reports_the_cancellation(self, render: RenderFixture) -> None: render.configure() render.logic.start() - on_cancelled = MagicMock() - render.logic.on_cancelled = on_cancelled + on_canceled = MagicMock() + render.logic.on_canceled = on_canceled render.logic.cancel() - render.service.emit(ServiceCancelled()) + render.service.emit(ServiceCanceled()) - on_cancelled.assert_called_once() - assert render.view.phase == RenderPhase.CANCELLED + on_canceled.assert_called_once() + assert render.view.phase == RenderPhase.CANCELED assert not render.logic.is_active def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 7b0bf7cc2..1366058d9 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -10,7 +10,7 @@ from sampletones_application.services.export.service import ExportService from sampletones_application.services.export.success import ExportSuccess from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceProgress, ServiceStarted, ) @@ -601,18 +601,18 @@ def test_a_stage_that_lands_on_its_total_is_reported(self, service, tmp_path) -> class TestWithdrawingARun: - """A cancelled export answers with a cancellation rather than a failure.""" + """A canceled export answers with a cancellation rather than a failure.""" - def test_a_cancelled_run_ends_cancelled(self, service, tmp_path) -> None: + def test_a_canceled_run_ends_canceled(self, service, tmp_path) -> None: export_service, results = service export_service.export_instrument( tmp_path / "instrument.nsf", CancellingBackend(export_service), build_instrument(), ) - assert isinstance(outcome(results), ServiceCancelled) + assert isinstance(outcome(results), ServiceCanceled) - def test_a_cancelled_run_reports_no_failure(self, service, tmp_path) -> None: + def test_a_canceled_run_reports_no_failure(self, service, tmp_path) -> None: export_service, results = service export_service.export_instrument( tmp_path / "instrument.nsf", diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py index 2a1ce82c3..2c18e6ee2 100644 --- a/tests/unit/sampletones_application/services/render/test_service.py +++ b/tests/unit/sampletones_application/services/render/test_service.py @@ -8,7 +8,7 @@ from sampletones_application.services.render.result import RenderResult, RenderStage from sampletones_application.services.render.service import SongRenderService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceProgress, ServiceStarted, @@ -148,12 +148,12 @@ def test_the_spill_file_is_removed(self, tmp_path: Path) -> None: class TestCancelling(BaseTestSuite): - """A cancelled render reports itself cancelled and names no file.""" + """A canceled render reports itself canceled and names no file.""" def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None) - def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None: + def test_a_canceled_render_leaves_no_file(self, tmp_path: Path) -> None: destination = tmp_path / "song.wav" service = SongRenderService() service.start( @@ -166,7 +166,7 @@ def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None: assert not destination.exists() - def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> None: + def test_a_canceled_render_reports_itself_canceled(self, tmp_path: Path) -> None: service = SongRenderService() results: List[RenderResult] = [] service.subscribe(results.append) @@ -178,9 +178,9 @@ def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> No total_samples=TOTAL_SAMPLES, ) - assert results[-1] == ServiceCancelled() + assert results[-1] == ServiceCanceled() - def test_a_cancelled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: + def test_a_canceled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: service = SongRenderService() service.start( synthesizer=self._cancelling_synthesizer(service), diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index 6485df495..403ff0a0e 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -7,7 +7,7 @@ from sampletones_application.services.conversion.service import ConversionService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -76,7 +76,7 @@ def test_start_wires_five_lifecycle_callbacks( "on_progress", "on_completed", "on_error", - "on_cancelled", + "on_canceled", } def test_start_while_running_does_not_create_second_converter( @@ -203,15 +203,15 @@ def test_on_error_emits_service_error( assert isinstance(result, ServiceError) assert result.exception is exception - def test_on_cancelled_emits_service_cancelled( + def test_on_canceled_emits_service_canceled( self, service: Service, ) -> None: _, _, callbacks, results = service - callbacks["on_cancelled"]() + callbacks["on_canceled"]() assert len(results) == 1 - assert isinstance(results[0], ServiceCancelled) + assert isinstance(results[0], ServiceCanceled) def test_forward_library_progress_emits_service_intermediate( self, diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 153f2d09f..204e045b0 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -8,7 +8,7 @@ from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceSuccess, ) @@ -86,7 +86,7 @@ def reconstruction() -> MockReconstruction: class TestRegenerationServiceStart: - def test_start_when_not_cancelled_returns_true( + def test_start_when_not_canceled_returns_true( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction ) -> None: service = RegenerationService() @@ -99,7 +99,7 @@ def test_start_when_not_cancelled_returns_true( ) assert result is True - def test_start_when_cancelled_returns_false(self) -> None: + def test_start_when_canceled_returns_false(self) -> None: service = RegenerationService() service.cancel() @@ -107,7 +107,7 @@ def test_start_when_cancelled_returns_false(self) -> None: assert result is False - def test_start_when_cancelled_does_not_emit(self) -> None: + def test_start_when_canceled_does_not_emit(self) -> None: service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) @@ -141,13 +141,13 @@ def test_start_reports_a_submit_failure(self) -> None: assert result is False - def test_cancel_sets_cancelled_flag(self) -> None: + def test_cancel_sets_canceled_flag(self) -> None: service = RegenerationService() - assert not service._cancelled + assert not service._canceled service.cancel() - assert service._cancelled + assert service._canceled class TestRegenerationServiceIsRunning: @@ -163,11 +163,11 @@ def test_is_running_delegates_to_the_executor(self) -> None: class TestRegenerationServiceRun: - def test_run_when_cancelled_emits_service_cancelled(self) -> None: + def test_run_when_canceled_emits_service_canceled(self) -> None: service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) - service._cancelled = True + service._canceled = True service._run( MagicMock(), @@ -178,7 +178,7 @@ def test_run_when_cancelled_emits_service_cancelled(self) -> None: ) assert len(results) == 1 - assert isinstance(results[0], ServiceCancelled) + assert isinstance(results[0], ServiceCanceled) def test_run_success_emits_service_success( self, diff --git a/tests/unit/sampletones_application/services/test_result.py b/tests/unit/sampletones_application/services/test_result.py index db1ed71a9..829598fda 100644 --- a/tests/unit/sampletones_application/services/test_result.py +++ b/tests/unit/sampletones_application/services/test_result.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, @@ -102,18 +102,18 @@ def test_same_instance_equals_itself(self) -> None: assert error == error # noqa: PLR0124 -class TestServiceCancelled: +class TestServiceCanceled: def test_instantiates(self) -> None: - cancelled = ServiceCancelled() - assert isinstance(cancelled, ServiceCancelled) + canceled = ServiceCanceled() + assert isinstance(canceled, ServiceCanceled) def test_frozen(self) -> None: - cancelled = ServiceCancelled() + canceled = ServiceCanceled() with pytest.raises(FrozenInstanceError): - cancelled.x = 1 # type: ignore[attr-defined] + canceled.x = 1 # type: ignore[attr-defined] def test_equality(self) -> None: - assert ServiceCancelled() == ServiceCancelled() + assert ServiceCanceled() == ServiceCanceled() class TestServiceIntermediate: diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index e5f8fa29a..5d2cf4377 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from sampletones_application.application import Application -from sampletones_application.services.result import ServiceCancelled +from sampletones_application.services.result import ServiceCanceled from sampletones_application.services.retune import RetunedSample from sampletones_core.project.voices.sample import Sample @@ -144,13 +144,13 @@ def test_does_not_dim_when_no_reconstruction_is_open(self) -> None: def test_restores_the_dim_when_the_batch_finishes(self) -> None: app = _app_for_rate([], open_reconstruction=None, running=False) - app._on_retune_result(ServiceCancelled()) + app._on_retune_result(ServiceCanceled()) app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(False) def test_keeps_the_dim_while_the_batch_is_running(self) -> None: app = _app_for_rate([], open_reconstruction=None, running=True) - app._on_retune_result(ServiceCancelled()) + app._on_retune_result(ServiceCanceled()) app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py index 1dfe4852b..f175443f9 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py @@ -312,7 +312,7 @@ def test_a_listening_cell_asks_for_the_press(self, harness: Harness) -> None: assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == CAPTURING_MESSAGE - def test_a_cancelled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None: + def test_a_canceled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None: harness.render(view_model(selected=SAVE_PROJECT)) harness.click_shortcut(SAVE_PROJECT) harness.press(dpg.mvKey_Escape) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py index 510e60bef..a770434a1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -83,11 +83,11 @@ def test_dropping_a_partial_entry_holds_the_selection(self) -> None: assert held.pending == "" def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: - cancelled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel() + canceled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel() - assert cancelled.region is None - assert cancelled.pending == "" - assert cancelled.cursor == _Cell(2, 1) + assert canceled.region is None + assert canceled.pending == "" + assert canceled.cursor == _Cell(2, 1) def test_a_committed_entry_leaves_the_cursor_alone(self) -> None: settled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3))._after_entry() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 9be209e76..549b19912 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -103,10 +103,10 @@ def test_typing_an_index_collapses_the_selection(self) -> None: assert committed.region is None def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: - cancelled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel() + canceled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel() - assert cancelled.region is None - assert cancelled.pending == "" + assert canceled.region is None + assert canceled.pending == "" class TestTarget: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 7c5c37c36..9a24a18d7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -119,10 +119,10 @@ def test_a_note_off_collapses_the_selection(self) -> None: assert typed.region is None def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: - cancelled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel() + canceled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel() - assert cancelled.region is None - assert cancelled.pending == "" + assert canceled.region is None + assert canceled.pending == "" def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: selected = _state(SubColumn.TRANSPOSE, row=4).extend_row(2, ROW_COUNT) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py index bd90b106b..e0aaae67e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py @@ -29,7 +29,7 @@ class VoicesPanelFixture: removed: List[str] = field(default_factory=list) moved: List[Move] = field(default_factory=list) renamed: List[str] = field(default_factory=list) - cancelled: List[None] = field(default_factory=list) + canceled: List[None] = field(default_factory=list) @pytest.fixture @@ -45,7 +45,7 @@ def voices(monkeypatch: pytest.MonkeyPatch) -> VoicesPanelFixture: panel.on_remove_requested = fixture.removed.append panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target)) monkeypatch.setattr(panel, "start_rename", fixture.renamed.append) - monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None)) + monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.canceled.append(None)) return fixture @@ -95,7 +95,7 @@ def test_the_cancel_key_drops_the_name_being_edited(self, voices: VoicesPanelFix voices.panel._editing_voice_id = SELECTED_ID assert voices.panel._on_key_pressed(_press("Esc")) is True - assert voices.cancelled == [None] + assert voices.canceled == [None] def test_every_other_key_stays_with_the_field(self, voices: VoicesPanelFixture) -> None: """A rename keeps the keyboard, so typing a name reaches the input rather than the list.""" diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py index 7dc40610e..4ad5efcc5 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py @@ -23,7 +23,7 @@ WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) CONFIRMED: Final[str] = "confirmed" -CANCELLED: Final[str] = "cancelled" +CANCELED: Final[str] = "canceled" OPTED_OUT: Final[str] = "opted_out" @@ -60,7 +60,7 @@ def render( path=path, opt_out_label=opt_out_label, on_opt_out=lambda: answers.append(OPTED_OUT) if answers is not None else None, - on_cancel=lambda: answers.append(CANCELLED) if answers is not None else None, + on_cancel=lambda: answers.append(CANCELED) if answers is not None else None, ) window.create_window() @@ -85,7 +85,7 @@ def test_cancel_runs_the_negative_answer_and_closes(self, window: GUIConfirmatio press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL)) - assert answers == [CANCELLED] + assert answers == [CANCELED] assert not dpg.does_item_exist(WINDOW_TAG) def test_a_ticked_opt_out_rides_the_confirmation(self, window: GUIConfirmationWindow) -> None: diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py index 55458c17a..d5d4b373f 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -59,7 +59,7 @@ def press(tag: str) -> None: class TestSaveConfirmationWindow: - def test_a_cancelled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None: + def test_a_canceled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None: answers: List[str] = [] render(window, save_succeeds=False, answers=answers) diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py index ca2ae9fca..d0b96015e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py @@ -37,10 +37,10 @@ class Harness: def __init__(self) -> None: self.router = KeyRouter() self.captured: List[KeyCombination] = [] - self.cancelled = 0 + self.canceled = 0 self.capture = KeyCapture(key_router=self.router, cancel=CANCEL) self.capture.on_captured = self.captured.append - self.capture.on_cancelled = self._on_cancelled + self.capture.on_canceled = self._on_canceled def press(self, key: int, modifiers: ModifierSet = NO_MODIFIERS) -> None: self.router.route(KeyEvent(key=key, modifiers=modifiers)) @@ -49,8 +49,8 @@ def press_all(self, events: Tuple[KeyEvent, ...]) -> None: for event in events: self.press(event.key, event.modifiers) - def _on_cancelled(self) -> None: - self.cancelled += 1 + def _on_canceled(self) -> None: + self.canceled += 1 @pytest.fixture(name="harness") @@ -234,16 +234,16 @@ def test_a_key_pressed_after_one_the_table_names_none_of_is_read(self, harness: assert harness.captured == [KeyCombination(dpg.mvKey_D, CTRL)] -class TestCancelledCapture: +class TestCanceledCapture: def test_the_cancel_combination_ends_the_capture_without_assigning(self, harness: Harness) -> None: harness.press(dpg.mvKey_Escape) assert harness.captured == [] - assert harness.cancelled == 1 + assert harness.canceled == 1 assert not harness.capture.is_listening def test_the_cancel_key_under_a_modifier_is_a_combination_like_any_other(self, harness: Harness) -> None: harness.press(dpg.mvKey_Escape, CTRL) assert harness.captured == [KeyCombination(dpg.mvKey_Escape, CTRL)] - assert harness.cancelled == 0 + assert harness.canceled == 0 diff --git a/tests/unit/sampletones_application/utils/parallelization/test_thread.py b/tests/unit/sampletones_application/utils/parallelization/test_thread.py index 018becfea..da62a4332 100644 --- a/tests/unit/sampletones_application/utils/parallelization/test_thread.py +++ b/tests/unit/sampletones_application/utils/parallelization/test_thread.py @@ -4,7 +4,7 @@ from unittest.mock import patch from sampletones_application.utils.parallelization.thread import ( - BackgroundWorkCancelled, + BackgroundWorkCanceled, SingleThreadExecutor, concurrent, ) @@ -100,11 +100,11 @@ def work(self) -> None: assert ran == [] - def test_cancelled_exception_unwinds_without_logging_an_error(self) -> None: + def test_canceled_exception_unwinds_without_logging_an_error(self) -> None: class Worker: @concurrent(wait=True) def work(self) -> None: - raise BackgroundWorkCancelled + raise BackgroundWorkCanceled with patch("sampletones_application.utils.parallelization.thread.logger") as logger: Worker().work() diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index b74daa833..e1377edc2 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -116,7 +116,7 @@ class TestPrimaryAction: (ConversionPhase.RUNNING, ConverterAction.CANCEL), (ConversionPhase.CANCELLING, ConverterAction.CANCEL), (ConversionPhase.COMPLETED, ConverterAction.CONVERT), - (ConversionPhase.CANCELLED, ConverterAction.CONVERT), + (ConversionPhase.CANCELED, ConverterAction.CONVERT), (ConversionPhase.FAILED, ConverterAction.CONVERT), ], ) @@ -141,7 +141,7 @@ def test_cancel_enablement(self, phase: ConversionPhase, enabled: bool) -> None: @pytest.mark.parametrize( "phase", - [ConversionPhase.COMPLETED, ConversionPhase.CANCELLED, ConversionPhase.FAILED], + [ConversionPhase.COMPLETED, ConversionPhase.CANCELED, ConversionPhase.FAILED], ) def test_convert_disabled_in_terminal_phases(self, phase: ConversionPhase) -> None: assert _view_model(phase=phase).primary_action_enabled is False diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py index f2f37bf01..310f50b62 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_render.py +++ b/tests/unit/sampletones_application/view_model/shared/test_render.py @@ -154,5 +154,5 @@ def test_a_song_holding_no_rows_starts_no_render(self) -> None: assert not view.render_enabled def test_an_outcome_releases_the_application(self) -> None: - for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELLED, RenderPhase.FAILED): + for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELED, RenderPhase.FAILED): assert not view_model(wave_settings(), phase=phase).is_active diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py index 722a259c6..eeb11889f 100644 --- a/tests/unit/sampletones_core/exports/test_famitracker.py +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -16,7 +16,7 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from tests.suite.progress import RecordingReporter @@ -205,7 +205,7 @@ def test_a_withdrawn_batch_leaves_the_slices_it_had_not_reached( build_instrument("lead", ENVELOPE_FRAMES), build_instrument("bass", ENVELOPE_FRAMES), ) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_sample(tmp_path / f"kit{EXT_FILE_INSTRUMENT}", sample, reporter) assert not (tmp_path / f"bass{EXT_FILE_INSTRUMENT}").exists() diff --git a/tests/unit/sampletones_core/exports/test_progress.py b/tests/unit/sampletones_core/exports/test_progress.py index d51d9d2a5..c1c9ae047 100644 --- a/tests/unit/sampletones_core/exports/test_progress.py +++ b/tests/unit/sampletones_core/exports/test_progress.py @@ -7,7 +7,7 @@ announce, ) from sampletones_core.exports.stage import ExportStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.progress import silent_reporter from tests.suite.progress import FIRST_REPORT, RecordingReporter @@ -38,10 +38,10 @@ class TestWithdrawingARun: def test_a_withdrawn_run_unwinds_where_it_was_told(self) -> None: reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) def test_a_withdrawal_names_the_stage_it_landed_on(self) -> None: reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled, match=ExportStage.WRITING.value): + with pytest.raises(OperationCanceled, match=ExportStage.WRITING.value): announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE) diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py index dd75d8e9d..2be4f2a4b 100644 --- a/tests/unit/sampletones_core/performance/test_song.py +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -8,7 +8,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.timing import SongTiming -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.performance import ( make_pulse_reconstruction, place_instrument, @@ -115,5 +115,5 @@ def test_the_walk_counts_up_as_it_goes(self) -> None: assert counted == sorted(counted) def test_a_withdrawn_walk_stops_where_it_was_told(self) -> None: - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): song_instructions(_project(), lambda progress: False) diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_progress.py b/tests/unit/sampletones_core/reconstructions/converter/test_progress.py index 6d77df2c1..7ea81a540 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_progress.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_progress.py @@ -6,7 +6,7 @@ from sampletones_core.reconstructions.converter.progress import JobReporter from sampletones_core.reconstructions.progress import ReconstructionProgress, announce from sampletones_core.reconstructions.stage import ReconstructionStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.progress import PROGRESS_STEPS from tests.suite.base import BaseTestSuite @@ -111,7 +111,7 @@ def test_a_withdrawn_job_unwinds_at_its_next_frame(self) -> None: line = RecordingLine(withdraw_after=1) reporter = JobReporter(line) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): for frame in range(FRAMES + 1): announce(reporter, ReconstructionStage.MATCHING, frame, FRAMES) diff --git a/tests/unit/sampletones_core/reconstructions/test_progress.py b/tests/unit/sampletones_core/reconstructions/test_progress.py index 7cc0d50c1..3e6194c80 100644 --- a/tests/unit/sampletones_core/reconstructions/test_progress.py +++ b/tests/unit/sampletones_core/reconstructions/test_progress.py @@ -8,7 +8,7 @@ announce, ) from sampletones_core.reconstructions.stage import STAGE_WEIGHTS, ReconstructionStage -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.progress import silent_reporter from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -124,7 +124,7 @@ def test_a_report_carries_the_stage_and_its_counts(self) -> None: def test_a_withdrawn_run_unwinds_where_it_stood(self) -> None: reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): announce(reporter, ReconstructionStage.MATCHING, 0, FRAMES) def test_a_caller_watching_nothing_hears_the_run_through(self) -> None: diff --git a/tests/unit/sampletones_player/compression/progress/test_monitor.py b/tests/unit/sampletones_player/compression/progress/test_monitor.py index 28b0c98d7..99585392a 100644 --- a/tests/unit/sampletones_player/compression/progress/test_monitor.py +++ b/tests/unit/sampletones_player/compression/progress/test_monitor.py @@ -6,7 +6,7 @@ from sampletones_player.compression.progress.report import ( CodecProgress, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.progress import silent_reporter from tests.suite.progress import FIRST_REPORT, RecordingReporter @@ -47,15 +47,15 @@ class TestWithdrawingARun: def test_a_withdrawn_reading_unwinds_the_run(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) def test_a_withdrawn_poll_unwinds_the_run(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): CodecMonitor(reporter).poll() def test_a_withdrawal_names_what_the_run_was_holding(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) - with pytest.raises(OperationCancelled, match=str(BYTES_LAID_DOWN)): + with pytest.raises(OperationCanceled, match=str(BYTES_LAID_DOWN)): CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index 3e1d8bc4d..2595c8a00 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -17,7 +17,7 @@ PHRASE_ID_ESCAPE, TokenTag, ) -from sampletones_shared.exceptions import OperationCancelled +from sampletones_shared.exceptions import OperationCanceled from tests.suite.progress import FIRST_REPORT, RecordingReporter EVERY_LAYER: Final[CodecOptions] = CodecOptions( @@ -175,7 +175,7 @@ class TestWithdrawingAnEncoding: def test_a_withdrawn_run_unwinds(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): encode_planes( planes, (), @@ -187,7 +187,7 @@ def test_a_withdrawn_run_unwinds(self) -> None: def test_a_withdrawn_run_stops_where_it_was_told(self) -> None: reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): encode_planes( planes, (), diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py index 6449987f3..3736dfb90 100644 --- a/tests/unit/sampletones_player/test_export.py +++ b/tests/unit/sampletones_player/test_export.py @@ -29,7 +29,7 @@ TITLE_OFFSET, ) from sampletones_player.specification.song import LOOP_TICK_OFFSET -from sampletones_shared.exceptions import OperationCancelled, SongTooLargeError +from sampletones_shared.exceptions import OperationCanceled, SongTooLargeError from sampletones_shared.paths.extensions import EXT_FILE_NSF from tests.suite.performance import ( make_pulse_reconstruction, @@ -341,7 +341,7 @@ def test_a_withdrawn_walk_leaves_no_file( ) -> None: destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_project(destination, ProjectExport(project=drum_project()), reporter) assert reporter.last.stage == ExportStage.WALKING @@ -394,7 +394,7 @@ def test_a_withdrawn_run_writes_nothing(self, backend: NSFBackend, tmp_path: Pat destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING) request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_sample(destination, request, reporter) assert not destination.exists() @@ -407,7 +407,7 @@ def test_a_run_withdrawn_mid_compression_writes_nothing( destination = tmp_path / FILENAME reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_COMPRESSING) request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY) - with pytest.raises(OperationCancelled): + with pytest.raises(OperationCanceled): backend.write_sample(destination, request, reporter) assert reporter.last.stage == ExportStage.COMPRESSING From 9c662fd64a5c4b444846f3cd79d9b3e4e3094aba Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 23:27:14 +0200 Subject: [PATCH 128/142] Kept: an instrument's release inside the FamiTracker item limit --- docs/development/bugs-and-todos.md | 9 +- docs/formats/famitracker.md | 33 +++-- .../reconstruction/instruments/instruments.py | 22 ++-- .../view_model/shared/export.py | 4 +- src/sampletones_core/exporters/truncation.py | 25 ---- .../exports/implementation/famitracker.py | 9 +- src/sampletones_core/features/envelope.py | 12 +- .../formats/famitracker/sequences/features.py | 110 ++++++++++++++-- .../famitracker/test_ftm_pipeline.py | 21 ++- .../exporters/implementation/test_release.py | 123 ++++++++++++++++++ .../exporters/test_truncation.py | 19 --- .../features/test_envelope.py | 18 +-- .../famitracker/sequences/test_features.py | 84 ++++++++++++ 13 files changed, 380 insertions(+), 109 deletions(-) create mode 100644 tests/unit/sampletones_core/exporters/implementation/test_release.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f56d1380b..0e301b222 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -62,7 +62,14 @@ starts carrying. and the panels beside them. The language-keys check expands such a helper over the whole enum, so a member no call names is reached all the same and stands unnoticed. Spelling those keys literally at the call site would make each entry exactly checkable and retire the enums that remain. -* Respecting FamiTracker limitations +* Respecting FamiTracker limitations at the writers. A target format's ceilings belong to the + code that writes that format: an envelope carries whatever length a reader wrote, and meets a + limit where a file is built. `formats/famitracker/sequences/features.py` is where the 252-item + sequence ceiling applies today, and it is the one place that decides what a file holds, which + both the export's report and the instruments panel's warning read. What is still owed is the + same treatment for the ceilings a module carries — the instrument, sequence and pattern counts + in `specification/` — so a project past one of them is reported to the reader rather than + refused by the writer. * Per-tab undo routing * In-application console * Improve performance of browser favorite scan of the entire tree per click diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index a8ec43fb4..549667748 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -155,10 +155,20 @@ dimension therefore carries the length it was written at: a two-item volume enve beside a one-item duty envelope plays exactly as a padded pair would, and costs the padding less. -Every length stays within the 252 items a FamiTracker sequence holds, so a reconstruction -longer than 252 frames — 8.4 s at the default 30 fps — exports its opening 252 frames and -logs the shortening. The instruments panel colors a sequence input warning orange once it -passes that length, so the limit is visible before an export. +**The release.** A volume envelope whose frames end audible carries one silent item past +them, and that item is what stops the note: the driver holds a halted sequence's last value +for as long as a row keeps the note sounding, so a volume envelope ending audible would sound +to the end of the song. Every generator writes that item, so a volume dimension runs one item +longer than the frames it describes. + +**The item limit.** A FamiTracker sequence holds 252 items, and that ceiling belongs to this +writer: an envelope carries whatever length it was written at, and meets the limit only here. +A dimension over it is written as its opening items, and a volume dimension keeps its release +as the last of them — the note has to end, so the release displaces the sounding item that +would not fit. A reconstruction therefore reaches the limit at 252 frames, since its volume +carries the release past them; that is 8.4 s at the default 30 fps. The export reports what it +left out, and the instruments panel colors a sequence input warning orange while a file would +hold only part of it, so the limit is visible before an export. An empty dimension is written as a disabled sequence, which is a different instrument from one carrying a single zero: the disabled slot leaves that dimension to the channel, while a @@ -237,18 +247,19 @@ the reader cannot take leaves the project as it stood and the history without an ## D. FamiTracker capacity limits -FamiTracker bounds several quantities that the _SampleToNES_ `Project` currently -leaves looser. The exporter guards these limits, so every file it writes loads: it -raises on a project structure FamiTracker has no room for, and shortens an envelope -that outruns a sequence. Enforcing them on the domain model — so the editor prevents -reaching an unexportable state — is planned as a follow-up phase; this table is that -checklist. +FamiTracker bounds several quantities, and those bounds belong to this writer. A project +holds what a reader wrote — an envelope of any length, a pool of any size — and meets a +limit where a file is built, so the editor stays free of a format it may never export to. +The writer guards each limit, so every file it writes loads: it raises on a project +structure FamiTracker has no room for, and shortens an envelope that outruns a sequence +while keeping the release that ends its note. What each limit costs is reported to the +reader; this table is where those answers are stated. | Quantity | FamiTracker limit | Project bound today | Exporter behavior | | --- | --- | --- | --- | | Instruments | 64 total | unbounded (1–4 per sample, one per hand-written instrument) | raises when the instruments exceed 64 | | Sequences per kind | 128 | unbounded | raises when a kind's pool exceeds 128 | -| Items per sequence | 252 | one item per reconstruction frame, unbounded | keeps the opening 252 items and logs a warning | +| Items per sequence | 252 | one item per frame, plus the volume's release; unbounded | keeps the opening items, a volume dimension ending at its release, and reports what it left out | | Patterns per channel | 128 (indices 0–127) | pool keyed by arbitrary ints | raises when a pattern index exceeds 127 | | Order frames | 128 | unbounded | raises when the order exceeds 128 frames | | Pattern length (rows) | 256 | 1–256 (`rows_per_pattern`) | matches; no guard needed | diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 437b2ae1e..fa013f834 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -98,8 +98,9 @@ ) from sampletones_core.features.envelope import Envelope from sampletones_core.features.text import format_envelope, parse_envelope -from sampletones_core.formats.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, +from sampletones_core.formats.famitracker.sequences.features import ( + is_shortened, + stored_envelope, ) from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, @@ -930,13 +931,13 @@ def _sequence_status_message( *_args: Any, **_kwargs: Any, ) -> str: - """Describes the sequence input, naming the export limit once a sequence passes it.""" - item_count = len(self._standing_sequence(channel_name, feature_key).items) - if item_count > MAX_SEQUENCE_ITEMS: + """Describes the sequence input, naming what a FamiTracker file holds of an over-long one.""" + envelope = self._standing_sequence(channel_name, feature_key) + if is_shortened(feature_key, envelope): return self._language_manager["reconstructions.instruments.message.status_sequence_too_long"].format( instrument_feature=feature_key.capitalized, - items=item_count, - limit=MAX_SEQUENCE_ITEMS, + items=len(envelope.items), + limit=len(stored_envelope(feature_key, envelope).items), ) return self._language_manager["reconstructions.instruments.message.status_sequence"].format( @@ -959,14 +960,13 @@ def _show_sequence( ) -> None: """Holds the dimension the input now shows, colored by how a FamiTracker export treats its length. - A sequence longer than ``MAX_SEQUENCE_ITEMS`` exports its opening items, so the - input carries the warning color to show which part of the envelope reaches a - FamiTracker file. + A sequence a FamiTracker file holds only part of carries the warning color, so which + dimensions reach that file whole is visible before an export. """ self._sequences[(channel_name, feature_key)] = envelope text_group_tag = self._get_feature_text_group_tag(channel_name, feature_key) raw_data_tag = self._get_feature_text_tag(text_group_tag) - theme = self.warning_input_theme if len(envelope.items) > MAX_SEQUENCE_ITEMS else self.theme + theme = self.warning_input_theme if is_shortened(feature_key, envelope) else self.theme theme.bind_to_item(raw_data_tag) def _parse_raw_data_input( diff --git a/src/sampletones_application/view_model/shared/export.py b/src/sampletones_application/view_model/shared/export.py index 40d9fd6bc..dde9fb2ec 100644 --- a/src/sampletones_application/view_model/shared/export.py +++ b/src/sampletones_application/view_model/shared/export.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import StrEnum from typing import Final, FrozenSet, Optional, Tuple @@ -47,7 +49,7 @@ class SongExportViewModel(BaseModel, frozen=True): travelling: bool @classmethod - def idle(cls) -> "SongExportViewModel": + def idle(cls) -> SongExportViewModel: """The dialog with no run behind it, which is what the window opens on.""" return cls( phase=ExportPhase.IDLE, diff --git a/src/sampletones_core/exporters/truncation.py b/src/sampletones_core/exporters/truncation.py index 269f5834d..575a29626 100644 --- a/src/sampletones_core/exporters/truncation.py +++ b/src/sampletones_core/exporters/truncation.py @@ -18,31 +18,6 @@ class EnvelopeTruncation: source_frames: int instruments: int - @classmethod - def measure( - cls, - source_frames: int, - limit: Optional[int], - ) -> Optional[EnvelopeTruncation]: - """Reports what an export of one instrument's envelopes keeps. - - Args: - source_frames: The frame count the envelopes arrived with. - limit: The most items the target format stores, or ``None`` when it is unbounded. - - Returns: - Optional[EnvelopeTruncation]: The shortening the limit imposes, and ``None`` - when the envelopes fit whole. - """ - if limit is None or source_frames <= limit: - return None - - return cls( - frames=limit, - source_frames=source_frames, - instruments=1, - ) - @classmethod def summarize( cls, diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py index 374210d65..9346e2365 100644 --- a/src/sampletones_core/exports/implementation/famitracker.py +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -15,12 +15,10 @@ from sampletones_core.formats.famitracker.builder import build_instrument from sampletones_core.formats.famitracker.export import write_ftm from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.sequences.features import features_truncation from sampletones_core.formats.famitracker.specification.instruments import ( STANDALONE_INSTRUMENT_INDEX, ) -from sampletones_core.formats.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, -) from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_shared.utils.progress import silent_reporter from sampletones_shared.utils.system.paths import get_filename @@ -67,10 +65,7 @@ def write_instrument( return ExportArtifact( paths=(destination,), - truncation=EnvelopeTruncation.measure( - request.features.frame_count, - MAX_SEQUENCE_ITEMS, - ), + truncation=features_truncation(request.features), ) def write_sample( diff --git a/src/sampletones_core/features/envelope.py b/src/sampletones_core/features/envelope.py index f90c2f311..38cbb824b 100644 --- a/src/sampletones_core/features/envelope.py +++ b/src/sampletones_core/features/envelope.py @@ -2,8 +2,6 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from sampletones_shared.logger import logger - ItemT = TypeVar("ItemT") @@ -71,13 +69,14 @@ def at(self, tick: int) -> Optional[ItemT]: return self.items[self.loop_point + (tick - self.loop_point) % cycle] def limited(self, limit: int) -> "Envelope[ItemT]": - """This dimension's opening items, as many as a target format stores. + """This dimension's opening items, at most ``limit`` of them. A point standing past what survives moves to the last item kept, which is the value the - dimension would hold there anyway. + dimension would hold there anyway. Whoever states the limit is where it comes from, so a + dimension carries whatever length it was written at until a target asks for less. Args: - limit: The most items the target format stores. + limit: The most items to keep. Returns: Envelope[ItemT]: The dimension within that limit, with its point kept inside it. @@ -85,9 +84,6 @@ def limited(self, limit: int) -> "Envelope[ItemT]": if len(self.items) <= limit: return self - logger.debug( - f"Instrument envelope of {len(self.items)} items keeps its first {limit}, the most the format holds" - ) return self._holding(self.items[:limit]) def resized(self, length: int) -> "Envelope[ItemT]": diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index c40fd2fee..a03a1fa21 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -1,6 +1,9 @@ -from typing import Dict +from typing import Dict, Final, Optional +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( @@ -10,16 +13,17 @@ SequenceKind, ) +ONE_INSTRUMENT: Final[int] = 1 + def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, InstrumentSequence]: """Builds the five 2A03 sequences from a channel slice's envelopes. Each dimension becomes an :class:`InstrumentSequence`; one the generator lacks, or one left - to the channel, becomes a disabled sequence the instrument stores nothing for. Item counts - stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer reconstruction - exports its opening frames and the shortening is logged. Every dimension keeps the length it - was written at and the item it repeats from, which is how FamiTracker advances each sequence - on a counter of its own. + to the channel, becomes a disabled sequence the instrument stores nothing for. Every dimension + keeps the length it was written at and the item it repeats from, which is how FamiTracker + advances each sequence on a counter of its own, and each stands within the items the file + holds — see :func:`stored_envelope`. Args: features: The per-dimension envelopes describing the slice. @@ -27,15 +31,101 @@ def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, I Returns: Dict[SequenceKind, InstrumentSequence]: The sequences, one per dimension FamiTracker holds. """ - written = { - FEATURE_KEY_TO_SEQUENCE_KIND[feature_key]: envelope.limited(MAX_SEQUENCE_ITEMS) + stored = _stored_envelopes(features) + return {kind: _sequence(kind, stored.get(kind, Envelope[int]())) for kind in SequenceKind} + + +def stored_envelope( + feature_key: FeatureKey, + envelope: Envelope[int], +) -> Envelope[int]: + """One dimension as a FamiTracker file holds it, within the items a sequence stores. + + The item limit belongs to the file: an envelope carries whatever length it was written at, and + this is where a longer one meets what the format stores. A dimension over the limit keeps its + opening items, and a volume dimension ending at silence keeps that silence as its last item — + the release is what ends a note, so it is the one item worth a place of its own. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + Envelope[int]: The dimension within the items the file holds. + """ + if feature_key is FeatureKey.VOLUME and _releases(envelope): + return _keeping_release(envelope, MAX_SEQUENCE_ITEMS) + + return envelope.limited(MAX_SEQUENCE_ITEMS) + + +def is_shortened(feature_key: FeatureKey, envelope: Envelope[int]) -> bool: + """Whether a FamiTracker file leaves items out of this dimension. + + A reader watching an envelope grow and an export reporting what it wrote read the same answer, + so the length a file holds is decided in one place. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + bool: Whether the file holds fewer items than the dimension carries. + """ + return len(stored_envelope(feature_key, envelope).items) < len(envelope.items) + + +def features_truncation(features: Features) -> Optional[EnvelopeTruncation]: + """What a FamiTracker file leaves out of one instrument's envelopes. + + Args: + features: The per-dimension envelopes describing the instrument. + + Returns: + Optional[EnvelopeTruncation]: The shortening the file imposes, and ``None`` where every + dimension is held whole. + """ + source_frames = features.frame_count + stored = max( + (len(envelope.items) for envelope in _stored_envelopes(features).values()), + default=0, + ) + if stored >= source_frames: + return None + + return EnvelopeTruncation( + frames=stored, + source_frames=source_frames, + instruments=ONE_INSTRUMENT, + ) + + +def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]: + """Each dimension the slice offers, as the file holds it.""" + return { + FEATURE_KEY_TO_SEQUENCE_KIND[feature_key]: stored_envelope(feature_key, envelope) for feature_key, envelope in features.envelopes.items() } - return {kind: _sequence(kind, written.get(kind, Envelope[int]())) for kind in SequenceKind} + +def _releases(envelope: Envelope[int]) -> bool: + """Whether a volume dimension ends by silencing the note, which is what releases it.""" + return bool(envelope.items) and envelope.items[-1] == SILENT_VOLUME and not envelope.loops + + +def _keeping_release(envelope: Envelope[int], limit: int) -> Envelope[int]: + """This dimension within ``limit`` items, the last of them the release it ends on.""" + if len(envelope.items) <= limit: + return envelope + + opening = envelope.limited(limit - 1) + return opening.with_items(opening.items + envelope.items[-1:]) -def _sequence(kind: SequenceKind, envelope: Envelope[int]) -> InstrumentSequence: +def _sequence( + kind: SequenceKind, + envelope: Envelope[int], +) -> InstrumentSequence: """One sequence as the file states it, with the item the dimension repeats from.""" return InstrumentSequence( kind=kind, diff --git a/tests/integration/famitracker/test_ftm_pipeline.py b/tests/integration/famitracker/test_ftm_pipeline.py index 952e39936..2d24b22b3 100644 --- a/tests/integration/famitracker/test_ftm_pipeline.py +++ b/tests/integration/famitracker/test_ftm_pipeline.py @@ -4,10 +4,14 @@ from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.formats.famitracker.export import write_ftm from sampletones_core.formats.famitracker.specification.channels import ChannelId from sampletones_core.formats.famitracker.specification.file import FTM_VERSION -from sampletones_core.formats.famitracker.specification.sequences import SequenceKind +from sampletones_core.formats.famitracker.specification.sequences import ( + NO_LOOP_POINT, + SequenceKind, +) from sampletones_core.project.project import Project from tests.suite.famitracker import ParsedModule, parse_ftm @@ -50,6 +54,21 @@ def test_module_carries_audible_volume(self, parsed_module: ParsedModule) -> Non ] assert any(any(item > 0 for item in sequence.items) for sequence in volume_sequences) + def test_every_written_instrument_releases_its_note(self, parsed_module: ParsedModule) -> None: + """A halted volume sequence ends at silence, which is what stops the note it sounds. + + FamiTracker holds a halted sequence's last item for as long as a row keeps the note + sounding, so an instrument whose volume ended audible would sound to the end of the song. + """ + halting = [ + sequence + for sequence in parsed_module.sequences + if sequence.sequence_type == int(SequenceKind.VOLUME) and sequence.loop_point == NO_LOOP_POINT + ] + + assert halting + assert all(sequence.items[-1] == SILENT_VOLUME for sequence in halting) + class TestSilencedChannelsReachTheModuleWhole: """A silenced channel belongs to the listening session, so the module still carries it. diff --git a/tests/unit/sampletones_core/exporters/implementation/test_release.py b/tests/unit/sampletones_core/exporters/implementation/test_release.py new file mode 100644 index 000000000..6196910fc --- /dev/null +++ b/tests/unit/sampletones_core/exporters/implementation/test_release.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass +from typing import Callable, Final, List, Sequence, Tuple + +import pytest + +from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH, SILENT_VOLUME +from sampletones_core.exporters.implementation.noise import NoiseExporter +from sampletones_core.exporters.implementation.pulse import PulseExporter +from sampletones_core.exporters.implementation.triangle import TriangleExporter +from sampletones_core.instructions.implementation.noise import NoiseInstruction +from sampletones_core.instructions.implementation.pulse import PulseInstruction +from sampletones_core.instructions.implementation.triangle import TriangleInstruction +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +VOLUMES: Final[int] = 2 + +SOUNDING: Final[int] = 12 +LOUDER: Final[int] = 15 + + +def pulse_volumes(levels: Sequence[int]) -> List[int]: + instructions = [ + PulseInstruction(on=level > SILENT_VOLUME, pitch=MIN_PITCH, volume=level, duty_cycle=0) for level in levels + ] + return PulseExporter.extract_data(instructions)[VOLUMES] + + +def triangle_volumes(levels: Sequence[int]) -> List[int]: + instructions = [TriangleInstruction(on=level > SILENT_VOLUME, pitch=MIN_PITCH) for level in levels] + return TriangleExporter.extract_data(instructions)[VOLUMES] + + +def noise_volumes(levels: Sequence[int]) -> List[int]: + instructions = [NoiseInstruction(on=level > SILENT_VOLUME, period=0, volume=level, short=False) for level in levels] + return NoiseExporter.extract_data(instructions)[VOLUMES] + + +class TestTheReleaseEveryGeneratorWrites(BaseTestSuite): + """A channel that stops sounding writes the silence that stops it. + + A FamiTracker sequence halts on its last item and holds that value for as long as the note + sounds, so a volume envelope whose frames end audible carries one silent item past them to + release the note. Every generator writes that item, which is what a reader edits in the + instruments panel and what an export keeps within the items the file holds. + """ + + @dataclass(frozen=True, kw_only=True) + class Writer(BaseAutolabelTestCase): + """One channel's exporter, and the level a sounding frame reaches it at. + + The triangle plays at one level, so a frame there is audible or silent rather than graded, + and a case states its levels once for every channel to read what it can of. + """ + + expected: str + volumes: Callable[[Sequence[int]], List[int]] + plays_at_one_level: bool + + @property + def label(self) -> str: + return self.expected + + def written(self, level: int) -> int: + """The value this channel writes for a frame stated at ``level``.""" + if level == SILENT_VOLUME: + return SILENT_VOLUME + + return MAX_VOLUME if self.plays_at_one_level else level + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + levels: Tuple[int, ...] + ending: str + + @property + def label(self) -> str: + return self.ending + + writers = ( + Writer(expected="pulse", volumes=pulse_volumes, plays_at_one_level=False), + Writer(expected="triangle", volumes=triangle_volumes, plays_at_one_level=True), + Writer(expected="noise", volumes=noise_volumes, plays_at_one_level=False), + ) + + test_cases = ( + TestCase( + ending="ends_sounding", + levels=(SOUNDING, SOUNDING), + expected=(SOUNDING, SOUNDING, SILENT_VOLUME), + ), + TestCase( + ending="ends_silent", + levels=(SOUNDING, SILENT_VOLUME), + expected=(SOUNDING, SILENT_VOLUME), + ), + TestCase( + ending="one_sounding_frame", + levels=(SOUNDING,), + expected=(SOUNDING, SILENT_VOLUME), + ), + TestCase( + ending="nothing_written", + levels=(), + expected=(), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + @pytest.mark.parametrize("writer", writers, ids=lambda writer: writer.label) + def test_the_volume_written_ends_where_the_channel_stops_sounding( + self, + writer: Writer, + test_case: TestCase, + ) -> None: + expected = tuple(writer.written(level) for level in test_case.expected) + assert tuple(writer.volumes(test_case.levels)) == expected + + @pytest.mark.parametrize("writer", writers, ids=lambda writer: writer.label) + def test_a_louder_ending_releases_the_same_way(self, writer: Writer) -> None: + """The release follows from the channel going quiet, whatever level it went quiet from.""" + assert writer.volumes((SOUNDING, LOUDER))[-1] == SILENT_VOLUME diff --git a/tests/unit/sampletones_core/exporters/test_truncation.py b/tests/unit/sampletones_core/exporters/test_truncation.py index 71803658d..9249292a6 100644 --- a/tests/unit/sampletones_core/exporters/test_truncation.py +++ b/tests/unit/sampletones_core/exporters/test_truncation.py @@ -1,29 +1,10 @@ from typing import Final -import pytest - from sampletones_core.exporters.truncation import EnvelopeTruncation ITEM_LIMIT: Final[int] = 252 -class TestEnvelopeTruncationMeasure: - @pytest.mark.parametrize( - "source_frames", - [0, 1, ITEM_LIMIT], - ids=["empty", "single", "at_the_limit"], - ) - def test_an_envelope_within_the_limit_reports_nothing(self, source_frames: int) -> None: - assert EnvelopeTruncation.measure(source_frames, ITEM_LIMIT) is None - - def test_an_envelope_beyond_the_limit_reports_both_counts(self) -> None: - truncation = EnvelopeTruncation.measure(300, ITEM_LIMIT) - assert truncation == EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=300, instruments=1) - - def test_an_unbounded_format_reports_nothing(self) -> None: - assert EnvelopeTruncation.measure(100_000, None) is None - - class TestEnvelopeTruncationSummarize: def test_instruments_that_all_fit_report_nothing(self) -> None: assert EnvelopeTruncation.summarize([None, None]) is None diff --git a/tests/unit/sampletones_core/features/test_envelope.py b/tests/unit/sampletones_core/features/test_envelope.py index 58707bc8a..fea15e1a9 100644 --- a/tests/unit/sampletones_core/features/test_envelope.py +++ b/tests/unit/sampletones_core/features/test_envelope.py @@ -1,4 +1,3 @@ -import logging from dataclasses import dataclass from typing import Final, Optional, Tuple @@ -6,7 +5,6 @@ from pydantic import ValidationError from sampletones_core.features.envelope import Envelope -from sampletones_shared.application import SAMPLETONES_NAME from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -80,7 +78,9 @@ def test_a_point_on_a_dimension_writing_nothing_is_refused(self) -> None: Envelope[int](items=(), loop_point=0) -class TestADimensionWithinAFormatsLimit: +class TestADimensionWithinALimit: + """``limited`` states no limit of its own: whoever asks for one is where it comes from.""" + def test_a_dimension_within_the_limit_stands_as_written(self) -> None: envelope = Envelope[int](items=(15, 12, 9, 0)) @@ -101,18 +101,6 @@ def test_a_point_inside_what_survives_stays_where_it_was(self) -> None: assert limited.loop_point == 4 - def test_an_over_long_dimension_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - Envelope[int](items=items_of(ITEM_LIMIT + 1)).limited(ITEM_LIMIT) - - assert str(ITEM_LIMIT) in caplog.text - - def test_a_dimension_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level(logging.DEBUG, logger=SAMPLETONES_NAME): - Envelope[int](items=items_of(ITEM_LIMIT)).limited(ITEM_LIMIT) - - assert caplog.text == "" - class TestADimensionBroughtToALength: def test_a_shorter_dimension_holds_its_final_value(self) -> None: diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 1d18df018..785e7ff09 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -1,9 +1,13 @@ from typing import Final, Optional, Sequence +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME from sampletones_core.exporters.feature import Features from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, + features_truncation, + is_shortened, ) from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, @@ -13,6 +17,17 @@ ) REFERENCE_PITCH: Final[int] = 60 +RELEASE: Final[int] = SILENT_VOLUME + + +def sounding(length: int) -> Sequence[int]: + """A volume dimension of ``length`` items that never falls silent.""" + return [MAX_VOLUME - index % MAX_VOLUME for index in range(length)] + + +def released(length: int) -> Sequence[int]: + """A volume dimension of ``length`` items whose last one releases the note.""" + return list(sounding(length - 1)) + [RELEASE] def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]: @@ -133,3 +148,72 @@ def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> Non sequences = features_to_instrument_sequences(build([index % 16 for index in range(length)], [0] * length)) assert all(len(sequence.items) <= MAX_SEQUENCE_ITEMS for sequence in sequences.values()) + + +class TestTheReleaseAnExportKeeps: + """A volume dimension ending at silence is what releases a note, so the file keeps that item. + + FamiTracker halts a sequence on its last item and holds the value there for as long as the + note sounds, so a shortened volume dimension that dropped its silence would sound forever. + """ + + def test_a_released_dimension_at_the_limit_is_written_whole(self) -> None: + sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS), [])) + volume = sequences[SequenceKind.VOLUME] + assert len(volume.items) == MAX_SEQUENCE_ITEMS + assert volume.items[-1] == RELEASE + + def test_a_released_dimension_past_the_limit_still_ends_at_its_release(self) -> None: + sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS + 1), [])) + volume = sequences[SequenceKind.VOLUME] + assert len(volume.items) == MAX_SEQUENCE_ITEMS + assert volume.items[-1] == RELEASE + + def test_the_release_displaces_the_last_item_that_would_not_fit(self) -> None: + source = released(MAX_SEQUENCE_ITEMS + 1) + volume = features_to_instrument_sequences(build(source, []))[SequenceKind.VOLUME] + assert volume.items == tuple(source[: MAX_SEQUENCE_ITEMS - 1]) + (RELEASE,) + + def test_a_dimension_that_goes_on_sounding_keeps_its_opening_items(self) -> None: + source = sounding(MAX_SEQUENCE_ITEMS + 8) + volume = features_to_instrument_sequences(build(source, []))[SequenceKind.VOLUME] + assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS]) + + def test_a_circling_dimension_reads_its_final_silence_as_part_of_the_cycle(self) -> None: + """A dimension repeating from a point never halts, so its last item releases nothing.""" + source = released(MAX_SEQUENCE_ITEMS + 1) + volume = features_to_instrument_sequences(build(source, [], loop_point=0))[SequenceKind.VOLUME] + assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS]) + + def test_a_dimension_other_than_volume_keeps_its_opening_items(self) -> None: + """Only a volume dimension releases a note; the rest are read at whatever they last stated.""" + source = [index % 8 for index in range(MAX_SEQUENCE_ITEMS + 8)] + source[-1] = 0 + arpeggio = features_to_instrument_sequences(build(released(4), source))[SequenceKind.ARPEGGIO] + assert arpeggio.items == tuple(source[:MAX_SEQUENCE_ITEMS]) + + +class TestWhetherAnExportShortensADimension: + def test_a_dimension_within_the_limit_is_written_whole(self) -> None: + assert is_shortened(FeatureKey.VOLUME, envelope(released(MAX_SEQUENCE_ITEMS))) is False + + def test_a_dimension_past_the_limit_is_shortened(self) -> None: + assert is_shortened(FeatureKey.VOLUME, envelope(released(MAX_SEQUENCE_ITEMS + 1))) is True + + def test_keeping_the_release_still_counts_as_shortening(self) -> None: + """The release survives, so one sounding item is what the file leaves out.""" + source = envelope(released(MAX_SEQUENCE_ITEMS + 1)) + assert is_shortened(FeatureKey.VOLUME, source) is True + + +class TestWhatAnExportReportsLeavingOut: + def test_features_within_the_limit_report_nothing(self) -> None: + assert features_truncation(build(released(MAX_SEQUENCE_ITEMS), [0])) is None + + def test_features_past_the_limit_report_both_counts(self) -> None: + source_frames = MAX_SEQUENCE_ITEMS + 48 + truncation = features_truncation(build(released(source_frames), [])) + assert truncation is not None + assert truncation.source_frames == source_frames + assert truncation.frames == MAX_SEQUENCE_ITEMS + assert truncation.instruments == 1 From edd41bd1f9f98148a4198a06c9d058b031bb767d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 23:40:54 +0200 Subject: [PATCH 129/142] Renamed: the voice history actions and the spelling of a traveling stage --- .../categories/elements/global_.py | 4 ++-- .../coordinators/tabs/sequencer.py | 18 +++++++++--------- .../logic/export/logic.py | 6 +++--- .../logic/history/action.py | 10 +++++----- .../services/export/reporter.py | 4 ++-- .../services/progress.py | 2 +- .../view_model/shared/export.py | 10 +++++----- src/sampletones_config/lang/en.yaml | 14 +++++++------- src/sampletones_core/exports/stage.py | 4 ++-- src/sampletones_core/features/envelope.py | 12 +++++++----- .../project/voices/envelopes.py | 8 +++++++- .../coordinators/tabs/test_sequencer.py | 10 +++++----- .../logic/export/test_logic.py | 4 ++-- .../logic/history/test_fingerprint.py | 2 +- .../ui/panels/dialogs/test_export.py | 14 +++++++------- .../view_model/shared/test_export.py | 10 +++++----- 16 files changed, 70 insertions(+), 62 deletions(-) diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 8f08d4cc9..e41ede4a9 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -143,7 +143,7 @@ class GlobalMessageElements(AbstractElement): CONFIGURATION_RECOVERY_PATH_PREFIX = "configuration_recovery_path_prefix" AUDIO_PLAYBACK_ERROR = "audio_playback_error" NO_PROJECT_OPEN = "no_project_open" - REMOVE_SAMPLE = "remove_sample" + REMOVE_VOICE = "remove_voice" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" OPERATION_IN_PROGRESS = "operation_in_progress" @@ -175,7 +175,7 @@ class GlobalDialogTitleElements(AbstractElement): OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" NO_PROJECT_OPEN = "no_project_open" - REMOVE_SAMPLE = "remove_sample" + REMOVE_VOICE = "remove_voice" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" ABOUT = "about" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 02b9c9ac9..00f0d2316 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -636,13 +636,13 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_voices_panel.on_remove_requested = self._remove_voice self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice self._sequencer_voices_panel.on_move_requested = self._undoable( - HistoryAction.MOVE_SAMPLE, + HistoryAction.MOVE_VOICE, self._sequencer_voices_logic.move_voice, detail=self._history_detail.move_voice, ) self._sequencer_voices_panel.on_rename_committed = self._submit_rename self._sequencer_voices_panel.on_duplicate_requested = self._undoable( - HistoryAction.DUPLICATE_SAMPLE, + HistoryAction.DUPLICATE_VOICE, self._sequencer_voices_logic.duplicate_voice, detail=self._history_detail.duplicate_voice, ) @@ -1436,9 +1436,9 @@ def _on_sample_selected(self, voice_id: str) -> None: logger.debug(f"Sequencer sample selected: {voice_id}") def _remove_voice(self, voice_id: str) -> None: - """Removes a sample, confirming first only when a pattern still references it. + """Removes a voice, confirming first only when a pattern still references it. - An unused sample is dropped silently; a referenced one would clear every row + An unused voice is dropped silently; a referenced one would clear every row that points at it, so the user confirms that loss first. """ if not self._sequencer_voices_logic.is_voice_used(voice_id): @@ -1448,8 +1448,8 @@ def _remove_voice(self, voice_id: str) -> None: name = self._sequencer_voices_logic.voice_name(voice_id) self._dialogs.show_confirmation( tag=TAG_SEQUENCER_VOICES_DIALOG_REMOVE, - title=self._language_manager["global.dialog.title.remove_sample"], - message=self._language_manager["global.dialog.message.remove_sample"].format(name=name), + title=self._language_manager["global.dialog.title.remove_voice"], + message=self._language_manager["global.dialog.message.remove_voice"].format(name=name), on_confirm=lambda: self._perform_remove_voice(voice_id), ok_label=self._language_manager["global.dialog.label.remove"], ) @@ -1457,18 +1457,18 @@ def _remove_voice(self, voice_id: str) -> None: def _perform_remove_voice(self, voice_id: str) -> None: detail = self._history_detail.remove_voice(voice_id) with self._history.transaction( - HistoryAction.REMOVE_SAMPLE, + HistoryAction.REMOVE_VOICE, detail=detail, ): self._sequencer_voices_logic.remove_voice(voice_id) def _submit_rename(self, voice_id: str, name: str) -> None: - """Applies an inline rename, ignoring a blank name so the sample keeps its current one.""" + """Applies an inline rename, ignoring a blank name so the voice keeps its current one.""" stripped = name.strip() if stripped: detail = self._history_detail.rename_voice(voice_id, stripped) with self._history.transaction( - HistoryAction.RENAME_SAMPLE, + HistoryAction.RENAME_VOICE, detail=detail, ): self._sequencer_voices_logic.rename_voice(voice_id, stripped) diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index 95453db65..3da132ced 100644 --- a/src/sampletones_application/logic/export/logic.py +++ b/src/sampletones_application/logic/export/logic.py @@ -16,7 +16,7 @@ ExportPhase, SongExportViewModel, ) -from sampletones_core.exports.stage import TRAVELLING_STAGES, ExportStage +from sampletones_core.exports.stage import TRAVELING_STAGES, ExportStage from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -116,7 +116,7 @@ def _on_progress(self, progress: ServiceProgress[ExportStage]) -> None: return self._reach(stage) - self._travelling = stage in TRAVELLING_STAGES + self._travelling = stage in TRAVELING_STAGES self._progress = progress.fraction self._figure = self._figure_text(progress) self._emit_view() @@ -154,5 +154,5 @@ def _view_model(self) -> SongExportViewModel: stages=tuple(self._stages), figure=self._figure, progress=self._progress, - travelling=self._travelling, + traveling=self._travelling, ) diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index ca2dfa7cd..8bfb66fbd 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -26,13 +26,13 @@ class HistoryAction(AbstractElement): MOVE_FRAME = "move_frame" SET_ORDER_ENTRY = "set_order_entry" ADD_SAMPLE = "add_sample" - REMOVE_SAMPLE = "remove_sample" - REPLACE_SAMPLE = "replace_sample" - RENAME_SAMPLE = "rename_sample" - MOVE_SAMPLE = "move_sample" - DUPLICATE_SAMPLE = "duplicate_sample" ADD_INSTRUMENT = "add_instrument" + REPLACE_SAMPLE = "replace_sample" EDIT_INSTRUMENT = "edit_instrument" + RENAME_VOICE = "rename_voice" + MOVE_VOICE = "move_voice" + DUPLICATE_VOICE = "duplicate_voice" + REMOVE_VOICE = "remove_voice" SET_TEMPO = "set_tempo" SET_SPEED = "set_speed" SET_NES_FREQUENCY = "set_nes_frequency" diff --git a/src/sampletones_application/services/export/reporter.py b/src/sampletones_application/services/export/reporter.py index 6633e7767..20bcf3645 100644 --- a/src/sampletones_application/services/export/reporter.py +++ b/src/sampletones_application/services/export/reporter.py @@ -3,7 +3,7 @@ from sampletones_application.services.progress import UNMEASURED, StageProgress from sampletones_application.services.result import ServiceProgress from sampletones_core.exports.progress import ExportProgress -from sampletones_core.exports.stage import TRAVELLING_STAGES, ExportStage +from sampletones_core.exports.stage import TRAVELING_STAGES, ExportStage class ExportProgressReporter: @@ -49,7 +49,7 @@ def _limiter(self, progress: ExportProgress) -> StageProgress[ExportStage]: progress.stage, UNMEASURED if progress.total is None else progress.total, emit=self._emit, - estimates=progress.stage in TRAVELLING_STAGES, + estimates=progress.stage in TRAVELING_STAGES, ) return self._progress diff --git a/src/sampletones_application/services/progress.py b/src/sampletones_application/services/progress.py index 4f98ad1cf..fb7dd3665 100644 --- a/src/sampletones_application/services/progress.py +++ b/src/sampletones_application/services/progress.py @@ -38,7 +38,7 @@ def __init__( bounds it. emit: Carries a report to the service's subscribers. estimates: Whether the count reaches ``total`` at a rate a remaining time can be read - from. A stage measured against a limit it is not travelling toward states no + from. A stage measured against a limit it is not traveling toward states no estimate, since one taken from that would be a guess wearing the clothes of a fact. """ self._stage = stage diff --git a/src/sampletones_application/view_model/shared/export.py b/src/sampletones_application/view_model/shared/export.py index dde9fb2ec..92fe560c7 100644 --- a/src/sampletones_application/view_model/shared/export.py +++ b/src/sampletones_application/view_model/shared/export.py @@ -39,14 +39,14 @@ class SongExportViewModel(BaseModel, frozen=True): stages: The stages the run has reached, in the order it reached them. figure: What the stage under way has covered, in the words its own unit is stated in. progress: How far the stage under way has got, from 0 to 1, where it travels to an end. - travelling: Whether the stage under way arrives at what it is measured against. + traveling: Whether the stage under way arrives at what it is measured against. """ phase: ExportPhase stages: Tuple[ExportStage, ...] figure: str progress: float - travelling: bool + traveling: bool @classmethod def idle(cls) -> SongExportViewModel: @@ -56,7 +56,7 @@ def idle(cls) -> SongExportViewModel: stages=(), figure="", progress=NO_PROGRESS, - travelling=False, + traveling=False, ) @property @@ -71,12 +71,12 @@ def stage(self) -> Optional[ExportStage]: @property def progress_visible(self) -> bool: """Whether a bar stands, which a stage arriving at an end is what earns.""" - return self.travelling + return self.traveling @property def working_visible(self) -> bool: """Whether the turning indicator stands, which is how a stage without an end reads.""" - return not self.travelling + return not self.traveling @property def progress_overlay(self) -> str: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 55570a095..b1493b462 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -41,7 +41,7 @@ global.dialog.title.new_unsaved_project: "New project" global.dialog.title.open_unsaved_project: "Open project" global.dialog.title.close_unsaved_project: "Close project" global.dialog.title.no_project_open: "No project open" -global.dialog.title.remove_sample: "Remove sample" +global.dialog.title.remove_voice: "Remove voice" global.dialog.title.change_nes_frequency: "Change NES frequency" global.dialog.title.frequency_mismatch: "Different NES frequency" global.dialog.title.about: "About" @@ -96,7 +96,7 @@ global.dialog.message.configuration_recovery_list_header: "The following setting global.dialog.message.configuration_recovery_path_prefix: "You can edit the configuration file directly:" global.dialog.message.audio_playback_error: "Audio playback error" global.dialog.message.no_project_open: "No project is open. Create or open a project before adding a reconstruction." -global.dialog.message.remove_sample: "The sample \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?" +global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?" global.dialog.message.change_nes_frequency: "Changing the NES frequency leaves the loaded reconstructions out of sync with the project rate for editing in the Reconstructions tab. Song playback already follows the new rate. Retune all samples to match?" global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?" global.dialog.message.operation_in_progress: "An operation is in progress. Please wait until the running operation finishes." @@ -663,13 +663,13 @@ sequencer.history.label.clear_frame: "Clear frame" sequencer.history.label.move_frame: "Move frame" sequencer.history.label.set_order_entry: "Set order entry" sequencer.history.label.add_sample: "Add sample" -sequencer.history.label.remove_sample: "Remove sample" -sequencer.history.label.replace_sample: "Replace sample" -sequencer.history.label.rename_sample: "Rename sample" -sequencer.history.label.move_sample: "Move sample" -sequencer.history.label.duplicate_sample: "Duplicate sample" sequencer.history.label.add_instrument: "Add instrument" +sequencer.history.label.replace_sample: "Replace sample" sequencer.history.label.edit_instrument: "Edit instrument" +sequencer.history.label.rename_voice: "Rename voice" +sequencer.history.label.move_voice: "Move voice" +sequencer.history.label.duplicate_voice: "Duplicate voice" +sequencer.history.label.remove_voice: "Remove voice" sequencer.history.label.set_tempo: "Set tempo" sequencer.history.label.set_speed: "Set speed" sequencer.history.label.set_nes_frequency: "Set NES frequency" diff --git a/src/sampletones_core/exports/stage.py b/src/sampletones_core/exports/stage.py index 874e71ec6..47916d5f2 100644 --- a/src/sampletones_core/exports/stage.py +++ b/src/sampletones_core/exports/stage.py @@ -14,7 +14,7 @@ class ExportStage(StrEnum): Walking arrives at the song's last tick and writing at its last file, so how far each has come is how far it has to go. Compressing ends when the song offers no further phrase that pays for itself, so its bytes are measured against the room the console has and reach it only by - overflowing; :data:`TRAVELLING_STAGES` is what separates the two. + overflowing; :data:`TRAVELING_STAGES` is what separates the two. """ WALKING = "walking" @@ -22,7 +22,7 @@ class ExportStage(StrEnum): WRITING = "writing" -TRAVELLING_STAGES: Final[FrozenSet[ExportStage]] = frozenset( +TRAVELING_STAGES: Final[FrozenSet[ExportStage]] = frozenset( { ExportStage.WALKING, ExportStage.WRITING, diff --git a/src/sampletones_core/features/envelope.py b/src/sampletones_core/features/envelope.py index 38cbb824b..9210cec20 100644 --- a/src/sampletones_core/features/envelope.py +++ b/src/sampletones_core/features/envelope.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Generic, Optional, Tuple, TypeVar from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -31,7 +33,7 @@ class Envelope(BaseModel, Generic[ItemT]): ) @model_validator(mode="after") - def _check_loop_point(self) -> "Envelope[ItemT]": + def _check_loop_point(self) -> Envelope[ItemT]: if self.loop_point is not None and self.loop_point >= len(self.items): raise ValueError(f"loop point {self.loop_point} stands past the {len(self.items)} items written") @@ -68,7 +70,7 @@ def at(self, tick: int) -> Optional[ItemT]: cycle = len(self.items) - self.loop_point return self.items[self.loop_point + (tick - self.loop_point) % cycle] - def limited(self, limit: int) -> "Envelope[ItemT]": + def limited(self, limit: int) -> Envelope[ItemT]: """This dimension's opening items, at most ``limit`` of them. A point standing past what survives moves to the last item kept, which is the value the @@ -86,7 +88,7 @@ def limited(self, limit: int) -> "Envelope[ItemT]": return self._holding(self.items[:limit]) - def resized(self, length: int) -> "Envelope[ItemT]": + def resized(self, length: int) -> Envelope[ItemT]: """This dimension brought to a length, holding its final value where it falls short. A format storing one row per tick reads every dimension out of the same row, so a @@ -103,7 +105,7 @@ def resized(self, length: int) -> "Envelope[ItemT]": return self._holding(self.items[:length] + self.items[-1:] * (length - len(self.items))) - def with_items(self, items: Tuple[ItemT, ...]) -> "Envelope[ItemT]": + def with_items(self, items: Tuple[ItemT, ...]) -> Envelope[ItemT]: """This dimension carrying different values, repeating from a point inside them. A reader redrawing a dimension states the values alone, so the point it already repeats @@ -117,7 +119,7 @@ def with_items(self, items: Tuple[ItemT, ...]) -> "Envelope[ItemT]": """ return self._holding(items) - def _holding(self, items: Tuple[ItemT, ...]) -> "Envelope[ItemT]": + def _holding(self, items: Tuple[ItemT, ...]) -> Envelope[ItemT]: """This dimension carrying ``items``, with the loop point held inside them.""" loop_point = min(self.loop_point, len(items) - 1) if self.loop_point is not None and items else None return type(self)(items=items, loop_point=loop_point) diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py index b463541c9..02d8251b8 100644 --- a/src/sampletones_core/project/voices/envelopes.py +++ b/src/sampletones_core/project/voices/envelopes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Annotated, Dict from pydantic import BaseModel, ConfigDict, Field @@ -60,7 +62,11 @@ def envelope(self, feature_key: FeatureKey) -> Envelope[int]: """ return self.envelope_map[feature_key] - def with_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> "InstrumentEnvelopes": + def with_envelope( + self, + feature_key: FeatureKey, + envelope: Envelope[int], + ) -> InstrumentEnvelopes: """The envelopes with one dimension replaced. Args: diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 12cc3702e..61f61d93d 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -79,11 +79,11 @@ from tests.suite.language import FakeLanguageManager FREQUENCY_MISMATCH_MESSAGE_KEY: Final[str] = "global.dialog.message.frequency_mismatch" -REMOVE_SAMPLE_MESSAGE_KEY: Final[str] = "global.dialog.message.remove_sample" +REMOVE_VOICE_MESSAGE_KEY: Final[str] = "global.dialog.message.remove_voice" TEXTS: Final[Dict[str, str]] = { FREQUENCY_MISMATCH_MESSAGE_KEY: "recon {reconstruction} vs project {project}", - REMOVE_SAMPLE_MESSAGE_KEY: "Remove {name}?", + REMOVE_VOICE_MESSAGE_KEY: "Remove {name}?", } @@ -417,7 +417,7 @@ def test_submit_rename_ignores_blank_name( ) -> None: samples_coordinator._submit_rename("abc", " ") - samples_coordinator._sequencer_voices_logic.rename_sample.assert_not_called() + samples_coordinator._sequencer_voices_logic.rename_voice.assert_not_called() @pytest.fixture @@ -921,7 +921,7 @@ def test_failed_load_shows_error_and_replaces_nothing( replace_coordinator._dialogs.show_error.assert_called_once() replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called() - replace_coordinator._sequencer_voices_logic.rename_sample.assert_not_called() + replace_coordinator._sequencer_voices_logic.rename_voice.assert_not_called() replace_coordinator._on_sample_reconstruction_replaced.assert_not_called() def test_selected_sample_is_renamed_and_substituted( @@ -1546,7 +1546,7 @@ def view_coordinator() -> SequencerTabCoordinator: def _detail_entry(value: str) -> HistoryEntry: return HistoryEntry( project=MagicMock(), - action=HistoryAction.MOVE_SAMPLE, + action=HistoryAction.MOVE_VOICE, created=datetime.now(tz=UTC), detail=( HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE), diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py index 3a8b5eea5..0f456cc32 100644 --- a/tests/unit/sampletones_application/logic/export/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -148,7 +148,7 @@ def test_a_second_run_starts_the_list_over( class TestHowEachStageReads: - """A stage travelling to an end is a fraction; one measured against a limit is a figure.""" + """A stage traveling to an end is a fraction; one measured against a limit is a figure.""" def test_a_travelling_stage_carries_its_share( self, @@ -188,7 +188,7 @@ def test_a_stage_measured_against_a_limit_carries_no_bar( ) -> None: service.deliver(ServiceStarted(total=NOTHING_MEASURED)) service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) - assert views[-1].travelling is False + assert views[-1].traveling is False def test_a_stage_with_nothing_to_measure_against_stands_at_the_start( self, diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index 207387993..637ccdf17 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -160,7 +160,7 @@ def test_eviction_prunes_cache_to_retained_reconstructions( with history.transaction(HistoryAction.ADD_SAMPLE): sample = controller.add_sample(reconstruction_factory(), name="lead") - with history.transaction(HistoryAction.REMOVE_SAMPLE): + with history.transaction(HistoryAction.REMOVE_VOICE): controller.remove_voice(sample.id) with history.transaction(HistoryAction.SET_TEMPO): diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py index 62630e5b5..47e40de3a 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -48,7 +48,7 @@ def render( phase: ExportPhase = ExportPhase.EXPORTING, figure: str = "", progress: float = HALFWAY, - travelling: bool = True, + traveling: bool = True, ) -> None: """Builds the widget tree and draws the given state, the way an open window is kept up to date.""" window.create_window() @@ -58,7 +58,7 @@ def render( stages=stages, figure=figure, progress=progress, - travelling=travelling, + traveling=traveling, ) ) @@ -96,25 +96,25 @@ class TestHowTheStageUnderWayReads: """A stage arriving at an end carries a bar; one measured against a limit carries a figure.""" def test_a_travelling_stage_shows_its_bar(self, window: GUIExportWindow) -> None: - render(window, travelling=True) + render(window, traveling=True) assert shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED) assert not shown(TAG_SETTINGS_EXPORT_GROUP_WORKING) def test_a_bar_stands_where_the_stage_has_reached(self, window: GUIExportWindow) -> None: - render(window, travelling=True, progress=HALFWAY) + render(window, traveling=True, progress=HALFWAY) assert dpg.get_value(TAG_SETTINGS_EXPORT_PROGRESS) == pytest.approx(HALFWAY) def test_a_bar_is_labeled_with_the_share_it_has_covered(self, window: GUIExportWindow) -> None: - render(window, travelling=True, progress=HALFWAY) + render(window, traveling=True, progress=HALFWAY) assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_PROGRESS)["overlay"] == "50%" def test_a_stage_without_an_end_shows_what_it_holds(self, window: GUIExportWindow) -> None: - render(window, travelling=False, figure=SIZE) + render(window, traveling=False, figure=SIZE) assert shown(TAG_SETTINGS_EXPORT_GROUP_WORKING) assert not shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED) def test_the_figure_reaches_the_reader(self, window: GUIExportWindow) -> None: - render(window, travelling=False, figure=SIZE) + render(window, traveling=False, figure=SIZE) assert dpg.get_value(TAG_SETTINGS_EXPORT_TEXT_FIGURE) == SIZE diff --git a/tests/unit/sampletones_application/view_model/shared/test_export.py b/tests/unit/sampletones_application/view_model/shared/test_export.py index 76a0618ce..c20f47912 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_export.py +++ b/tests/unit/sampletones_application/view_model/shared/test_export.py @@ -17,14 +17,14 @@ def view_model( stages: Tuple[ExportStage, ...] = (ExportStage.WALKING,), figure: str = "", progress: float = HALFWAY, - travelling: bool = True, + traveling: bool = True, ) -> SongExportViewModel: return SongExportViewModel( phase=phase, stages=stages, figure=figure, progress=progress, - travelling=travelling, + traveling=traveling, ) @@ -67,13 +67,13 @@ class TestHowTheStageUnderWayReads: """A stage arriving at an end carries a bar; one that does not carries the turning symbol.""" def test_a_travelling_stage_shows_its_bar(self) -> None: - assert view_model(travelling=True).progress_visible is True + assert view_model(traveling=True).progress_visible is True def test_a_travelling_stage_hides_the_turning_symbol(self) -> None: - assert view_model(travelling=True).working_visible is False + assert view_model(traveling=True).working_visible is False def test_a_stage_without_an_end_shows_the_turning_symbol(self) -> None: - assert view_model(travelling=False, figure=SIZE).working_visible is True + assert view_model(traveling=False, figure=SIZE).working_visible is True def test_a_bar_is_labeled_with_the_share_it_has_covered(self) -> None: assert view_model(progress=HALFWAY).progress_overlay == "50%" From edeac9a7a442f93f0071bdef80dd476ca450da8c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 24 Aug 2026 23:49:11 +0200 Subject: [PATCH 130/142] Renamed: the voices panel's hooks after the voices they carry --- src/sampletones_application/application.py | 2 +- .../coordinators/tabs/sequencer.py | 28 +++++----- .../logic/reconstruction/instruments.py | 8 +-- .../logic/sequencer/history_detail.py | 16 +++--- .../logic/sequencer/voices.py | 48 ++++++++--------- .../reconstruction/instruments/instruments.py | 4 +- .../ui/panels/sequencer/voices/footprint.py | 6 +-- .../ui/panels/sequencer/voices/menu.py | 10 ++-- .../ui/panels/sequencer/voices/panel.py | 52 +++++++++---------- .../view_model/reconstruction/instruments.py | 4 +- .../view_model/shared/footprint.py | 2 +- .../logic/sequencer/test_history_detail.py | 14 ++++- .../logic/sequencer/test_voices.py | 12 ++--- .../reconstruction/test_instruments_panel.py | 6 +-- .../ui/panels/sequencer/voices/test_hover.py | 12 ++--- .../ui/panels/sequencer/voices/test_menu.py | 16 +++--- 16 files changed, 125 insertions(+), 115 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index f4960b59f..49bc23ebe 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -511,7 +511,7 @@ def __init__( language_manager=self.language_manager, dialogs=self.dialogs, status_bar=self.status_bar, - on_edit_sample_requested=self._edit_project_voice, + on_edit_voice_requested=self._edit_project_voice, on_favorite_changed=self._repaint_reconstruction_favorites, on_sample_reconstruction_replaced=self._rebind_replaced_sample, on_tab_switch=self._set_current_tab, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 00f0d2316..3ab100328 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -167,7 +167,7 @@ def __init__( language_manager: LanguageManager, dialogs: DialogsRenderer, status_bar: GUIStatusBar, - on_edit_sample_requested: StringCallback, + on_edit_voice_requested: StringCallback, on_favorite_changed: Callable[[FileSystemNode], None], on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None], on_tab_switch: Callable[[Tab], None], @@ -179,7 +179,7 @@ def __init__( self._history = history self._original_audio_locator = original_audio_locator self._instrument_exports = instrument_exports - self._on_edit_sample_requested = on_edit_sample_requested + self._on_edit_voice_requested = on_edit_voice_requested self._on_favorite_changed = on_favorite_changed self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced self._on_tab_switch = on_tab_switch @@ -326,7 +326,7 @@ def _wire_callbacks(self) -> None: self._wire_channels_callbacks() self._wire_order_callbacks() self._wire_block_callbacks() - self._wire_samples_callbacks() + self._wire_voices_callbacks() self._wire_browser_callbacks() self._wire_playback_callbacks() self._wire_project_callbacks() @@ -626,13 +626,13 @@ def _paste_order_block(self, cell: OrderCell) -> None: if block is not None: self._order_block_writer.write(block, cell) - def _wire_samples_callbacks(self) -> None: + def _wire_voices_callbacks(self) -> None: self._sequencer_voices_logic.on_voices_changed = self._on_voices_changed - self._sequencer_voices_logic.on_edit_sample_requested = self._dispatch_edit_sample + self._sequencer_voices_logic.on_edit_voice_requested = self._dispatch_edit_voice self._sequencer_voices_logic.on_autoplay_error = self._on_preview_error - self._sequencer_voices_panel.sample_footprint = self._sequencer_voices_logic.build_voice_footprint - self._sequencer_voices_panel.on_sample_selected = self._on_sample_selected - self._sequencer_voices_panel.on_sample_edit_requested = self._sequencer_voices_logic.request_edit + self._sequencer_voices_panel.voice_footprint = self._sequencer_voices_logic.build_voice_footprint + self._sequencer_voices_panel.on_voice_selected = self._on_voice_selected + self._sequencer_voices_panel.on_voice_edit_requested = self._sequencer_voices_logic.request_edit self._sequencer_voices_panel.on_remove_requested = self._remove_voice self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice self._sequencer_voices_panel.on_move_requested = self._undoable( @@ -831,7 +831,7 @@ def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists a card's collapsed state so it restores on the next launch.""" self._session_manager.set_card_collapsed(card_tag, collapsed) if card_tag == TAG_SEQUENCER_HISTORY_PANEL: - self._sync_samples_height() + self._sync_voices_height() def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists the browser panel's collapse, then docks or restores the width of the column it fills.""" @@ -876,7 +876,7 @@ def _stacked_card_gap(self) -> int: spacing_y = int(spacing[1]) if spacing is not None else 0 return self._geometry.panel_gap + 2 * spacing_y - def _sync_samples_height(self) -> None: + def _sync_voices_height(self) -> None: """Reserves the bottom space the history card and its inter-card gap occupy, so samples fills the rest. The samples card fills the right column above the history card by reserving that footprint below @@ -1412,8 +1412,8 @@ def _replace_target_label(self) -> Optional[str]: return selection.label - def _dispatch_edit_sample(self, voice_id: str) -> None: - self._on_edit_sample_requested(voice_id) + def _dispatch_edit_voice(self, voice_id: str) -> None: + self._on_edit_voice_requested(voice_id) def _on_tracker_play_from_row(self, row_index: int) -> None: """Starts playback from the right-clicked row of the frame the tracker is showing.""" @@ -1429,7 +1429,7 @@ def _on_voices_changed( self._sequencer_voices_panel.update_view(view_model) self._sequencer_tracker_panel.update_samples(view_model) - def _on_sample_selected(self, voice_id: str) -> None: + def _on_voice_selected(self, voice_id: str) -> None: self._sequencer_tracker_panel.deselect_cell() self._sequencer_order_panel.deselect_cell() self._sequencer_voices_logic.request_autoplay(voice_id) @@ -1682,7 +1682,7 @@ def _build_right_column(self, parent: str) -> None: self._sequencer_voices_panel.create_panel(parent) dpg.add_spacer(height=self._geometry.panel_gap) self._sequencer_history_panel.create_panel(parent) - self._sync_samples_height() + self._sync_voices_height() @property def player(self) -> AudioPlayerProtocol: diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 860b5f918..f6e041abe 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -17,7 +17,7 @@ from sampletones_application.view_model.reconstruction.update import ( ReconstructionUpdate, ) -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features, playing_channels from sampletones_core.features.envelope import Envelope @@ -140,21 +140,21 @@ def _instrument_view_model( return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, playing_channels=playing_channels(self._instrument_channels(instrument)), - footprint=SampleFootprintViewModel.from_instrument(features_footprint(instrument.features)), + footprint=VoiceFootprintViewModel.from_instrument(features_footprint(instrument.features)), instrument=InstrumentViewModel(name=instrument.name), ) def _build_footprint( self, channels: Dict[ChannelName, Features], - ) -> SampleFootprintViewModel: + ) -> VoiceFootprintViewModel: """Measures each playing channel's instrument as the size its own export writes. Each instrument is measured at the lengths its own envelopes state, matching what **Export instrument...** produces. A channel standing by is written nowhere, so it is measured nowhere and the sample's total names what the export costs. """ - return SampleFootprintViewModel.from_footprints( + return VoiceFootprintViewModel.from_footprints( { channel_name: features_footprint(features) for channel_name, features in channels.items() diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index ecdd3ec34..d6448ed5e 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -94,10 +94,10 @@ class SequencerHistoryDetail: def __init__( self, tracker_logic: SequencerTrackerLogic, - samples_logic: SequencerVoicesLogic, + voices_logic: SequencerVoicesLogic, ) -> None: self._tracker_logic = tracker_logic - self._samples_logic = samples_logic + self._voices_logic = voices_logic def edit_row( self, @@ -289,7 +289,7 @@ def rename_voice(self, voice_id: str, name: str) -> Segments: return ( self._voice_name(voice_id), self._arrow(), - self._name(name, self._samples_logic.voice_kind(voice_id)), + self._name(name, self._voices_logic.voice_kind(voice_id)), ) def move_voice(self, voice_id: str, to_index: int) -> Segments: @@ -343,7 +343,7 @@ def remove_stem(self, voice_id: str, stem_name: str) -> Segments: """Describes a recording taken out of a sample's reconstruction: its position and name.""" return ( self._voice(voice_id, colon=True), - self._name(stem_name, self._samples_logic.voice_kind(voice_id)), + self._name(stem_name, self._voices_logic.voice_kind(voice_id)), ) def value(self, number: int) -> Segments: @@ -470,8 +470,8 @@ def _name( def _voice_name(self, voice_id: str) -> HistoryDetailSegment: """The name a voice in the pool carries, read in the color of the kind it is.""" return self._name( - self._samples_logic.voice_name(voice_id), - self._samples_logic.voice_kind(voice_id), + self._voices_logic.voice_name(voice_id), + self._voices_logic.voice_kind(voice_id), ) def _voice( @@ -480,11 +480,11 @@ def _voice( *, colon: bool = False, ) -> HistoryDetailSegment: - position = self._samples_logic.voice_position(voice_id) + position = self._voices_logic.voice_position(voice_id) text = f"{position}:" if colon else position return HistoryDetailSegment( text=text, - role=_kind_role(self._samples_logic.voice_kind(voice_id)), + role=_kind_role(self._voices_logic.voice_kind(voice_id)), ) def _arrow(self) -> HistoryDetailSegment: diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index 3f1af0fbf..a59ad494e 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -16,7 +16,7 @@ VoiceEntryViewModel, VoiceKind, ) -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.audio import AudioDeviceManager from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName @@ -48,16 +48,16 @@ class SequencerVoicesLogic(CallbackMixin): - """Drives the samples panel: lists the pool, edits it, and previews samples. + """Drives the voices panel: lists the pool, edits it, and previews what it holds. - Every pool edit goes through the controller so the project stays the single - source of truth. ``on_edit_sample_requested`` hands a sample id to the - application, which opens that sample's reconstruction in the Reconstruction - tab for live-linked editing. + Every pool edit goes through the controller so the project stays the single source of truth. + ``on_edit_voice_requested`` hands a voice id to the application, which opens that voice in the + Reconstructions tab — a recording as the reconstruction behind it, a hand-written one as its + envelopes. - Previewing mirrors the reconstruction browser: a single click schedules a - debounced autoplay that fires only when the session's autoplay flag is on, and - a double-click (edit) cancels the pending preview before it plays. + Previewing mirrors the reconstruction browser: a single click schedules a debounced autoplay + that fires only when the session's autoplay flag is on, and a double-click (edit) cancels the + pending preview before it plays. """ def __init__( @@ -72,10 +72,10 @@ def __init__( self._session_manager = session_manager self._audio_device_manager = audio_device_manager self._scheduling = scheduling - self._pending_autoplay_sample: Optional[str] = None + self._pending_autoplay_voice: Optional[str] = None self.on_voices_changed: Optional[Callable[[SequencerVoicesViewModel], None]] = None - self.on_edit_sample_requested: Optional[StringCallback] = None + self.on_edit_voice_requested: Optional[StringCallback] = None self.on_autoplay_error: Optional[Callable[[Exception], None]] = None def build_voices(self) -> SequencerVoicesViewModel: @@ -189,7 +189,7 @@ def is_voice_used(self, voice_id: str) -> bool: def build_voice_footprint( self, voice_id: str, - ) -> Optional[SampleFootprintViewModel]: + ) -> Optional[VoiceFootprintViewModel]: """Measures one voice's instruments as the module export writes them. A sample yields a figure per channel its reconstruction covers; an instrument yields one, @@ -200,14 +200,14 @@ def build_voice_footprint( voice_id: The voice to measure. Returns: - Optional[SampleFootprintViewModel]: The voice's byte figures, or ``None`` while the + Optional[VoiceFootprintViewModel]: The voice's byte figures, or ``None`` while the pool holds no such voice. """ match self._controller.project.voices.get(voice_id): case Sample() as sample: - return SampleFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) + return VoiceFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) case Instrument() as instrument: - return SampleFootprintViewModel.from_instrument(features_footprint(instrument.instrument_features())) + return VoiceFootprintViewModel.from_instrument(features_footprint(instrument.instrument_features())) case _: return None @@ -215,7 +215,7 @@ def voice_name(self, voice_id: str) -> str: return self._controller.project.voices[voice_id].name def voice_position(self, voice_id: str) -> str: - """Returns the sample's hex list position, matching how the tracker labels it.""" + """Returns the voice's hex list position, matching how the tracker labels it.""" return display_voice( voices=self._controller.project.voices, voice_id=voice_id, @@ -248,10 +248,10 @@ def duplicate_voice(self, voice_id: str) -> None: def request_edit(self, voice_id: str) -> None: self.cancel_autoplay() - self.call(self.on_edit_sample_requested, voice_id) + self.call(self.on_edit_voice_requested, voice_id) def play_voice(self, voice_id: str) -> None: - """Plays a sample on demand, regardless of the autoplay setting. + """Plays a voice on demand, regardless of the autoplay setting. Explicit playback is intentional, so it uses ``NORMAL`` priority and thereby preempts the sequencer song / reconstruction players. @@ -260,7 +260,7 @@ def play_voice(self, voice_id: str) -> None: def request_autoplay(self, voice_id: str) -> None: """Schedules a debounced preview that a following double-click can cancel.""" - self._pending_autoplay_sample = voice_id + self._pending_autoplay_voice = voice_id CallbackQueue.add( self._execute_autoplay, priority=self._scheduling.priorities.schedule, @@ -268,14 +268,14 @@ def request_autoplay(self, voice_id: str) -> None: ) def cancel_autoplay(self) -> None: - self._pending_autoplay_sample = None + self._pending_autoplay_voice = None def _execute_autoplay(self) -> None: - if self._pending_autoplay_sample is None: + if self._pending_autoplay_voice is None: return - voice_id = self._pending_autoplay_sample - self._pending_autoplay_sample = None + voice_id = self._pending_autoplay_voice + self._pending_autoplay_voice = None if self._session_manager.autoplay: self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW) @@ -332,6 +332,6 @@ def _play_voice( except (PlaybackError, ValueError) as exception: logger.error_with_traceback( exception, - f"Failed to preview sample: {voice_id}", + f"Failed to preview voice: {voice_id}", ) self.call(self.on_autoplay_error, exception) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index fa013f834..691c89292 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -84,7 +84,7 @@ from sampletones_application.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, ) -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ( ChannelName, FeatureKey, @@ -612,7 +612,7 @@ def _apply_playing_state( def _update_sizes( self, - footprint: Optional[SampleFootprintViewModel], + footprint: Optional[VoiceFootprintViewModel], *, shows_one_instrument: bool, ) -> None: diff --git a/src/sampletones_application/ui/panels/sequencer/voices/footprint.py b/src/sampletones_application/ui/panels/sequencer/voices/footprint.py index bc0245083..a3a8b9304 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/footprint.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/footprint.py @@ -8,7 +8,7 @@ from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.hierarchy import TextType from sampletones_application.categories.manager import LanguageManager -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName @@ -31,7 +31,7 @@ def size(self, byte_count: int) -> str: def items( self, - footprint: Optional[SampleFootprintViewModel], + footprint: Optional[VoiceFootprintViewModel], ) -> List[Tuple[str, str]]: """The byte figures a menu prints: the voice's total, then each channel that plays. @@ -60,7 +60,7 @@ def items( return items - def channels(self, footprint: SampleFootprintViewModel) -> List[str]: + def channels(self, footprint: VoiceFootprintViewModel) -> List[str]: """The channels a voice plays, each named as every display naming a channel names it.""" return [ channel_label(self._language_manager, instrument.channel) diff --git a/src/sampletones_application/ui/panels/sequencer/voices/menu.py b/src/sampletones_application/ui/panels/sequencer/voices/menu.py index ea14a5587..02019cf6f 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/menu.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/menu.py @@ -31,7 +31,7 @@ from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.sequencer.voices import VoiceSelection -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.callback import StringCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -48,10 +48,10 @@ class VoicesMenuHost(Protocol): a menu reads whichever answer stands at the moment it opens. """ - sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] + voice_footprint: Optional[Callable[[str], Optional[VoiceFootprintViewModel]]] voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] - on_sample_edit_requested: Optional[StringCallback] + on_voice_edit_requested: Optional[StringCallback] on_duplicate_requested: Optional[StringCallback] on_remove_requested: Optional[StringCallback] on_play_requested: Optional[StringCallback] @@ -162,7 +162,7 @@ def add_action_items(self, target: VoiceSelection) -> None: """ dpg.add_menu_item( label=self._label(SequencerVoicesElements.CONTEXT_EDIT), - callback=lambda: self.call(self._panel.on_sample_edit_requested, target.voice_id), + callback=lambda: self.call(self._panel.on_voice_edit_requested, target.voice_id), ) dpg.add_menu_item( label=self._label(SequencerVoicesElements.CONTEXT_RENAME), @@ -315,7 +315,7 @@ def _footprint_items( """ return self._footprint_text.items( self.query( - self._panel.sample_footprint, + self._panel.voice_footprint, voice_id, default=None, ) diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 778db7aae..62344e530 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -47,7 +47,7 @@ VoiceKind, VoiceSelection, ) -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.utils.display import display_id from sampletones_shared.types.application import Sender @@ -90,11 +90,11 @@ def __init__( self._tpl_status_sample = language_manager["sequencer.voices.template.status_sample"] self._tpl_status_instrument = language_manager["sequencer.voices.template.status_instrument"] self._channel_separator = language_manager["sequencer.voices.template.status_channel_separator"] - self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None + self.voice_footprint: Optional[Callable[[str], Optional[VoiceFootprintViewModel]]] = None self.voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] = None self.instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] = None - self.on_sample_selected: Optional[StringCallback] = None - self.on_sample_edit_requested: Optional[StringCallback] = None + self.on_voice_selected: Optional[StringCallback] = None + self.on_voice_edit_requested: Optional[StringCallback] = None self.on_remove_requested: Optional[StringCallback] = None self.on_play_requested: Optional[StringCallback] = None self.on_move_requested: Optional[Callable[[str, int], None]] = None @@ -135,8 +135,8 @@ def create_panel(self, parent: str) -> None: def _create_row_handlers(self) -> None: with dpg.item_handler_registry(tag=self._row_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_sample_clicked) - dpg.add_item_double_clicked_handler(callback=self._on_sample_double_clicked) + dpg.add_item_clicked_handler(callback=self._on_voice_clicked) + dpg.add_item_double_clicked_handler(callback=self._on_voice_double_clicked) dpg.add_item_hover_handler(callback=self._on_row_hovered) def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: @@ -160,7 +160,7 @@ def _voice_status_message(self, voice_id: str) -> str: channel and carries a single figure. """ entry = self._entry_for(voice_id) - footprint = self.query(self.sample_footprint, voice_id, default=None) + footprint = self.query(self.voice_footprint, voice_id, default=None) if entry is None or footprint is None: return "" @@ -261,7 +261,7 @@ def update_view(self, view_model: SequencerVoicesViewModel) -> None: self._rebuild() def _rebuild(self) -> None: - """Rebuilds the samples table from the cached entries with explicit parents. + """Rebuilds the voices table from the cached entries with explicit parents. Items pass an explicit ``parent`` so each widget binds directly: the browser tree builds on a worker thread and the DearPyGui container stack is @@ -271,11 +271,11 @@ def _rebuild(self) -> None: dpg_delete_children(TAG_SEQUENCER_VOICES_TABLE, slot=1) self._selected_row = None for position, entry in enumerate(self._entries): - self._build_sample_row(position, entry) + self._build_voice_row(position, entry) if self._selected_row is None: self._selected_voice_id = None - def _build_sample_row( + def _build_voice_row( self, position: int, entry: VoiceEntryViewModel, @@ -358,7 +358,7 @@ def _build_id_cell( parent=id_cell, label=display_id(position), user_data=(position, entry.voice_id), - callback=self._on_sample_selected, + callback=self._on_voice_selected, ) FontRegistry.bind_to_item(id_selectable, Font.MONO_SMALL) dpg.bind_item_handler_registry(id_selectable, self._row_handler_tag) @@ -385,7 +385,7 @@ def _build_name_selectable( parent=name_cell, label=entry.name, user_data=(position, entry.voice_id), - callback=self._on_sample_selected, + callback=self._on_voice_selected, ) FontRegistry.bind_to_item(name_selectable, Font.MONO_SMALL) dpg.bind_item_handler_registry(name_selectable, self._row_handler_tag) @@ -406,7 +406,7 @@ def _build_name_input( FontRegistry.bind_to_item(name_input, Font.MONO_SMALL) dpg.bind_item_handler_registry(name_input, self._rename_handler_tag) - def _on_sample_selected( + def _on_voice_selected( self, sender: Sender, _app_data: bool, @@ -423,11 +423,11 @@ def _on_sample_selected( self._selected_row = position self._selected_voice_id = voice_id self._highlight_selected_row(position) - self.call(self.on_sample_selected, voice_id) + self.call(self.on_voice_selected, voice_id) @property def selection(self) -> Optional[VoiceSelection]: - """The selected sample, or ``None`` while the panel holds no selection. + """The selected voice, or ``None`` while the panel holds no selection. Derived from the highlighted row and the cached entries on each read, so it reports whatever the table currently shows. Lets an operation hosted by another panel of the tab @@ -448,7 +448,7 @@ def selection(self) -> Optional[VoiceSelection]: ) def deselect(self) -> None: - """Drops the sample selection so the panel stops consuming keystrokes. + """Drops the voice selection so the panel stops consuming keystrokes. Mirrors the grid and order panels: each registers a key-router scope that is active only while it holds a selection, so a single selection across the three decides which one acts @@ -464,11 +464,11 @@ def deselect(self) -> None: self._selected_voice_id = None def _keys_active(self) -> bool: - """Whether the samples panel owns the next key. + """Whether the voices panel owns the next key. The panel answers only while its tab is in front, since a selection outlives a move to another tab. There, a name being edited keeps the keyboard so Escape can cancel the rename; - otherwise the panel acts when a sample is selected and no field holds the keyboard. A modal + otherwise the panel acts when a voice is selected and no field holds the keyboard. A modal dialog claims keys at a higher priority in the router, so the panel needs no modal check. """ if not self._tab_active(): @@ -480,9 +480,9 @@ def _keys_active(self) -> bool: return self._selected_voice_id is not None and not self._router.is_field_focused def _on_key_pressed(self, event: KeyEvent) -> bool: - """Applies a samples key to the selected sample, reporting whether the panel consumed it. + """Applies a voices key to the selected voice, reporting whether the panel consumed it. - The scheme says which press each samples action answers to; a press the samples category + The scheme says which press each voices action answers to; a press the voices category leaves unnamed goes to the application's global shortcuts. """ shortcut_id = self._shortcuts.action(ShortcutCategory.VOICES, event) @@ -519,7 +519,7 @@ def _cancel_edit(self, shortcut_id: Optional[ShortcutId]) -> bool: return True def _move_voice(self, shortcut_id: ShortcutId) -> bool: - """Moves the selected sample up, down, to the top or to the bottom of the list. + """Moves the selected voice up, down, to the top or to the bottom of the list. Returns whether the action was one of the moves, so a boundary with nowhere to go still counts as consumed and stays out of the global shortcuts. @@ -535,7 +535,7 @@ def _move_voice(self, shortcut_id: ShortcutId) -> bool: return True def start_rename(self, voice_id: str) -> None: - """Turns the sample's name cell into a focused text input.""" + """Turns the voice's name cell into a focused text input.""" if self._entry_for(voice_id) is None: return @@ -571,7 +571,7 @@ def _on_rename_enter(self, _sender: Sender, _app_data: str) -> None: def _on_rename_deactivated(self, _sender: Sender, _app_data: int) -> None: self._commit_rename() - def _on_sample_double_clicked( + def _on_voice_double_clicked( self, _sender: Sender, app_data: List[int], @@ -580,9 +580,9 @@ def _on_sample_double_clicked( user_data = dpg.get_item_user_data(clicked_item) if user_data is not None: _, voice_id = user_data - self.call(self.on_sample_edit_requested, voice_id) + self.call(self.on_voice_edit_requested, voice_id) - def _on_sample_clicked( + def _on_voice_clicked( self, _sender: Sender, app_data: Tuple[int, int], @@ -659,7 +659,7 @@ def voice_count(self) -> int: return len(self._entries) def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this panel's actions, which it does while it holds a sample. + """Whether the Edit menu states this panel's actions, which it does while it holds a voice. The menu offers what the next press would reach, so the key scope decides it, and the selection those keys act on is the one the actions are built for. diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 5e4b1b3a2..b298605a1 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName @@ -27,7 +27,7 @@ class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): reconstruction_loaded: bool playing_channels: FrozenSet[ChannelName] - footprint: Optional[SampleFootprintViewModel] + footprint: Optional[VoiceFootprintViewModel] instrument: Optional[InstrumentViewModel] = None @property diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index c9b2f9e67..afe0f6364 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -27,7 +27,7 @@ def total_bytes(self) -> int: return self.footprint.total_bytes -class SampleFootprintViewModel(BaseModel, frozen=True): +class VoiceFootprintViewModel(BaseModel, frozen=True): """The byte sizes a voice's instruments occupy. A sample exports one instrument per channel its reconstruction covers, so a display reads diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 8bfae68af..7f65fa2fe 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -37,13 +37,13 @@ def _controller() -> ProjectController: def _formatter(controller: ProjectController) -> SequencerHistoryDetail: tracker_logic = SequencerTrackerLogic(controller) - samples_logic = SequencerVoicesLogic( + voices_logic = SequencerVoicesLogic( controller, MagicMock(), MagicMock(), scheduling=MagicMock(), ) - return SequencerHistoryDetail(tracker_logic, samples_logic) + return SequencerHistoryDetail(tracker_logic, voices_logic) def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]: @@ -391,6 +391,16 @@ def test_a_written_voice_is_renamed_under_its_own_kind(self) -> None: ("Strings", HistoryDetailRole.INSTRUMENT), ] + def test_a_written_voice_is_duplicated_under_its_own_kind(self) -> None: + controller = _controller() + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + assert _pairs(formatter.duplicate_voice(instrument.id)) == [ + ("00:", HistoryDetailRole.INSTRUMENT), + ("Pad", HistoryDetailRole.INSTRUMENT), + ] + def test_the_kinds_read_apart_where_one_gesture_serves_both(self) -> None: """Moving is one gesture over the whole pool, so its line says which kind moved.""" controller = _controller() diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 6815d03d9..1d68dbc69 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -10,7 +10,7 @@ from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.view_model.sequencer.voices import VoiceKind -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.features.envelope import Envelope @@ -228,7 +228,7 @@ def test_it_measures_the_sample_as_its_own_export_writes_it( footprint = logic.build_voice_footprint(sample.id) - assert footprint == SampleFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) + assert footprint == VoiceFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction)) def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: """A channel's figure is the cost of its own instrument, and the channels differ. @@ -366,7 +366,7 @@ def test_executes_pending_preview_when_autoplay_enabled( controller, logic, session_manager, audio_device_manager = _logic_with_mocks() session_manager.autoplay = True sample = controller.add_sample(reconstruction_factory(), name="lead") - logic._pending_autoplay_sample = sample.id + logic._pending_autoplay_voice = sample.id logic._execute_autoplay() @@ -380,7 +380,7 @@ def test_skips_pending_preview_when_autoplay_disabled( controller, logic, session_manager, audio_device_manager = _logic_with_mocks() session_manager.autoplay = False sample = controller.add_sample(reconstruction_factory(), name="lead") - logic._pending_autoplay_sample = sample.id + logic._pending_autoplay_voice = sample.id logic._execute_autoplay() @@ -393,7 +393,7 @@ def test_cancel_autoplay_drops_pending_preview( controller, logic, session_manager, audio_device_manager = _logic_with_mocks() session_manager.autoplay = True sample = controller.add_sample(reconstruction_factory(), name="lead") - logic._pending_autoplay_sample = sample.id + logic._pending_autoplay_voice = sample.id logic.cancel_autoplay() logic._execute_autoplay() @@ -407,7 +407,7 @@ def test_request_edit_cancels_pending_preview( controller, logic, session_manager, audio_device_manager = _logic_with_mocks() session_manager.autoplay = True sample = controller.add_sample(reconstruction_factory(), name="lead") - logic._pending_autoplay_sample = sample.id + logic._pending_autoplay_voice = sample.id logic.request_edit(sample.id) logic._execute_autoplay() diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index 765c7d31f..ff08da7e1 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -41,7 +41,7 @@ InstrumentViewModel, ReconstructionInstrumentsViewModel, ) -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import InstrumentFootprint @@ -67,7 +67,7 @@ ONE_INSTRUMENT: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, playing_channels=frozenset((ChannelName.PULSE1,)), - footprint=SampleFootprintViewModel.from_instrument(LARGEST_PULSE), + footprint=VoiceFootprintViewModel.from_instrument(LARGEST_PULSE), instrument=InstrumentViewModel(name="lead"), ) @@ -84,7 +84,7 @@ def build_view_model( return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, playing_channels=frozenset(channel_footprints), - footprint=SampleFootprintViewModel.from_footprints(channel_footprints), + footprint=VoiceFootprintViewModel.from_footprints(channel_footprints), ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py index 8ba1ce0d4..ee54015ba 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py @@ -14,7 +14,7 @@ VoiceEntryViewModel, VoiceKind, ) -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint @@ -29,16 +29,16 @@ PULSE_1_FOOTPRINT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32) NOISE_FOOTPRINT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12) -SAMPLE_FOOTPRINT: Final[SampleFootprintViewModel] = SampleFootprintViewModel.from_footprints( +SAMPLE_FOOTPRINT: Final[VoiceFootprintViewModel] = VoiceFootprintViewModel.from_footprints( { ChannelName.PULSE1: PULSE_1_FOOTPRINT, ChannelName.NOISE: NOISE_FOOTPRINT, } ) -INSTRUMENT_FOOTPRINT: Final[SampleFootprintViewModel] = SampleFootprintViewModel.from_instrument(PULSE_1_FOOTPRINT) +INSTRUMENT_FOOTPRINT: Final[VoiceFootprintViewModel] = VoiceFootprintViewModel.from_instrument(PULSE_1_FOOTPRINT) -def _panel(footprint: Optional[SampleFootprintViewModel]) -> GUISequencerVoicesPanel: +def _panel(footprint: Optional[VoiceFootprintViewModel]) -> GUISequencerVoicesPanel: """The panel over the facts a hovered row reads, with no DearPyGui context behind it.""" language_manager = LanguageManager(LANG_EN) panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) @@ -47,7 +47,7 @@ def _panel(footprint: Optional[SampleFootprintViewModel]) -> GUISequencerVoicesP panel._tpl_status_sample = language_manager["sequencer.voices.template.status_sample"] panel._tpl_status_instrument = language_manager["sequencer.voices.template.status_instrument"] panel._channel_separator = language_manager["sequencer.voices.template.status_channel_separator"] - panel.sample_footprint = lambda _voice_id: footprint + panel.voice_footprint = lambda _voice_id: footprint return panel @@ -89,6 +89,6 @@ def test_an_instrument_names_no_channel_and_says_every_one_can_play_it(self) -> def test_a_voice_with_nothing_to_state_says_nothing( self, voice_id: str, - footprint: Optional[SampleFootprintViewModel], + footprint: Optional[VoiceFootprintViewModel], ) -> None: assert _panel(footprint)._voice_status_message(voice_id) == "" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index a77d8062b..43a928be5 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -16,7 +16,7 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind -from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.utils.display import display_voice_label @@ -41,7 +41,7 @@ NOISE_FOOTPRINT = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12) PULSE_1_BYTES = PULSE_1_FOOTPRINT.total_bytes NOISE_BYTES = NOISE_FOOTPRINT.total_bytes -FOOTPRINT = SampleFootprintViewModel.from_footprints( +FOOTPRINT = VoiceFootprintViewModel.from_footprints( { ChannelName.PULSE1: PULSE_1_FOOTPRINT, ChannelName.NOISE: NOISE_FOOTPRINT, @@ -145,7 +145,7 @@ def _panel( tab_active: bool = True, editing: Optional[str] = None, field_focused: bool = False, - footprint: Optional[SampleFootprintViewModel] = FOOTPRINT, + footprint: Optional[VoiceFootprintViewModel] = FOOTPRINT, footprint_wired: bool = True, instruments: Tuple[Optional[ChannelName], ...] = ONE_INSTRUMENT, channels: Tuple[ChannelName, ...] = NO_CHANNELS, @@ -165,12 +165,12 @@ def _panel( panel._list_menu_pending = False panel._tab_active = lambda: tab_active panel._router = _Router(field_focused=field_focused) - panel.sample_footprint = (lambda _voice_id: footprint) if footprint_wired else None + panel.voice_footprint = (lambda _voice_id: footprint) if footprint_wired else None panel.voice_instruments = lambda _voice_id: instruments panel.instrument_channels = lambda _voice_id: channels requests = Requests() - panel.on_sample_edit_requested = requests.edited.append + panel.on_voice_edit_requested = requests.edited.append panel.on_duplicate_requested = requests.duplicated.append panel.on_remove_requested = requests.removed.append panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target)) @@ -504,12 +504,12 @@ def test_the_figures_name_the_voice_the_pointer_landed_on(self, monkeypatch: pyt """The figures are asked for as the menu opens, so they answer for the row right-clicked.""" measured: List[str] = [] - def _measure(voice_id: str) -> SampleFootprintViewModel: + def _measure(voice_id: str) -> VoiceFootprintViewModel: measured.append(voice_id) return FOOTPRINT fixture = _panel(monkeypatch) - fixture.panel.sample_footprint = _measure + fixture.panel.voice_footprint = _measure fixture.menu._footprint_items("lead-id") @@ -718,7 +718,7 @@ def test_a_row_claiming_the_press_leaves_the_list_menu_unbuilt( monkeypatch.setattr(panel_module.dpg, "get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID)) fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON) - fixture.panel._on_sample_clicked(0, (RIGHT_BUTTON, 0)) + fixture.panel._on_voice_clicked(0, (RIGHT_BUTTON, 0)) fixture.panel._show_list_menu() assert build_recorder.widgets == [] From 9a397bac18396c9f44ebb078f5fe72f909854d41 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 00:36:28 +0200 Subject: [PATCH 131/142] Sealed: the graph layers into a package so they stop shadowing the standard library --- src/sampletones_application/ui/elements/graphs/layers/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/sampletones_application/ui/elements/graphs/layers/__init__.py diff --git a/src/sampletones_application/ui/elements/graphs/layers/__init__.py b/src/sampletones_application/ui/elements/graphs/layers/__init__.py new file mode 100644 index 000000000..e69de29bb From a0ae5121dc81ebb13278bef4dcb8d3e8a9b34123 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 00:37:24 +0200 Subject: [PATCH 132/142] Divided: the sequencer grid panels into subpackages --- .../coordinators/tabs/sequencer.py | 4 +- .../ui/panels/sequencer/order/__init__.py | 0 .../ui/panels/sequencer/order/callbacks.py | 23 + .../ui/panels/sequencer/order/menu.py | 222 +++++++++ .../ui/panels/sequencer/order/moves.py | 11 + .../sequencer/{order.py => order/panel.py} | 241 ++-------- .../ui/panels/sequencer/tracker/__init__.py | 0 .../ui/panels/sequencer/tracker/adjust.py | 68 +++ .../ui/panels/sequencer/tracker/callbacks.py | 28 ++ .../ui/panels/sequencer/tracker/menu.py | 311 +++++++++++++ .../{tracker.py => tracker/panel.py} | 436 +++--------------- .../ui/panels/sequencer/tracker/themes.py | 124 +++++ .../view_model/sequencer/kind.py | 23 + .../coordinators/tabs/test_sequencer.py | 6 +- .../ui/panels/sequencer/conftest.py | 2 +- .../ui/panels/sequencer/test_block_keys.py | 6 +- .../ui/panels/sequencer/test_block_menu.py | 60 ++- .../panels/sequencer/test_order_channels.py | 25 +- .../ui/panels/sequencer/test_order_keys.py | 2 +- .../ui/panels/sequencer/test_order_remove.py | 2 +- .../ui/panels/sequencer/test_panel_escape.py | 4 +- .../panels/sequencer/test_panel_tab_gate.py | 4 +- .../panels/sequencer/test_selection_drag.py | 8 +- .../panels/sequencer/test_selection_keys.py | 4 +- .../sequencer/test_tracker_cell_themes.py | 66 +-- .../panels/sequencer/test_tracker_channels.py | 16 +- .../sequencer/test_tracker_navigation.py | 4 +- .../ui/panels/sequencer/test_tracker_piano.py | 2 +- .../sequencer/test_tracker_play_shortcut.py | 2 +- .../ui/panels/sequencer/test_tracker_rows.py | 4 +- .../sequencer/test_tracker_typed_voice.py | 2 +- .../ui/panels/sequencer/tracker/__init__.py | 0 .../test_header_menu.py} | 25 +- .../test_menu.py} | 173 +++---- .../panels/sequencer/tracker/test_themes.py | 94 ++++ 35 files changed, 1247 insertions(+), 755 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/order/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/order/callbacks.py create mode 100644 src/sampletones_application/ui/panels/sequencer/order/menu.py create mode 100644 src/sampletones_application/ui/panels/sequencer/order/moves.py rename src/sampletones_application/ui/panels/sequencer/{order.py => order/panel.py} (86%) create mode 100644 src/sampletones_application/ui/panels/sequencer/tracker/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/tracker/adjust.py create mode 100644 src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py create mode 100644 src/sampletones_application/ui/panels/sequencer/tracker/menu.py rename src/sampletones_application/ui/panels/sequencer/{tracker.py => tracker/panel.py} (82%) create mode 100644 src/sampletones_application/ui/panels/sequencer/tracker/themes.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/tracker/__init__.py rename tests/unit/sampletones_application/ui/panels/sequencer/{test_tracker_header_menu.py => tracker/test_header_menu.py} (93%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_tracker_context_menu.py => tracker/test_menu.py} (60%) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 3ab100328..ea0f094d9 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -85,8 +85,8 @@ from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.ui.panels.sequencer.voices.panel import ( GUISequencerVoicesPanel, ) diff --git a/src/sampletones_application/ui/panels/sequencer/order/__init__.py b/src/sampletones_application/ui/panels/sequencer/order/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/order/callbacks.py b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py new file mode 100644 index 000000000..c8f2776d9 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py @@ -0,0 +1,23 @@ +from typing import Callable, Optional, Tuple + +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.input.order import OrderCursor +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion +from sampletones_core.constants.enums import ChannelName + +OrderKey = Tuple[Optional[ChannelName], int] + +OnFrameSelectedCallback = Callable[[int], None] +OnRemoveCallback = Callable[[int], None] +OnFrameActionCallback = Callable[[int], None] +OnMoveCallback = Callable[[int, int], None] +OnSetOrderEntryCallback = Callable[[ChannelName, int, Optional[int]], None] +OnSetMasterEntryCallback = Callable[[int, Optional[int]], None] +OnChannelMuteToggledCallback = Callable[[ChannelName], None] +OnChannelSoloedCallback = Callable[[ChannelName], None] +OnBlockRegionCallback = Callable[[OrderRegion], None] +OnPasteBlockCallback = Callable[[OrderCell], None] +CanPasteBlockQuery = Callable[[], bool] + +OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget] diff --git a/src/sampletones_application/ui/panels/sequencer/order/menu.py b/src/sampletones_application/ui/panels/sequencer/order/menu.py new file mode 100644 index 000000000..a2289310d --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/order/menu.py @@ -0,0 +1,222 @@ +from typing import Optional, Protocol + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.sequencer import SequencerOrderElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.context_menu import add_play_menu_item, context_menu +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.sequencer.channels import ChannelSwitch +from sampletones_application.ui.panels.sequencer.display import cell_title +from sampletones_application.ui.panels.sequencer.input.order import OrderCursor +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget +from sampletones_application.ui.panels.sequencer.order.callbacks import ( + OnFrameActionCallback, + OnMoveCallback, + OnRemoveCallback, + OrderEditSurface, +) +from sampletones_application.ui.panels.sequencer.order.moves import MOVE_DIRECTIONS +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.utils.callbacks import CallbackMixin + + +class OrderMenuHost(Protocol): + """What the order panel states to the menus raised over its table. + + The hooks are the panel's own, so the coordinator keeps wiring them where it already does, and + a menu reads whichever answer stands at the moment it opens. + """ + + on_play_from_requested: Optional[OnFrameActionCallback] + on_duplicate_requested: Optional[OnFrameActionCallback] + on_clone_requested: Optional[OnFrameActionCallback] + on_insert_requested: Optional[OnFrameActionCallback] + on_clear_requested: Optional[OnFrameActionCallback] + on_remove_requested: Optional[OnRemoveCallback] + on_move_requested: Optional[OnMoveCallback] + + @property + def edit_surface(self) -> OrderEditSurface: ... + + @property + def channel_switch(self) -> ChannelSwitch: ... + + @property + def channels(self) -> Optional[SequencerChannelsViewModel]: ... + + @property + def position_count(self) -> int: ... + + def row_label(self, channel: Optional[ChannelName]) -> str: ... + + def select_shape(self, shortcut_id: ShortcutId, cell: OrderCursor) -> bool: ... + + +class OrderMenu(CallbackMixin): + """Every menu the order table offers, wherever a reader raised it. + + A cell menu names the frame a pointer landed on and a row-label menu the channel beneath it, + while the menu bar's **Edit** group asks for the cell the cursor stands on. The table states + its actions once in :meth:`add_action_items`, so an action added there reaches every door. + + What a menu offers about a frame is asked for as it opens — how far it can move, which channels + stand silenced — which keeps what a menu prints and what a click does one answer. + """ + + def __init__( + self, + panel: OrderMenuHost, + *, + language_manager: LanguageManager, + shortcut_source: ShortcutSource, + ) -> None: + self._panel = panel + self._shortcuts = shortcut_source + self._lbl_play = self._label(language_manager, SequencerOrderElements.CONTEXT_PLAY) + self._lbl_select_all = self._label(language_manager, SequencerOrderElements.CONTEXT_SELECT_ALL) + self._lbl_select_row = self._label(language_manager, SequencerOrderElements.CONTEXT_SELECT_ROW) + self._lbl_duplicate = self._label(language_manager, SequencerOrderElements.CONTEXT_DUPLICATE) + self._lbl_clone = self._label(language_manager, SequencerOrderElements.CONTEXT_CLONE) + self._lbl_insert = self._label(language_manager, SequencerOrderElements.CONTEXT_INSERT) + self._lbl_clear = self._label(language_manager, SequencerOrderElements.CONTEXT_CLEAR) + self._lbl_remove = self._label(language_manager, SequencerOrderElements.CONTEXT_REMOVE) + self._lbl_move_left = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_LEFT) + self._lbl_move_right = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_RIGHT) + self._lbl_move_start = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_START) + self._lbl_move_end = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_END) + + @staticmethod + def _label( + language_manager: LanguageManager, + element: SequencerOrderElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.ORDER, + TextType.LABEL, + element, + ] + + def show_for_channel(self, channel: Optional[ChannelName]) -> None: + """Opens the menu behind a row label, titled with the row's own name.""" + with context_menu(): + header = dpg.add_text(self._panel.row_label(channel)) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + self._panel.channel_switch.add_menu_items(channel, self._panel.channels) + + def show_for_cell( + self, + channel: Optional[ChannelName], + position: int, + ) -> None: + """Opens the frame-operations menu, titled with the cell the pointer landed on.""" + target = self._panel.edit_surface.target_at(OrderCursor(channel, position)) + with context_menu(): + header = dpg.add_text(cell_title(position, self._panel.row_label(channel))) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + add_play_menu_item( + self._lbl_play, + lambda: self.call(self._panel.on_play_from_requested, position), + shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), + ) + dpg.add_separator() + self.add_action_items(target) + + def add_action_items(self, target: OrderTarget) -> None: + """Builds every action an order cell offers, in the order each menu prints them. + + The table states its actions once, and whoever asks for them decides where they are shown: + the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the + cursor stands on. An action added here reaches both. + """ + self._add_select_items(target.cell) + dpg.add_separator() + self._panel.edit_surface.add_block_items(target) + dpg.add_separator() + self._add_frame_items(target.cell.position) + dpg.add_separator() + self._add_move_items(target.cell.position) + + def _add_select_items(self, cell: OrderCursor) -> None: + """Builds the two shapes a selection takes, the whole order and one row of it. + + Each item fires the gesture its key fires, on the cell the menu names: a row selected from + a cell menu is the row that cell stands in, and one selected from the menu bar is the row + the cursor stands in. + """ + dpg.add_menu_item( + label=self._lbl_select_all, + shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL), + callback=lambda: self._panel.select_shape(ShortcutId.ORDER_SELECT_ALL, cell), + ) + dpg.add_menu_item( + label=self._lbl_select_row, + shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW), + callback=lambda: self._panel.select_shape(ShortcutId.ORDER_SELECT_ROW, cell), + ) + + def _add_frame_items(self, position: int) -> None: + """Builds the frame operations, each acting on the whole frame the target cell sits in. + + Each item reads its hook as it fires rather than as it is built, so an item answers + whatever the coordinator has wired by the time a reader clicks it. + """ + dpg.add_menu_item( + label=self._lbl_duplicate, + shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), + callback=lambda: self.call(self._panel.on_duplicate_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_clone, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), + callback=lambda: self.call(self._panel.on_clone_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_insert, + shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), + callback=lambda: self.call(self._panel.on_insert_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_clear, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME), + callback=lambda: self.call(self._panel.on_clear_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_remove, + shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), + callback=lambda: self.call(self._panel.on_remove_requested, position), + ) + + def _add_move_items(self, position: int) -> None: + """Builds the four moves a frame can make, in the order they walk the song.""" + self._add_move_item(self._lbl_move_left, ShortcutId.ORDER_MOVE_FRAME_LEFT, position) + self._add_move_item(self._lbl_move_right, ShortcutId.ORDER_MOVE_FRAME_RIGHT, position) + self._add_move_item(self._lbl_move_start, ShortcutId.ORDER_MOVE_FRAME_TO_START, position) + self._add_move_item(self._lbl_move_end, ShortcutId.ORDER_MOVE_FRAME_TO_END, position) + + def _add_move_item( + self, + label: str, + shortcut_id: ShortcutId, + position: int, + ) -> None: + """Adds a move item, grayed out (disabled) when the move would have no effect. + + The action names both the direction it moves and the accelerator it prints, so the item a + reader sees is the one the key press performs. + """ + target = MOVE_DIRECTIONS[shortcut_id].target(position, self._panel.position_count) + dpg.add_menu_item( + label=label, + shortcut=self._shortcuts.display(shortcut_id), + enabled=target is not None, + callback=lambda: self.call(self._panel.on_move_requested, position, target), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/order/moves.py b/src/sampletones_application/ui/panels/sequencer/order/moves.py new file mode 100644 index 000000000..96bea9810 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/order/moves.py @@ -0,0 +1,11 @@ +from typing import Dict, Final + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.move import MoveDirection + +MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { + ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS, + ShortcutId.ORDER_MOVE_FRAME_RIGHT: MoveDirection.NEXT, + ShortcutId.ORDER_MOVE_FRAME_TO_START: MoveDirection.FIRST, + ShortcutId.ORDER_MOVE_FRAME_TO_END: MoveDirection.LAST, +} diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py similarity index 86% rename from src/sampletones_application/ui/panels/sequencer/order.py rename to src/sampletones_application/ui/panels/sequencer/order/panel.py index e57dddcd8..8dda52a4d 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple +from typing import Dict, Final, FrozenSet, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -25,10 +25,6 @@ TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, TAG_SEQUENCER_THEME_TABLE_ORDER, ) -from sampletones_application.ui.elements.context_menu import ( - add_play_menu_item, - context_menu, -) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel @@ -44,7 +40,6 @@ ChannelSwitch, channel_tooltip, ) -from sampletones_application.ui.panels.sequencer.display import cell_title from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( HorizontalScroll, @@ -64,6 +59,23 @@ OrderInputState, ) from sampletones_application.ui.panels.sequencer.input.target import OrderTarget +from sampletones_application.ui.panels.sequencer.order.callbacks import ( + CanPasteBlockQuery, + OnBlockRegionCallback, + OnChannelMuteToggledCallback, + OnChannelSoloedCallback, + OnFrameActionCallback, + OnFrameSelectedCallback, + OnMoveCallback, + OnPasteBlockCallback, + OnRemoveCallback, + OnSetMasterEntryCallback, + OnSetOrderEntryCallback, + OrderEditSurface, + OrderKey, +) +from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu +from sampletones_application.ui.panels.sequencer.order.moves import MOVE_DIRECTIONS from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, create_selectable_text_theme, @@ -88,7 +100,6 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_application.view_model.sequencer.move import MoveDirection from sampletones_application.view_model.sequencer.order import ( SequencerOrderTrackerViewModel, ) @@ -98,28 +109,6 @@ from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback -OrderKey = Tuple[Optional[ChannelName], int] - -OnFrameSelectedCallback = Callable[[int], None] -OnRemoveCallback = Callable[[int], None] -OnFrameActionCallback = Callable[[int], None] -OnMoveCallback = Callable[[int, int], None] -OnSetOrderEntryCallback = Callable[[ChannelName, int, Optional[int]], None] -OnSetMasterEntryCallback = Callable[[int, Optional[int]], None] -OnChannelMuteToggledCallback = Callable[[ChannelName], None] -OnChannelSoloedCallback = Callable[[ChannelName], None] -OnBlockRegionCallback = Callable[[OrderRegion], None] -OnPasteBlockCallback = Callable[[OrderCell], None] -CanPasteBlockQuery = Callable[[], bool] -OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget] - -MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { - ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS, - ShortcutId.ORDER_MOVE_FRAME_RIGHT: MoveDirection.NEXT, - ShortcutId.ORDER_MOVE_FRAME_TO_START: MoveDirection.FIRST, - ShortcutId.ORDER_MOVE_FRAME_TO_END: MoveDirection.LAST, -} - MASTER_TABLE_ROW: Final[int] = 0 DIVIDER_TABLE_ROW: Final[int] = 1 @@ -221,9 +210,13 @@ def __init__( ) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) - self._load_context_labels(language_manager) self._load_label_tooltips(language_manager) self._create_channel_switch(language_manager) + self._menu = OrderMenu( + self, + language_manager=language_manager, + shortcut_source=shortcut_source, + ) super().__init__( tag=TAG_SEQUENCER_ORDER_PANEL, @@ -248,23 +241,6 @@ def _load_row_labels(self, language_manager: LanguageManager) -> None: ChannelName.NOISE: self._label(language_manager, SequencerOrderElements.ROW_NOISE), } - def _load_context_labels(self, language_manager: LanguageManager) -> None: - def label(element: SequencerOrderElements) -> str: - return self._label(language_manager, element) - - self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) - self._lbl_context_select_all = label(SequencerOrderElements.CONTEXT_SELECT_ALL) - self._lbl_context_select_row = label(SequencerOrderElements.CONTEXT_SELECT_ROW) - self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) - self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) - self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) - self._lbl_context_clear = label(SequencerOrderElements.CONTEXT_CLEAR) - self._lbl_context_remove = label(SequencerOrderElements.CONTEXT_REMOVE) - self._lbl_context_move_left = label(SequencerOrderElements.CONTEXT_MOVE_LEFT) - self._lbl_context_move_right = label(SequencerOrderElements.CONTEXT_MOVE_RIGHT) - self._lbl_context_move_start = label(SequencerOrderElements.CONTEXT_MOVE_START) - self._lbl_context_move_end = label(SequencerOrderElements.CONTEXT_MOVE_END) - def _load_label_tooltips(self, language_manager: LanguageManager) -> None: """Reads the row-label tooltips, which name the click gestures the labels carry.""" @@ -1026,7 +1002,7 @@ def _on_cell_right_clicked( return channel, position = key - self._show_context_menu(channel, position) + self._menu.show_for_cell(channel, position) def _on_label_clicked( self, @@ -1053,41 +1029,7 @@ def _on_label_right_clicked( if clicked_item not in self._label_rows: return - self._show_channel_menu(self._label_rows[clicked_item]) - - def _show_channel_menu(self, channel: Optional[ChannelName]) -> None: - """Opens the menu behind a row label, titled with the row's own name.""" - with context_menu(): - header = dpg.add_text(self._row_labels[channel]) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - dpg.add_separator() - self._channel_switch.add_menu_items( - channel, - self._current_channels, - ) - - def _show_context_menu( - self, - channel: Optional[ChannelName], - position: int, - ) -> None: - target = self._surface.target_at(OrderCursor(channel, position)) - with context_menu(): - header = dpg.add_text( - cell_title( - position, - self._row_labels[channel], - ) - ) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - dpg.add_separator() - add_play_menu_item( - self._lbl_context_play, - lambda: self.call(self.on_play_from_requested, position), - shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), - ) - dpg.add_separator() - self.add_action_items(target) + self._menu.show_for_channel(self._label_rows[clicked_item]) # TODO: to abstract @property @@ -1105,121 +1047,28 @@ def owns_keys(self) -> bool: """Whether the table owns the next key, which is also what the Edit menu asks.""" return self._keys_active() - def add_action_items(self, target: OrderTarget) -> None: - """Builds every action an order cell offers, in the order each menu prints them. - - The table states its actions once, and whoever asks for them decides where they are shown: - the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the - cursor stands on. An action added here reaches both. - """ - self._add_select_items(target.cell) - dpg.add_separator() - self._surface.add_block_items(target) - dpg.add_separator() - self._add_frame_items(target.cell.position) - dpg.add_separator() - self._add_move_items(target.cell.position) - - def _add_select_items(self, cell: OrderCursor) -> None: - """Builds the two shapes a selection takes, the whole order and one row of it. - - Each item fires the gesture its key fires, on the cell the menu names: a row selected from - a cell menu is the row that cell stands in, and one selected from the menu bar is the row - the cursor stands in. - """ - dpg.add_menu_item( - label=self._lbl_context_select_all, - shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL), - callback=lambda: self._select_shape( - ShortcutId.ORDER_SELECT_ALL, - cell, - ), - ) - dpg.add_menu_item( - label=self._lbl_context_select_row, - shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW), - callback=lambda: self._select_shape( - ShortcutId.ORDER_SELECT_ROW, - cell, - ), - ) + @property + def channel_switch(self) -> ChannelSwitch: + """The switch a row label's click and menu act through.""" + return self._channel_switch - def _add_frame_items(self, position: int) -> None: - """Builds the frame operations, each acting on the whole frame the target cell sits in.""" - dpg.add_menu_item( - label=self._lbl_context_duplicate, - shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), - callback=lambda: self.call(self.on_duplicate_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_clone, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), - callback=lambda: self.call(self.on_clone_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_insert, - shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), - callback=lambda: self.call(self.on_insert_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_clear, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME), - callback=lambda: self.call(self.on_clear_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_remove, - shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), - callback=lambda: self.call(self.on_remove_requested, position), - ) + @property + def channels(self) -> Optional[SequencerChannelsViewModel]: + """Which channels stand silenced, as the last view the panel was given states it.""" + return self._current_channels - def _add_move_items(self, position: int) -> None: - """Builds the four moves a frame can make, in the order they walk the song.""" - self._add_move_item( - self._lbl_context_move_left, - ShortcutId.ORDER_MOVE_FRAME_LEFT, - position, - ) - self._add_move_item( - self._lbl_context_move_right, - ShortcutId.ORDER_MOVE_FRAME_RIGHT, - position, - ) - self._add_move_item( - self._lbl_context_move_start, - ShortcutId.ORDER_MOVE_FRAME_TO_START, - position, - ) - self._add_move_item( - self._lbl_context_move_end, - ShortcutId.ORDER_MOVE_FRAME_TO_END, - position, - ) + @property + def position_count(self) -> int: + """How many frames the order holds, which is how far a move can carry one.""" + return self._position_count - def _add_move_item( - self, - label: str, - shortcut_id: ShortcutId, - position: int, - ) -> None: - """Adds a move item, grayed out (disabled) when the move would have no effect. + def row_label(self, channel: Optional[ChannelName]) -> str: + """The name a row carries, which its label and its menu title show.""" + return self._row_labels[channel] - The action names both the direction it moves and the accelerator it prints, so the item a - reader sees is the one the key press performs. - """ - target = MOVE_DIRECTIONS[shortcut_id].target( - position, - self._position_count, - ) - dpg.add_menu_item( - label=label, - shortcut=self._shortcuts.display(shortcut_id), - enabled=target is not None, - callback=lambda: self.call( - self.on_move_requested, - position, - target, - ), - ) + def add_action_items(self, target: OrderTarget) -> None: + """Builds every action an order cell offers, which is what the menu bar's Edit group asks for.""" + self._menu.add_action_items(target) def _keys_active(self) -> bool: """Whether the order table owns the next key: its tab is in front, its cursor is set, and @@ -1254,7 +1103,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True - if self._select_shape(shortcut_id, cursor): + if self.select_shape(shortcut_id, cursor): return True if self._block_action(shortcut_id): @@ -1309,7 +1158,7 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True - def _select_shape( + def select_shape( self, shortcut_id: ShortcutId, cell: OrderCursor, diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/__init__.py b/src/sampletones_application/ui/panels/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/adjust.py b/src/sampletones_application/ui/panels/sequencer/tracker/adjust.py new file mode 100644 index 000000000..1d2dcd4ec --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/adjust.py @@ -0,0 +1,68 @@ +from typing import Callable, Dict, Final, Tuple + +from sampletones_application.categories.elements.sequencer import SequencerTrackerElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP +from sampletones_shared.types.application import Sender + +VOLUME_FINE_STEP: Final[int] = 1 +VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 + +AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int] +AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None] + +TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_UP, + ShortcutId.TRACKER_TRANSPOSE_UP, + SEMITONE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN, + ShortcutId.TRACKER_TRANSPOSE_DOWN, + -SEMITONE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP, + ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + OCTAVE_SEMITONES, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN, + ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + -OCTAVE_SEMITONES, + ), +) + +VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( + ( + SequencerTrackerElements.CONTEXT_VOLUME_UP, + ShortcutId.TRACKER_VOLUME_UP, + VOLUME_FINE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_DOWN, + ShortcutId.TRACKER_VOLUME_DOWN, + -VOLUME_FINE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE, + ShortcutId.TRACKER_VOLUME_UP_COARSE, + VOLUME_COARSE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE, + ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + -VOLUME_COARSE_STEP, + ), +) + + +def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]: + return {shortcut_id: delta for _, shortcut_id, delta in actions} + + +TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS) +VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py new file mode 100644 index 000000000..937d18bf4 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py @@ -0,0 +1,28 @@ +from typing import Callable, Optional, Tuple + +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceKind +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import VoidCallback + +OnClearRowCallback = Callable[[int, Optional[ChannelName]], None] +OnClearSubcolumnCallback = Callable[[int, Optional[ChannelName], SubColumn], None] +OnSetRowCallback = Callable[[int, Optional[ChannelName], Optional[str], Optional[int], Optional[int]], None] +OnSetNoteOffCallback = Callable[[int, Optional[ChannelName]], None] +OnNoteTypedCallback = Callable[[int, ChannelName, int], None] +OnCellSelectedCallback = VoidCallback +OnPlayFromRowCallback = Callable[[int], None] +OnPlayFromFrameCallback = VoidCallback +OnAdjustCallback = Callable[[TrackerRegion, int], None] +OnChannelMuteToggledCallback = Callable[[ChannelName], None] +OnChannelSoloedCallback = Callable[[ChannelName], None] +OnBlockRegionCallback = Callable[[TrackerRegion], None] +OnPasteBlockCallback = Callable[[TrackerCell], None] +CanPasteBlockQuery = Callable[[], bool] + +TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] +ThemeKey = Tuple[SubColumn, Optional[VoiceKind]] diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/menu.py b/src/sampletones_application/ui/panels/sequencer/tracker/menu.py new file mode 100644 index 000000000..abf5d8327 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/menu.py @@ -0,0 +1,311 @@ +from typing import Dict, Optional, Protocol, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.sequencer import SequencerTrackerElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.context_menu import add_play_menu_item, context_menu +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.sequencer import display as tracker_display +from sampletones_application.ui.panels.sequencer.channels import ChannelSwitch +from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_application.ui.panels.sequencer.tracker.adjust import ( + TRANSPOSE_ACTIONS, + VOLUME_ACTIONS, + AdjustAction, + AdjustMenuCallback, +) +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ( + OnAdjustCallback, + OnClearRowCallback, + OnClearSubcolumnCallback, + OnPlayFromFrameCallback, + OnPlayFromRowCallback, + OnSetNoteOffCallback, + OnSetRowCallback, + TrackerEditSurface, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel +from sampletones_application.view_model.sequencer.kind import column_takes +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.callbacks import CallbackMixin + + +class TrackerMenuHost(Protocol): + """What the tracker panel states to the menus raised over its grid. + + The hooks are the panel's own, so the coordinator keeps wiring them where it already does, and + a menu reads whichever answer stands at the moment it opens. + """ + + on_clear_row: Optional[OnClearRowCallback] + on_clear_subcolumn: Optional[OnClearSubcolumnCallback] + on_set_row: Optional[OnSetRowCallback] + on_set_note_off: Optional[OnSetNoteOffCallback] + on_play_from_row: Optional[OnPlayFromRowCallback] + on_play_from_frame: Optional[OnPlayFromFrameCallback] + on_adjust_transpose: Optional[OnAdjustCallback] + on_adjust_volume: Optional[OnAdjustCallback] + + @property + def edit_surface(self) -> TrackerEditSurface: ... + + @property + def channel_switch(self) -> ChannelSwitch: ... + + @property + def channels(self) -> Optional[SequencerChannelsViewModel]: ... + + @property + def voices(self) -> Tuple[VoiceEntryViewModel, ...]: ... + + def column_label(self, channel: Optional[ChannelName]) -> str: ... + + def select_shape(self, shortcut_id: ShortcutId, cell: TrackerCursor) -> bool: ... + + +class TrackerMenu(CallbackMixin): + """Every menu the tracker grid offers, wherever a reader raised it. + + A cell menu names the cell a pointer landed on and a header menu the column beneath it, while + the menu bar's **Edit** group asks for the cell the cursor stands on. The grid states its + actions once in :meth:`add_action_items`, so an action added there reaches every door. + + What a menu offers about a cell is asked for as it opens — the pool a voice is picked from, the + columns a block covers — which keeps what a menu prints and what a click does one answer. + """ + + def __init__( + self, + panel: TrackerMenuHost, + *, + language_manager: LanguageManager, + shortcut_source: ShortcutSource, + ) -> None: + self._panel = panel + self._shortcuts = shortcut_source + self._lbl_play = self._label(language_manager, SequencerTrackerElements.CONTEXT_PLAY) + self._lbl_play_from_frame = self._label(language_manager, SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) + self._lbl_select_all = self._label(language_manager, SequencerTrackerElements.CONTEXT_SELECT_ALL) + self._lbl_select_column = self._label(language_manager, SequencerTrackerElements.CONTEXT_SELECT_COLUMN) + self._lbl_select_subcolumn = self._label(language_manager, SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN) + self._lbl_note_off = self._label(language_manager, SequencerTrackerElements.CONTEXT_NOTE_OFF) + self._lbl_set_voice = self._label(language_manager, SequencerTrackerElements.CONTEXT_SET_VOICE) + self._lbl_no_voices = self._label(language_manager, SequencerTrackerElements.CONTEXT_NO_VOICES) + self._lbl_clear_subcolumn = self._label(language_manager, SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN) + self._lbl_clear_cell = self._label(language_manager, SequencerTrackerElements.CONTEXT_CLEAR_CELL) + self._lbl_clear_row = self._label(language_manager, SequencerTrackerElements.CONTEXT_CLEAR_ROW) + self._lbl_adjust: Dict[SequencerTrackerElements, str] = { + element: self._label(language_manager, element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS) + } + + @staticmethod + def _label( + language_manager: LanguageManager, + element: SequencerTrackerElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.TRACKER, + TextType.LABEL, + element, + ] + + def show_for_header(self, channel: Optional[ChannelName]) -> None: + """Opens the menu behind a column header, titled with the column's own name.""" + with context_menu(): + header = dpg.add_text(self._panel.column_label(channel)) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + self._panel.channel_switch.add_menu_items(channel, self._panel.channels) + + def show_for_cell( + self, + row_index: int, + channel: Optional[ChannelName], + subcolumn: SubColumn, + ) -> None: + """Opens the cell-operations menu, titled with the cell the pointer landed on.""" + target = self._panel.edit_surface.target_at(TrackerCursor(row_index, channel, subcolumn)) + with context_menu(): + header = dpg.add_text( + tracker_display.cell_title(row_index, self._panel.column_label(channel)), + ) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + add_play_menu_item( + self._lbl_play, + lambda: self.call(self._panel.on_play_from_row, row_index), + shortcut=self._shortcuts.display(ShortcutId.TRACKER_PLAY_FROM_ROW), + ) + add_play_menu_item( + self._lbl_play_from_frame, + lambda: self.call(self._panel.on_play_from_frame), + shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), + ) + dpg.add_separator() + self.add_action_items(target) + + def add_action_items(self, target: TrackerTarget) -> None: + """Builds every action a tracker cell offers, in the order each menu prints them. + + The grid states its actions once, and whoever asks for them decides where they are shown: + the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the + cursor stands on. An action added here reaches both. + """ + self._add_select_items(target.cell) + dpg.add_separator() + self._panel.edit_surface.add_block_items(target) + dpg.add_separator() + self._add_voice_submenu(target.cell) + dpg.add_menu_item( + label=self._lbl_note_off, + callback=lambda: self.call(self._panel.on_set_note_off, target.cell.row, target.cell.channel), + ) + dpg.add_separator() + self._add_transpose_items(target) + dpg.add_separator() + self._add_volume_items(target) + dpg.add_separator() + self._add_clear_items(target.cell) + + def _add_select_items(self, cell: TrackerCursor) -> None: + """Builds the three shapes a selection takes, from the whole frame down to one subcolumn. + + Each item fires the gesture its key fires, on the cell the menu names: a column selected + from a cell menu is the column that cell stands in, and one selected from the menu bar is + the column the cursor stands in. + """ + dpg.add_menu_item( + label=self._lbl_select_all, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL), + callback=lambda: self._panel.select_shape(ShortcutId.TRACKER_SELECT_ALL, cell), + ) + dpg.add_menu_item( + label=self._lbl_select_column, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN), + callback=lambda: self._panel.select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell), + ) + dpg.add_menu_item( + label=self._lbl_select_subcolumn, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN), + callback=lambda: self._panel.select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell), + ) + + def _add_voice_submenu(self, cell: TrackerCursor) -> None: + """Offers the pool to a cell, each voice enabled where that cell's column takes it. + + The whole pool is listed wherever the menu is raised, so a reader sees every voice the + project holds and where each one goes: a channel column takes any of them, while the + sample column spreads a voice over the channels it covers and so takes a recording alone. + A voice the column stands by for is offered unreachable, which says it exists while + leaving it where it belongs. + """ + with dpg.menu(label=self._lbl_set_voice): + voices = self._panel.voices + if not voices: + dpg.add_menu_item( + label=self._lbl_no_voices, + enabled=False, + ) + return + + for index, voice in enumerate(voices): + dpg.add_menu_item( + label=tracker_display.indexed_label(index, voice.name), + user_data=(cell.row, cell.channel, voice.voice_id), + callback=self._on_set_voice_menu, + enabled=column_takes(cell.channel, voice.kind), + ) + + def _add_transpose_items(self, target: TrackerTarget) -> None: + self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu) + + def _add_volume_items(self, target: TrackerTarget) -> None: + self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu) + + def _add_adjust_items( + self, + target: TrackerTarget, + actions: Tuple[AdjustAction, ...], + callback: AdjustMenuCallback, + ) -> None: + """Builds one axis of adjustment items, each shifting the cells its target covers. + + An adjustment acts on whole cells, so it reaches the columns the target's block covers and + the rows it spans: a nudge with a selection standing moves all of it, and one on a cell + alone moves that cell. Each item prints the key it answers to, since the action states its + label, its binding and its step in one entry. + """ + for element, shortcut_id, delta in actions: + dpg.add_menu_item( + label=self._lbl_adjust[element], + shortcut=self._shortcuts.display(shortcut_id), + user_data=(target.region, delta), + callback=callback, + ) + + def _on_set_voice_menu( + self, + _sender: Sender, + _app_data: None, + user_data: Tuple[int, Optional[ChannelName], str], + ) -> None: + row_index, channel, voice_id = user_data + self.call(self._panel.on_set_row, row_index, channel, voice_id, None, None) + + def _on_transpose_menu( + self, + _sender: Sender, + _app_data: None, + user_data: Tuple[TrackerRegion, int], + ) -> None: + region, delta = user_data + self.call(self._panel.on_adjust_transpose, region, delta) + + def _on_volume_menu( + self, + _sender: Sender, + _app_data: None, + user_data: Tuple[TrackerRegion, int], + ) -> None: + region, delta = user_data + self.call(self._panel.on_adjust_volume, region, delta) + + def _add_clear_items(self, cell: TrackerCursor) -> None: + """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row. + + The cell and row levels coincide on the sample column, which already clears every channel, + so the per-channel ``Clear cell`` item is offered only for an actual channel. + """ + dpg.add_menu_item( + label=self._lbl_clear_subcolumn, + callback=lambda: self.call( + self._panel.on_clear_subcolumn, + cell.row, + cell.channel, + cell.subcolumn, + ), + ) + if cell.channel is not None: + dpg.add_menu_item( + label=self._lbl_clear_cell, + callback=lambda: self.call( + self._panel.on_clear_row, + cell.row, + cell.channel, + ), + ) + dpg.add_menu_item( + label=self._lbl_clear_row, + callback=lambda: self.call(self._panel.on_clear_row, cell.row, None), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py similarity index 82% rename from src/sampletones_application/ui/panels/sequencer/tracker.py rename to src/sampletones_application/ui/panels/sequencer/tracker/panel.py index e1a6a2f28..5f74bd79d 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -28,10 +28,6 @@ TAG_SEQUENCER_TRACKER_TABLE, TAG_SEQUENCER_TRACKER_WINDOW, ) -from sampletones_application.ui.elements.context_menu import ( - add_play_menu_item, - context_menu, -) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel @@ -79,11 +75,29 @@ TrackerInputState, ) from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background -from sampletones_application.ui.themes.inline import ( - create_header_selectable_theme, - create_label_selectable_theme, - create_selectable_text_theme, +from sampletones_application.ui.panels.sequencer.tracker.adjust import ( + TRANSPOSE_STEPS, + VOLUME_STEPS, +) +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ( + CanPasteBlockQuery, + OnAdjustCallback, + OnBlockRegionCallback, + OnCellSelectedCallback, + OnChannelMuteToggledCallback, + OnChannelSoloedCallback, + OnClearRowCallback, + OnClearSubcolumnCallback, + OnNoteTypedCallback, + OnPasteBlockCallback, + OnPlayFromFrameCallback, + OnPlayFromRowCallback, + OnSetNoteOffCallback, + OnSetRowCallback, + TrackerEditSurface, ) +from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu +from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_delete_children from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -108,7 +122,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_application.view_model.sequencer.kind import places_across_channels +from sampletones_application.view_model.sequencer.kind import column_takes from sampletones_application.view_model.sequencer.region import ( TrackerCell, TrackerRegion, @@ -132,97 +146,18 @@ VoiceKind, ) from sampletones_core.constants.enums import ChannelName -from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import speaks_in_periods from sampletones_core.project.song_position import SongPosition from sampletones_core.utils.display import NOTE_OFF, display_id from sampletones_shared.constants.music import ( OCTAVE_OFFSET, OCTAVE_SEMITONES, - SEMITONE_STEP, ) from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback -OnClearRowCallback = Callable[[int, Optional[ChannelName]], None] -OnClearSubcolumnCallback = Callable[[int, Optional[ChannelName], SubColumn], None] -OnSetRowCallback = Callable[[int, Optional[ChannelName], Optional[str], Optional[int], Optional[int]], None] -OnSetNoteOffCallback = Callable[[int, Optional[ChannelName]], None] -OnNoteTypedCallback = Callable[[int, ChannelName, int], None] -OnCellSelectedCallback = VoidCallback -OnPlayFromRowCallback = Callable[[int], None] -OnPlayFromFrameCallback = VoidCallback -OnAdjustCallback = Callable[[TrackerRegion, int], None] -OnChannelMuteToggledCallback = Callable[[ChannelName], None] -OnChannelSoloedCallback = Callable[[ChannelName], None] -OnBlockRegionCallback = Callable[[TrackerRegion], None] -OnPasteBlockCallback = Callable[[TrackerCell], None] -CanPasteBlockQuery = Callable[[], bool] -TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] -ThemeKey = Tuple[SubColumn, Optional[VoiceKind]] - - -VOLUME_FINE_STEP: Final[int] = 1 -VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 PLAYHEAD_PAINT_FRAMES: Final[int] = 1 -AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int] -AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None] - -TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( - ( - SequencerTrackerElements.CONTEXT_TRANSPOSE_UP, - ShortcutId.TRACKER_TRANSPOSE_UP, - SEMITONE_STEP, - ), - ( - SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN, - ShortcutId.TRACKER_TRANSPOSE_DOWN, - -SEMITONE_STEP, - ), - ( - SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP, - ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, - OCTAVE_SEMITONES, - ), - ( - SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN, - ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, - -OCTAVE_SEMITONES, - ), -) - -VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( - ( - SequencerTrackerElements.CONTEXT_VOLUME_UP, - ShortcutId.TRACKER_VOLUME_UP, - VOLUME_FINE_STEP, - ), - ( - SequencerTrackerElements.CONTEXT_VOLUME_DOWN, - ShortcutId.TRACKER_VOLUME_DOWN, - -VOLUME_FINE_STEP, - ), - ( - SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE, - ShortcutId.TRACKER_VOLUME_UP_COARSE, - VOLUME_COARSE_STEP, - ), - ( - SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE, - ShortcutId.TRACKER_VOLUME_DOWN_COARSE, - -VOLUME_COARSE_STEP, - ), -) - - -def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]: - return {shortcut_id: delta for _, shortcut_id, delta in actions} - - -TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS) -VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS) - class GUISequencerTrackerPanel(GUIPanel): def __init__( @@ -281,12 +216,7 @@ def __init__( elapsed=dpg.get_delta_time, ) self._cell_kinds: CellKinds = {} - self._subcolumn_themes: Dict[ThemeKey, int] = {} - self._muted_subcolumn_themes: Dict[ThemeKey, int] = {} - self._row_number_theme: int = 0 - self._header_theme: int = 0 - self._muted_header_theme: int = 0 - self._column_label_theme: int = 0 + self._themes = TrackerThemes(layout) self._current_samples: Optional[SequencerVoicesViewModel] = None self._current_channels: Optional[SequencerChannelsViewModel] = None @@ -342,9 +272,13 @@ def __init__( SequencerTrackerElements.TRACKER_TEXT, ) self._load_column_labels(language_manager) - self._load_context_labels(language_manager) self._load_header_tooltips(language_manager) self._create_channel_switch(language_manager) + self._menu = TrackerMenu( + self, + language_manager=language_manager, + shortcut_source=shortcut_source, + ) super().__init__( tag=TAG_SEQUENCER_TRACKER_PANEL, @@ -375,25 +309,6 @@ def _label( element, ] - def _load_context_labels(self, language_manager: LanguageManager) -> None: - def label(element: SequencerTrackerElements) -> str: - return self._label(language_manager, element) - - self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) - self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) - self._lbl_context_select_all = label(SequencerTrackerElements.CONTEXT_SELECT_ALL) - self._lbl_context_select_column = label(SequencerTrackerElements.CONTEXT_SELECT_COLUMN) - self._lbl_context_select_subcolumn = label(SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN) - self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) - self._lbl_context_set_voice = label(SequencerTrackerElements.CONTEXT_SET_VOICE) - self._lbl_context_no_voices = label(SequencerTrackerElements.CONTEXT_NO_VOICES) - self._lbl_context_clear_subcolumn = label(SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN) - self._lbl_context_clear_cell = label(SequencerTrackerElements.CONTEXT_CLEAR_CELL) - self._lbl_context_clear_row = label(SequencerTrackerElements.CONTEXT_CLEAR_ROW) - self._lbl_adjust: Dict[SequencerTrackerElements, str] = { - element: label(element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS) - } - def _load_header_tooltips(self, language_manager: LanguageManager) -> None: """Reads the header tooltips, which name the click gestures the labels carry.""" @@ -458,55 +373,7 @@ def _setup_handlers(self) -> None: ) def _create_themes(self) -> None: - self._create_subcolumn_themes() - self._create_header_themes() - self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row) - - def _create_subcolumn_themes(self) -> None: - """Builds every text theme a cell can wear, in its full and its dimmed color. - - The voice slot carries one theme per kind of voice it can name, beside the shade it takes - while it names none, so the color of a cell reports what stands in it. Transpose and volume - speak for themselves and take one each. The dimmed variant keeps the same hue at reduced - alpha, so a silenced channel's values stay readable and editable while the others are - worked on. - """ - text = self._layout.colors.text - theme_colors: Dict[ThemeKey, BaseColor] = { - (SubColumn.VOICE, None): text.voice, - (SubColumn.VOICE, VoiceKind.SAMPLE): text.sample, - (SubColumn.VOICE, VoiceKind.INSTRUMENT): text.instrument, - (SubColumn.TRANSPOSE, None): text.transpose, - (SubColumn.VOLUME, None): text.volume, - } - fraction = self._layout.tracker.muted_text_fraction - for theme_key, color in theme_colors.items(): - self._subcolumn_themes[theme_key] = create_selectable_text_theme(color) - self._muted_subcolumn_themes[theme_key] = create_selectable_text_theme( - FadedColor( - color=color, - fraction=fraction, - ), - ) - - def _create_header_themes(self) -> None: - """Builds the two shades a channel's header label takes: audible and silenced. - - Both carry the header's own hover and press washes, so a label reads as the switch it is - while its text color reports whether the channel sounds. - """ - header = self._layout.colors.header - self._header_theme = create_header_selectable_theme( - self._layout.colors.label, - header.hovered, - header.active, - ) - self._muted_header_theme = create_header_selectable_theme( - self._layout.colors.muted.text, - header.hovered, - header.active, - ) - self._column_label_theme = create_label_selectable_theme(self._layout.colors.label) + self._themes.create() def _create_octave_control(self) -> None: """Offers the octave a note key types at, which is what turns one key row into a keyboard.""" @@ -893,7 +760,7 @@ def _add_header_label_cell(self, row_id: Sender) -> None: label=self._lbl_col_row, height=self._layout.tracker.header_height, ) - dpg.bind_item_theme(label, self._column_label_theme) + dpg.bind_item_theme(label, self._themes.column_label) def _add_header_selectable( self, @@ -954,7 +821,7 @@ def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None: callback=self._on_row_number_clicked, ) FontRegistry.bind_to_item(selectable, Font.MONO_SMALL) - dpg.bind_item_theme(selectable, self._row_number_theme) + dpg.bind_item_theme(selectable, self._themes.row_number) dpg.bind_item_handler_registry(selectable, self._item_handler_tag) self._rows[row_index] = selectable @@ -1078,10 +945,7 @@ def _apply_channel_cues(self) -> None: def _bind_header_themes(self) -> None: for selectable, channel in self._header_columns.items(): muted = channel is not None and self._is_muted(channel) - dpg.bind_item_theme( - selectable, - self._muted_header_theme if muted else self._header_theme, - ) + dpg.bind_item_theme(selectable, self._themes.header(muted=muted)) def _bind_channel_cell_themes(self, channel: ChannelName) -> None: for row_index in range(self._current_row_count): @@ -1102,9 +966,11 @@ def _cell_theme(self, key: CellKey) -> int: neutral shade the other slots' colors are read against. """ _, channel, subcolumn = key - kind = self._cell_kinds.get(key) - themes = self._muted_subcolumn_themes if self._is_muted_cell(channel) else self._subcolumn_themes - return themes[(subcolumn, kind)] + return self._themes.cell( + subcolumn, + self._cell_kinds.get(key), + muted=self._is_muted_cell(channel), + ) def _is_muted_cell(self, channel: Optional[ChannelName]) -> bool: """Whether a cell's channel is silenced, the sample column speaking for every channel.""" @@ -1162,7 +1028,7 @@ def _resolve_voice( voices = self._current_samples.voices sample_index = max(0, min(sample_index, len(voices) - 1)) voice = voices[sample_index] - if not self._column_takes(channel, voice): + if not column_takes(channel, voice.kind): return None return sample_index, voice @@ -1456,18 +1322,7 @@ def _on_header_right_clicked( if clicked_item not in self._header_columns: return - self._show_header_context_menu(self._header_columns[clicked_item]) - - def _show_header_context_menu( - self, - channel: Optional[ChannelName], - ) -> None: - """Opens the menu behind a column header, titled with the column's own name.""" - with context_menu(): - header = dpg.add_text(self._column_labels[channel]) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - dpg.add_separator() - self._channel_switch.add_menu_items(channel, self._current_channels) + self._menu.show_for_header(self._header_columns[clicked_item]) def _on_cell_right_clicked( self, @@ -1488,33 +1343,7 @@ def _on_cell_right_clicked( return row_index, channel, subcolumn = key - self._show_context_menu(row_index, channel, subcolumn) - - def _show_context_menu( - self, - row_index: int, - channel: Optional[ChannelName], - subcolumn: SubColumn, - ) -> None: - target = self._surface.target_at(TrackerCursor(row_index, channel, subcolumn)) - with context_menu(): - header = dpg.add_text( - tracker_display.cell_title(row_index, self._column_labels[channel]), - ) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - dpg.add_separator() - add_play_menu_item( - self._lbl_context_play, - lambda: self.call(self.on_play_from_row, row_index), - shortcut=self._shortcuts.display(ShortcutId.TRACKER_PLAY_FROM_ROW), - ) - add_play_menu_item( - self._lbl_context_play_from_frame, - lambda: self.call(self.on_play_from_frame), - shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), - ) - dpg.add_separator() - self.add_action_items(target) + self._menu.show_for_cell(row_index, channel, subcolumn) # TODO: to abstract @property @@ -1532,171 +1361,28 @@ def owns_keys(self) -> bool: """Whether the grid owns the next key, which is also what the Edit menu asks.""" return self._keys_active() - def add_action_items(self, target: TrackerTarget) -> None: - """Builds every action a tracker cell offers, in the order each menu prints them. - - The grid states its actions once, and whoever asks for them decides where they are shown: - the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the - cursor stands on. An action added here reaches both. - """ - self._add_select_items(target.cell) - dpg.add_separator() - self._surface.add_block_items(target) - dpg.add_separator() - self._add_voice_submenu(target.cell) - dpg.add_menu_item( - label=self._lbl_context_note_off, - callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.channel), - ) - dpg.add_separator() - self._add_transpose_items(target) - dpg.add_separator() - self._add_volume_items(target) - dpg.add_separator() - self._add_clear_items(target.cell) - - def _add_select_items(self, cell: TrackerCursor) -> None: - """Builds the three shapes a selection takes, from the whole frame down to one subcolumn. - - Each item fires the gesture its key fires, on the cell the menu names: a column selected - from a cell menu is the column that cell stands in, and one selected from the menu bar is - the column the cursor stands in. - """ - dpg.add_menu_item( - label=self._lbl_context_select_all, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL), - callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_ALL, cell), - ) - dpg.add_menu_item( - label=self._lbl_context_select_column, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN), - callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell), - ) - dpg.add_menu_item( - label=self._lbl_context_select_subcolumn, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN), - callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell), - ) - - def _add_voice_submenu(self, cell: TrackerCursor) -> None: - """Offers the pool to a cell, each voice enabled where that cell's column takes it. - - The whole pool is listed wherever the menu is raised, so a reader sees every voice the - project holds and where each one goes: a channel column takes any of them, while the - sample column spreads a voice over the channels it covers and so takes a recording alone. - A voice the column stands by for is offered unreachable, which says it exists while - leaving it where it belongs. - """ - with dpg.menu(label=self._lbl_context_set_voice): - voices = self._current_samples.voices if self._current_samples is not None else () - if not voices: - dpg.add_menu_item( - label=self._lbl_context_no_voices, - enabled=False, - ) - return - - for index, voice in enumerate(voices): - dpg.add_menu_item( - label=tracker_display.indexed_label(index, voice.name), - user_data=(cell.row, cell.channel, voice.voice_id), - callback=self._on_set_voice_menu, - enabled=self._column_takes(cell.channel, voice), - ) - - @staticmethod - def _column_takes( - channel: Optional[ChannelName], - voice: VoiceEntryViewModel, - ) -> bool: - """Whether the column a cell stands in places the voice a row of the menu names.""" - if channel is not None: - return True - - return places_across_channels(voice.kind) - - def _add_transpose_items(self, target: TrackerTarget) -> None: - self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu) - - def _add_volume_items(self, target: TrackerTarget) -> None: - self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu) - - def _add_adjust_items( - self, - target: TrackerTarget, - actions: Tuple[AdjustAction, ...], - callback: AdjustMenuCallback, - ) -> None: - """Builds one axis of adjustment items, each shifting the cells its target covers. - - An adjustment acts on whole cells, so it reaches the columns the target's block covers and - the rows it spans: a nudge with a selection standing moves all of it, and one on a cell - alone moves that cell. Each item prints the key it answers to, since the action states its - label, its binding and its step in one entry. - """ - for element, shortcut_id, delta in actions: - dpg.add_menu_item( - label=self._lbl_adjust[element], - shortcut=self._shortcuts.display(shortcut_id), - user_data=(target.region, delta), - callback=callback, - ) - - def _on_set_voice_menu( - self, - _sender: Sender, - _app_data: None, - user_data: Tuple[int, Optional[ChannelName], str], - ) -> None: - row_index, channel, voice_id = user_data - self.call(self.on_set_row, row_index, channel, voice_id, None, None) + @property + def channel_switch(self) -> ChannelSwitch: + """The switch a column header's click and menu act through.""" + return self._channel_switch - def _on_transpose_menu( - self, - _sender: Sender, - _app_data: None, - user_data: Tuple[TrackerRegion, int], - ) -> None: - region, delta = user_data - self.call(self.on_adjust_transpose, region, delta) + @property + def channels(self) -> Optional[SequencerChannelsViewModel]: + """Which channels stand silenced, as the last view the panel was given states it.""" + return self._current_channels - def _on_volume_menu( - self, - _sender: Sender, - _app_data: None, - user_data: Tuple[TrackerRegion, int], - ) -> None: - region, delta = user_data - self.call(self.on_adjust_volume, region, delta) + @property + def voices(self) -> Tuple[VoiceEntryViewModel, ...]: + """The pool a cell picks a voice from, empty while the panel has been given none.""" + return self._current_samples.voices if self._current_samples is not None else () - def _add_clear_items(self, cell: TrackerCursor) -> None: - """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row. + def column_label(self, channel: Optional[ChannelName]) -> str: + """The name a column carries, which its header and its menu title show.""" + return self._column_labels[channel] - The cell and row levels coincide on the sample column, which already clears every channel, - so the per-channel ``Clear cell`` item is offered only for an actual channel. - """ - dpg.add_menu_item( - label=self._lbl_context_clear_subcolumn, - callback=lambda: self.call( - self.on_clear_subcolumn, - cell.row, - cell.channel, - cell.subcolumn, - ), - ) - if cell.channel is not None: - dpg.add_menu_item( - label=self._lbl_context_clear_cell, - callback=lambda: self.call( - self.on_clear_row, - cell.row, - cell.channel, - ), - ) - dpg.add_menu_item( - label=self._lbl_context_clear_row, - callback=lambda: self.call(self.on_clear_row, cell.row, None), - ) + def add_action_items(self, target: TrackerTarget) -> None: + """Builds every action a tracker cell offers, which is what the menu bar's Edit group asks for.""" + self._menu.add_action_items(target) def _keys_active(self) -> bool: """Whether the grid owns the next key: its tab is in front, its cursor is set, and no @@ -1735,7 +1421,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True - if self._select_shape(shortcut_id, cursor): + if self.select_shape(shortcut_id, cursor): return True if self._block_action(shortcut_id): @@ -1798,7 +1484,7 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True - def _select_shape( + def select_shape( self, shortcut_id: ShortcutId, cell: TrackerCursor, diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py new file mode 100644 index 000000000..296d9146e --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py @@ -0,0 +1,124 @@ +from typing import Dict, Optional + +from sampletones_application.layout.tabs.sequencer import SequencerLayout +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey +from sampletones_application.ui.themes.inline import ( + create_header_selectable_theme, + create_label_selectable_theme, + create_selectable_text_theme, +) +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.faded import FadedColor +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceKind + +UNBUILT_THEME: int = 0 + + +class TrackerThemes: + """The shades a tracker cell, header and row number wear, built once and chosen per cell. + + Every shade a cell can take exists in two forms — the full color and the dimmed one a silenced + channel wears — so choosing between them is a lookup rather than a color computed at each + bind. Building them needs a DearPyGui context, which is why :meth:`create` stands apart from + construction and is called once the panel's widget tree is being raised. + """ + + def __init__(self, layout: SequencerLayout) -> None: + self._layout = layout + self._subcolumn: Dict[ThemeKey, int] = {} + self._muted_subcolumn: Dict[ThemeKey, int] = {} + self._header: int = UNBUILT_THEME + self._muted_header: int = UNBUILT_THEME + self._column_label: int = UNBUILT_THEME + self._row_number: int = UNBUILT_THEME + + def create(self) -> None: + """Builds every theme the grid binds, which a DearPyGui context has to stand behind.""" + self._create_subcolumn_themes() + self._create_header_themes() + self._row_number = create_selectable_text_theme(self._layout.colors.text.row) + + def _create_subcolumn_themes(self) -> None: + """Builds every text theme a cell can wear, in its full and its dimmed color. + + The voice slot carries one theme per kind of voice it can name, beside the shade it takes + while it names none, so the color of a cell reports what stands in it. Transpose and volume + speak for themselves and take one each. The dimmed variant keeps the same hue at reduced + alpha, so a silenced channel's values stay readable and editable while the others are + worked on. + """ + text = self._layout.colors.text + theme_colors: Dict[ThemeKey, BaseColor] = { + (SubColumn.VOICE, None): text.voice, + (SubColumn.VOICE, VoiceKind.SAMPLE): text.sample, + (SubColumn.VOICE, VoiceKind.INSTRUMENT): text.instrument, + (SubColumn.TRANSPOSE, None): text.transpose, + (SubColumn.VOLUME, None): text.volume, + } + fraction = self._layout.tracker.muted_text_fraction + for theme_key, color in theme_colors.items(): + self._subcolumn[theme_key] = create_selectable_text_theme(color) + self._muted_subcolumn[theme_key] = create_selectable_text_theme( + FadedColor( + color=color, + fraction=fraction, + ), + ) + + def _create_header_themes(self) -> None: + """Builds the two shades a channel's header label takes: audible and silenced. + + Both carry the header's own hover and press washes, so a label reads as the switch it is + while its text color reports whether the channel sounds. + """ + header = self._layout.colors.header + self._header = create_header_selectable_theme( + self._layout.colors.label, + header.hovered, + header.active, + ) + self._muted_header = create_header_selectable_theme( + self._layout.colors.muted.text, + header.hovered, + header.active, + ) + self._column_label = create_label_selectable_theme(self._layout.colors.label) + + def cell( + self, + subcolumn: SubColumn, + kind: Optional[VoiceKind], + *, + muted: bool, + ) -> int: + """The theme a cell wears: its slot's color, dimmed while its channel is silenced. + + A voice slot takes the color of the kind of voice standing in it, so a reader tells a + recording from a hand-written one across the whole grid; a slot naming none takes the + neutral shade the other slots' colors are read against. + + Args: + subcolumn: The slot the cell stands in. + kind: The kind of voice the slot names, ``None`` where it names none. + muted: Whether the cell's channel is silenced. + + Returns: + int: The theme to bind. + """ + themes = self._muted_subcolumn if muted else self._subcolumn + return themes[(subcolumn, kind)] + + def header(self, *, muted: bool) -> int: + """The theme a column header wears, which reports whether its channel sounds.""" + return self._muted_header if muted else self._header + + @property + def column_label(self) -> int: + """The theme a column's own label wears.""" + return self._column_label + + @property + def row_number(self) -> int: + """The theme the row-number column wears.""" + return self._row_number diff --git a/src/sampletones_application/view_model/sequencer/kind.py b/src/sampletones_application/view_model/sequencer/kind.py index b5070034b..98e243527 100644 --- a/src/sampletones_application/view_model/sequencer/kind.py +++ b/src/sampletones_application/view_model/sequencer/kind.py @@ -1,4 +1,7 @@ +from typing import Optional + from sampletones_application.view_model.sequencer.voices import VoiceKind +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.voice import VoiceUnion @@ -34,3 +37,23 @@ def places_across_channels(kind: VoiceKind) -> bool: bool: Whether the sample column takes it. """ return kind is VoiceKind.SAMPLE + + +def column_takes(channel: Optional[ChannelName], kind: VoiceKind) -> bool: + """Whether the column a cell stands in places a voice of this kind. + + A channel column sounds whatever it is given, so it takes either kind. The sample column + spreads a voice over the channels it covers, which a recording states and a hand-written voice + does not, so it takes a recording alone. + + Args: + channel: The channel the column carries, ``None`` for the sample column. + kind: The kind of the voice being placed. + + Returns: + bool: Whether the column takes it. + """ + if channel is not None: + return True + + return places_across_channels(kind) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 61f61d93d..4baf14383 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -45,9 +45,9 @@ from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer import channels as channels_module -from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS from sampletones_application.view_model.sequencer.region import ( OrderCell, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py index 574a1e837..5f447bfd9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py @@ -2,7 +2,7 @@ import pytest -from sampletones_application.ui.panels.sequencer import tracker +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker from sampletones_shared.types.callback import VoidCallback diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index fac7f2a64..e463d47a0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,7 +5,6 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, @@ -13,8 +12,9 @@ ) from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.view_model.sequencer.region import ( diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 3de6f4d66..dfe784bbc 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -5,9 +5,11 @@ import pytest +from sampletones_application.categories.elements.sequencer import SequencerOrderElements, SequencerTrackerElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.sequencer import CHANNEL_AXIS -from sampletones_application.ui.panels.sequencer import order as order_module -from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module from sampletones_application.ui.panels.sequencer.input.order import ( @@ -16,6 +18,11 @@ ) from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState +from sampletones_application.ui.panels.sequencer.order import panel as order_module +from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu +from sampletones_application.ui.panels.sequencer.tracker import adjust +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, @@ -128,10 +135,22 @@ def _labels(panel: Any, names: Tuple[str, ...]) -> None: def _adjust_labels(panel: Any) -> None: """Gives the panel the words its transpose and volume items print, each reading as its element.""" panel._lbl_adjust = { - element: element.value for element, _, _ in (*tracker_module.TRANSPOSE_ACTIONS, *tracker_module.VOLUME_ACTIONS) + element: element.value for element, _, _ in (*adjust.TRANSPOSE_ACTIONS, *adjust.VOLUME_ACTIONS) } +def _order_words(*elements: SequencerOrderElements) -> List[str]: + """The words the order menu prints for these actions, read from the language file it reads.""" + language_manager = LanguageManager(LANG_EN) + return [language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element] for element in elements] + + +def _tracker_words(*elements: SequencerTrackerElements) -> List[str]: + """The words the tracker menu prints for these actions, read from the language file it reads.""" + language_manager = LanguageManager(LANG_EN) + return [language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, element] for element in elements] + + def _tracker_panel( gestures: Gestures, *, @@ -151,6 +170,11 @@ def _tracker_panel( panel.can_paste_block = lambda: can_paste panel._blocks = BlockGestures(grid=panel) attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget) + panel._menu = TrackerMenu( + panel, + language_manager=LanguageManager(LANG_EN), + shortcut_source=panel._shortcuts, + ) return panel @@ -172,6 +196,11 @@ def _order_panel( panel.can_paste_block = lambda: can_paste panel._blocks = BlockGestures(grid=panel) attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget) + panel._menu = OrderMenu( + panel, + language_manager=LanguageManager(LANG_EN), + shortcut_source=panel._shortcuts, + ) return panel @@ -499,9 +528,13 @@ def test_the_tracker_action_set_leads_with_the_shapes_a_selection_takes( panel.add_action_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1))) labels = [item.label for item in tracker_recorder.items] - assert labels[:3] == ["select_all", "select_column", "select_subcolumn"] + assert labels[:3] == _tracker_words( + SequencerTrackerElements.CONTEXT_SELECT_ALL, + SequencerTrackerElements.CONTEXT_SELECT_COLUMN, + SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN, + ) assert labels[3:7] == ["Copy", "Cut", "Paste", "Delete"] - assert panel._lbl_context_clear_row in labels + assert _tracker_words(SequencerTrackerElements.CONTEXT_CLEAR_ROW)[0] in labels def test_the_order_action_set_leads_with_the_shapes_a_selection_takes( self, @@ -512,9 +545,12 @@ def test_the_order_action_set_leads_with_the_shapes_a_selection_takes( panel.add_action_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1))) labels = [item.label for item in order_recorder.items] - assert labels[:2] == ["select_all", "select_row"] + assert labels[:2] == _order_words( + SequencerOrderElements.CONTEXT_SELECT_ALL, + SequencerOrderElements.CONTEXT_SELECT_ROW, + ) assert labels[2:6] == ["Copy", "Cut", "Paste", "Delete"] - assert panel._lbl_context_move_end in labels + assert _order_words(SequencerOrderElements.CONTEXT_MOVE_END)[0] in labels class TestMenuItemOrder: @@ -538,7 +574,7 @@ class TestSelectItems: def test_the_tracker_items_print_the_keys_they_answer(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_select_items(_tracker_cell(ChannelName.PULSE1)) + panel._menu._add_select_items(_tracker_cell(ChannelName.PULSE1)) assert [item.shortcut for item in tracker_recorder.items] == [ "Ctrl+A", @@ -555,7 +591,7 @@ def test_a_tracker_item_selects_the_column_the_menu_was_raised_on( panel = _tracker_panel(Gestures()) states = _tracker_selections(monkeypatch, panel) - panel._add_select_items(_tracker_cell(ChannelName.TRIANGLE)) + panel._menu._add_select_items(_tracker_cell(ChannelName.TRIANGLE)) tracker_recorder.items[SELECT_COLUMN_ITEM].callback() region = states[-1].region @@ -571,7 +607,7 @@ def test_a_tracker_item_selects_the_whole_frame( panel = _tracker_panel(Gestures()) states = _tracker_selections(monkeypatch, panel) - panel._add_select_items(_tracker_cell(ChannelName.TRIANGLE)) + panel._menu._add_select_items(_tracker_cell(ChannelName.TRIANGLE)) tracker_recorder.items[SELECT_ALL_ITEM].callback() region = states[-1].region @@ -581,7 +617,7 @@ def test_a_tracker_item_selects_the_whole_frame( def test_the_order_items_print_the_keys_they_answer(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures()) - panel._add_select_items(_order_cell(ChannelName.PULSE1)) + panel._menu._add_select_items(_order_cell(ChannelName.PULSE1)) assert [item.shortcut for item in order_recorder.items] == ["Ctrl+A", "Ctrl+Shift+A"] @@ -593,7 +629,7 @@ def test_an_order_item_selects_the_row_the_menu_was_raised_on( panel = _order_panel(Gestures()) states = _order_selections(monkeypatch, panel) - panel._add_select_items(_order_cell(None)) + panel._menu._add_select_items(_order_cell(None)) order_recorder.items[SELECT_ROW_ITEM].callback() region = states[-1].region diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py index f7233bc04..9938a33bf 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py @@ -9,8 +9,10 @@ from sampletones_application.paths import LANG_EN from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer import channels as channels_module -from sampletones_application.ui.panels.sequencer import order as order_module -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.order import menu as menu_module +from sampletones_application.ui.panels.sequencer.order import panel as order_module +from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel from sampletones_application.utils.gui.keyboard.modifiers import ( CTRL, NO_MODIFIERS, @@ -23,6 +25,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import ColorRGBA, Sender from sampletones_shared.types.callback import VoidCallback +from tests.suite.shortcuts import shipped_source LABEL_WIDGET_ID: Sender = 9100 """A stand-in for the row-label id DearPyGui passes as the callback's sender.""" @@ -163,7 +166,13 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerOrderPanel: for position in range(POSITION_COUNT): panel._order.register((channel, position), _entry_widget(channel, position)) - panel._create_channel_switch(LanguageManager(LANG_EN)) + language_manager = LanguageManager(LANG_EN) + panel._create_channel_switch(language_manager) + panel._menu = OrderMenu( + panel, + language_manager=language_manager, + shortcut_source=shipped_source(), + ) return panel @@ -180,16 +189,16 @@ def recorder(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: @pytest.fixture def menu(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: instance = _MenuRecorder() - monkeypatch.setattr(order_module.dpg, "add_menu_item", instance.add_menu_item) - monkeypatch.setattr(order_module.dpg, "add_text", instance.add_text) - monkeypatch.setattr(order_module.dpg, "add_separator", instance.add_separator) - monkeypatch.setattr(order_module.FontRegistry, "bind_to_item", lambda item, font: None) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "add_text", instance.add_text) + monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator) + monkeypatch.setattr(menu_module.FontRegistry, "bind_to_item", lambda item, font: None) @contextlib.contextmanager def _popup() -> Iterator[None]: yield - monkeypatch.setattr(order_module, "context_menu", _popup) + monkeypatch.setattr(menu_module, "context_menu", _popup) return instance diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 47e3244f7..1009cc7c6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -7,7 +7,7 @@ OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_core.constants.enums import ChannelName diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py index 570da6aa0..8ed675ccb 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py @@ -5,7 +5,7 @@ OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel from sampletones_core.constants.enums import ChannelName POSITION_COUNT = 4 diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py index f41019f89..5ce3bc19b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py @@ -8,8 +8,8 @@ OrderInputState, ) from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS from sampletones_application.view_model.sequencer.subcolumn import SubColumn diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py index 29999c4d9..f3119af22 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -8,8 +8,8 @@ OrderInputState, ) from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter, focus from sampletones_application.view_model.sequencer.subcolumn import SubColumn diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index cf3a9713a..f086be604 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -28,8 +28,8 @@ OrderInputState, ) from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel, OrderKey -from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel, OrderKey +from sampletones_application.ui.panels.sequencer.tracker.panel import CellKey, GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import Modifier from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource @@ -97,7 +97,7 @@ def _tracker( states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) monkeypatch.setattr(panel, "_cell_at", lambda: reached) - _hold_modifiers(monkeypatch, "tracker", shift) + _hold_modifiers(monkeypatch, "tracker.panel", shift) return panel, states @@ -121,7 +121,7 @@ def _order( states: List[OrderInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) monkeypatch.setattr(panel, "_cell_at", lambda: reached) - _hold_modifiers(monkeypatch, "order", shift) + _hold_modifiers(monkeypatch, "order.panel", shift) return panel, states diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index 764f95b66..343b0f5d0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -8,8 +8,8 @@ OrderInputState, ) from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py index 648ef13ba..b90586d25 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py @@ -4,9 +4,11 @@ import pytest from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.display import CellKey, CellKinds -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel, ThemeKey +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_application.view_model.sequencer.channels import ( @@ -60,6 +62,22 @@ def _keys() -> List[CellKey]: ] +def _themes( + subcolumn: Dict[ThemeKey, int], + muted_subcolumn: Dict[ThemeKey, int], + *, + header: int = 0, + muted_header: int = 0, +) -> TrackerThemes: + """A themes object holding the ids under test, standing in for ones DearPyGui built.""" + themes = TrackerThemes.__new__(TrackerThemes) + themes._subcolumn = dict(subcolumn) + themes._muted_subcolumn = dict(muted_subcolumn) + themes._header = header + themes._muted_header = muted_header + return themes + + def _panel( *, cell_kinds: Optional[CellKinds] = None, @@ -72,8 +90,7 @@ def _panel( tracker=SimpleNamespace(muted_text_fraction=MUTED_TEXT_FRACTION), ) panel._cell_kinds = dict(cell_kinds or {}) - panel._subcolumn_themes = dict(THEME_IDS) - panel._muted_subcolumn_themes = dict(MUTED_THEME_IDS) + panel._themes = _themes(THEME_IDS, MUTED_THEME_IDS) panel._current_channels = SequencerChannelsViewModel(muted=muted) panel._current_row_count = ROW_COUNT panel._editable_cells = EditableCells() @@ -280,44 +297,3 @@ def test_a_cleared_slot_drops_its_number_and_its_color(self, bound: Dict[Sender, assert key not in panel._editable_cells.values assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, None)] - - -class TestWhichThemesAreBuilt: - @staticmethod - def _built(monkeypatch: pytest.MonkeyPatch) -> Tuple[GUISequencerTrackerPanel, List[BaseColor]]: - colors: List[BaseColor] = [] - - def _record(color: BaseColor, *_arguments: Any) -> int: - colors.append(color) - return len(colors) - - monkeypatch.setattr(tracker_module, "create_selectable_text_theme", _record) - panel = _panel() - panel._subcolumn_themes = {} - panel._muted_subcolumn_themes = {} - panel._create_subcolumn_themes() - return panel, colors - - def test_the_voice_slot_is_built_in_a_color_for_each_kind(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, _ = self._built(monkeypatch) - - voice_themes = {theme_key for theme_key in panel._subcolumn_themes if theme_key[0] is SubColumn.VOICE} - - assert voice_themes == { - (SubColumn.VOICE, None), - (SubColumn.VOICE, VoiceKind.SAMPLE), - (SubColumn.VOICE, VoiceKind.INSTRUMENT), - } - - def test_each_kind_is_built_in_its_own_color(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, colors = self._built(monkeypatch) - - sample = colors[panel._subcolumn_themes[(SubColumn.VOICE, VoiceKind.SAMPLE)] - 1] - instrument = colors[panel._subcolumn_themes[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] - 1] - - assert (sample.rgba, instrument.rgba) == (TEXT_COLORS.sample.rgba, TEXT_COLORS.instrument.rgba) - - def test_every_theme_has_a_dimmed_twin(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, _ = self._built(monkeypatch) - - assert set(panel._muted_subcolumn_themes) == set(panel._subcolumn_themes) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index 0c9494053..11982337f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -8,9 +8,10 @@ from sampletones_application.paths import LANG_EN from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer import channels as channels_module -from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.columns import tracker_table_column -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel, ThemeKey +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import ( CTRL, NO_MODIFIERS, @@ -23,6 +24,7 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import ColorRGBA, Sender +from tests.unit.sampletones_application.ui.panels.sequencer.test_tracker_cell_themes import _themes HEADER_WIDGET_ID = 7100 """A stand-in for the header selectable id DearPyGui passes as the callback's sender.""" @@ -107,10 +109,12 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: panel._current_channels = SequencerChannelsViewModel(muted=muted) panel._current_row_count = ROW_COUNT panel._cell_kinds = {} - panel._header_theme = HEADER_THEME - panel._muted_header_theme = MUTED_HEADER_THEME - panel._subcolumn_themes = dict(SUBCOLUMN_THEMES) - panel._muted_subcolumn_themes = dict(MUTED_SUBCOLUMN_THEMES) + panel._themes = _themes( + SUBCOLUMN_THEMES, + MUTED_SUBCOLUMN_THEMES, + header=HEADER_THEME, + muted_header=MUTED_HEADER_THEME, + ) panel._header_columns = {_header_widget(channel): channel for channel in HEADER_COLUMNS} panel._create_channel_switch(LanguageManager(LANG_EN)) panel._editable_cells = EditableCells() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index 8eb235ab0..af1dfee49 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -4,9 +4,9 @@ import pytest -from sampletones_application.ui.panels.sequencer import tracker from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN, KEY_PAGE_UP diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py index 386499ef6..1001b6a3b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py @@ -8,7 +8,7 @@ TrackerCursor, TrackerInputState, ) -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.view_model.sequencer.subcolumn import SubColumn diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py index 2679b2534..de2ac3390 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT from sampletones_application.view_model.sequencer.subcolumn import SubColumn diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index 26bc02145..c9bf4709d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -3,14 +3,14 @@ import pytest -from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.columns import ( HEADER_TABLE_ROW, tracker_table_column, tracker_table_row, ) from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py index d5ae9ab6e..cfc27efe1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -3,8 +3,8 @@ import pytest from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.input.edit import EditAction +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.voices import ( SequencerVoicesViewModel, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py similarity index 93% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py rename to tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py index 1aa7c74f1..f87fccdf9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py @@ -6,8 +6,10 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.paths import LANG_EN -from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker import menu as menu_module +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module +from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import Modifier from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, @@ -15,6 +17,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback +from tests.suite.shortcuts import shipped_source SENDER_WIDGET_ID: Sender = 8100 """A stand-in for the handler-registry id DearPyGui passes as the callback's sender.""" @@ -92,27 +95,33 @@ def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel: the menu is built the way the panel builds it, from the real language file, so the item labels under test are the ones a user reads. """ + language_manager = LanguageManager(LANG_EN) panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._column_labels = dict(COLUMN_LABELS) panel._header_columns = {widget: column for column, widget in HEADER_WIDGETS.items()} panel._current_channels = SequencerChannelsViewModel(muted=muted) - panel._create_channel_switch(LanguageManager(LANG_EN)) + panel._create_channel_switch(language_manager) + panel._menu = TrackerMenu( + panel, + language_manager=language_manager, + shortcut_source=shipped_source(), + ) return panel @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: instance = _MenuRecorder() - monkeypatch.setattr(tracker_module.dpg, "add_menu_item", instance.add_menu_item) - monkeypatch.setattr(tracker_module.dpg, "add_text", instance.add_text) - monkeypatch.setattr(tracker_module.dpg, "add_separator", instance.add_separator) - monkeypatch.setattr(tracker_module.FontRegistry, "bind_to_item", lambda item, font: None) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "add_text", instance.add_text) + monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator) + monkeypatch.setattr(menu_module.FontRegistry, "bind_to_item", lambda item, font: None) @contextlib.contextmanager def _popup() -> Iterator[None]: yield - monkeypatch.setattr(tracker_module, "context_menu", _popup) + monkeypatch.setattr(menu_module, "context_menu", _popup) return instance diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_menu.py similarity index 60% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py rename to tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_menu.py index 42d4a5dc8..fada8fe73 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_menu.py @@ -3,19 +3,19 @@ import pytest -from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import ( TrackerCursor, TrackerInputState, ) +from sampletones_application.ui.panels.sequencer.tracker import adjust +from sampletones_application.ui.panels.sequencer.tracker import menu as menu_module +from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_application.view_model.sequencer.voices import ( - SequencerVoicesViewModel, - VoiceEntryViewModel, - VoiceKind, -) +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind from sampletones_core.constants.enums import ChannelName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from tests.suite.shortcuts import shipped_source @@ -25,33 +25,57 @@ positional argument. The original bug let this id overwrite the step payload.""" -_CONTEXT_LABELS = ( - "_lbl_context_set_voice", - "_lbl_context_no_voices", -) - - -def _panel() -> tracker_module.GUISequencerTrackerPanel: - """Builds a panel without its DearPyGui-dependent constructor. +class _MenuHost: + """The panel surface a menu reads, wired to just what these cases touch.""" - The menu-dispatch methods touch only their hook attributes, the context - labels, the keys each item prints, and ``CallbackMixin.call``, so a fully - wired GUI context is unnecessary here. Labels carry no behavior, so any - placeholder text serves. - """ - panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) - for label in _CONTEXT_LABELS: - setattr(panel, label, "") - - panel._lbl_adjust = { - element: "" - for element, _, _ in ( - *tracker_module.TRANSPOSE_ACTIONS, - *tracker_module.VOLUME_ACTIONS, - ) - } - panel._shortcuts = shipped_source() - return panel + def __init__(self) -> None: + self.on_clear_row = None + self.on_clear_subcolumn = None + self.on_set_row = None + self.on_set_note_off = None + self.on_play_from_row = None + self.on_play_from_frame = None + self.on_adjust_transpose = None + self.on_adjust_volume = None + self._voices: Tuple[VoiceEntryViewModel, ...] = () + + @property + def edit_surface(self) -> Any: + raise NotImplementedError + + @property + def channel_switch(self) -> Any: + raise NotImplementedError + + @property + def channels(self) -> Any: + return None + + @property + def voices(self) -> Tuple[VoiceEntryViewModel, ...]: + return self._voices + + def hold_voices(self, *voices: VoiceEntryViewModel) -> None: + self._voices = voices + + def column_label(self, channel: Optional[ChannelName]) -> str: + return "" + + def select_shape(self, shortcut_id: Any, cell: TrackerCursor) -> bool: + return True + + +def _menu() -> Tuple[TrackerMenu, _MenuHost]: + """A menu over a bare host, which is all the item builders under test reach.""" + host = _MenuHost() + return ( + TrackerMenu( + host, + language_manager=LanguageManager(LANG_EN), + shortcut_source=shipped_source(), + ), + host, + ) class _MenuItemRecorder: @@ -82,13 +106,13 @@ def dispatch_as_dpg(self) -> None: @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder: instance = _MenuItemRecorder() - monkeypatch.setattr(tracker_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) @contextlib.contextmanager def _menu(**kwargs: Any) -> Iterator[None]: yield - monkeypatch.setattr(tracker_module.dpg, "menu", _menu) + monkeypatch.setattr(menu_module.dpg, "menu", _menu) return instance @@ -105,11 +129,11 @@ def _target(row: int, channel: ChannelName) -> TrackerTarget: class TestMenuDispatchPreservesPayload: def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: - panel = _panel() + menu, panel = _menu() deltas: List[int] = [] panel.on_adjust_transpose = lambda region, delta: deltas.append(delta) - panel._add_transpose_items(_target(2, ChannelName.PULSE1)) + menu._add_transpose_items(_target(2, ChannelName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -120,46 +144,44 @@ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecor ] def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: - panel = _panel() + menu, panel = _menu() deltas: List[int] = [] panel.on_adjust_volume = lambda region, delta: deltas.append(delta) - panel._add_volume_items(_target(2, ChannelName.PULSE1)) + menu._add_volume_items(_target(2, ChannelName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ - tracker_module.VOLUME_FINE_STEP, - -tracker_module.VOLUME_FINE_STEP, - tracker_module.VOLUME_COARSE_STEP, - -tracker_module.VOLUME_COARSE_STEP, + adjust.VOLUME_FINE_STEP, + -adjust.VOLUME_FINE_STEP, + adjust.VOLUME_COARSE_STEP, + -adjust.VOLUME_COARSE_STEP, ] def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuItemRecorder) -> None: - panel = _panel() + menu, panel = _menu() calls: List[Tuple[TrackerRegion, int]] = [] panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta)) target = _target(7, ChannelName.TRIANGLE) - panel._add_transpose_items(target) + menu._add_transpose_items(target) recorder.dispatch_as_dpg() assert calls[0] == (target.region, SEMITONE_STEP) def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) -> None: - panel = _panel() - panel._current_samples = SequencerVoicesViewModel( - voices=( - VoiceEntryViewModel( - voice_id="lead-id", - name="lead", - kind=VoiceKind.SAMPLE, - ), + menu, panel = _menu() + panel.hold_voices( + VoiceEntryViewModel( + voice_id="lead-id", + name="lead", + kind=VoiceKind.SAMPLE, ), ) chosen: List[str] = [] panel.on_set_row = lambda row, channel, voice_id, transpose, volume: chosen.append(voice_id) - panel._add_voice_submenu(_cell(0, ChannelName.PULSE2)) + menu._add_voice_submenu(_cell(0, ChannelName.PULSE2)) recorder.dispatch_as_dpg() assert chosen == ["lead-id"] @@ -172,36 +194,34 @@ class TestWhichVoicesAColumnOffers: INSTRUMENT_LABEL = "01 pad" @staticmethod - def _panel_with_both_kinds() -> tracker_module.GUISequencerTrackerPanel: - panel = _panel() - panel._current_samples = SequencerVoicesViewModel( - voices=( - VoiceEntryViewModel( - voice_id="lead-id", - name="lead", - kind=VoiceKind.SAMPLE, - ), - VoiceEntryViewModel( - voice_id="pad-id", - name="pad", - kind=VoiceKind.INSTRUMENT, - ), + def _menu_with_both_kinds() -> Tuple[TrackerMenu, "_MenuHost"]: + menu, host = _menu() + host.hold_voices( + VoiceEntryViewModel( + voice_id="lead-id", + name="lead", + kind=VoiceKind.SAMPLE, + ), + VoiceEntryViewModel( + voice_id="pad-id", + name="pad", + kind=VoiceKind.INSTRUMENT, ), ) - return panel + return menu, host def test_a_channel_column_reaches_both_kinds(self, recorder: _MenuItemRecorder) -> None: - panel = self._panel_with_both_kinds() + menu, panel = self._menu_with_both_kinds() - panel._add_voice_submenu(_cell(0, ChannelName.PULSE2)) + menu._add_voice_submenu(_cell(0, ChannelName.PULSE2)) assert recorder.reachable(self.SAMPLE_LABEL) is True assert recorder.reachable(self.INSTRUMENT_LABEL) is True def test_the_sample_column_reaches_a_sample_alone(self, recorder: _MenuItemRecorder) -> None: - panel = self._panel_with_both_kinds() + menu, panel = self._menu_with_both_kinds() - panel._add_voice_submenu(_cell(0, None)) + menu._add_voice_submenu(_cell(0, None)) assert recorder.reachable(self.SAMPLE_LABEL) is True assert recorder.reachable(self.INSTRUMENT_LABEL) is False @@ -211,9 +231,9 @@ def test_the_sample_column_still_names_the_instrument_it_stands_by_for( recorder: _MenuItemRecorder, ) -> None: """An unreachable item says the voice exists while leaving it where it belongs.""" - panel = self._panel_with_both_kinds() + menu, panel = self._menu_with_both_kinds() - panel._add_voice_submenu(_cell(0, None)) + menu._add_voice_submenu(_cell(0, None)) assert [entry["label"] for entry in recorder.entries] == [ self.SAMPLE_LABEL, @@ -221,9 +241,8 @@ def test_the_sample_column_still_names_the_instrument_it_stands_by_for( ] def test_an_empty_pool_offers_one_unreachable_item(self, recorder: _MenuItemRecorder) -> None: - panel = _panel() - panel._current_samples = SequencerVoicesViewModel(voices=()) + menu, _ = _menu() - panel._add_voice_submenu(_cell(0, None)) + menu._add_voice_submenu(_cell(0, None)) assert [entry["enabled"] for entry in recorder.entries] == [False] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py new file mode 100644 index 000000000..56b6cd045 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py @@ -0,0 +1,94 @@ +from types import SimpleNamespace +from typing import Any, Dict, List, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.tracker import themes as themes_module +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey +from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.voices import VoiceKind + +MUTED_TEXT_FRACTION = 0.25 + +TEXT_COLORS = SimpleNamespace( + voice=LiteralColor((118, 122, 142, 255)), + sample=LiteralColor((224, 200, 96, 255)), + instrument=LiteralColor((255, 112, 223, 255)), + transpose=LiteralColor((192, 192, 192, 255)), + volume=LiteralColor((100, 220, 100, 255)), + row=LiteralColor((90, 90, 90, 255)), +) + +LAYOUT = SimpleNamespace( + colors=SimpleNamespace(text=TEXT_COLORS), + tracker=SimpleNamespace(muted_text_fraction=MUTED_TEXT_FRACTION), +) + + +class TestWhichThemesAreBuilt: + """Every shade a cell can wear is built once, each kind in its own color and each with a twin.""" + + @staticmethod + def _built(monkeypatch: pytest.MonkeyPatch) -> Tuple[Dict[ThemeKey, int], Dict[ThemeKey, int], List[BaseColor]]: + colors: List[BaseColor] = [] + + def _record(color: BaseColor, *_arguments: Any) -> int: + colors.append(color) + return len(colors) + + monkeypatch.setattr(themes_module, "create_selectable_text_theme", _record) + themes = TrackerThemes(LAYOUT) + themes._create_subcolumn_themes() + return themes._subcolumn, themes._muted_subcolumn, colors + + def test_the_voice_slot_is_built_in_a_color_for_each_kind(self, monkeypatch: pytest.MonkeyPatch) -> None: + subcolumn, _, _ = self._built(monkeypatch) + + voice_themes = {theme_key for theme_key in subcolumn if theme_key[0] is SubColumn.VOICE} + + assert voice_themes == { + (SubColumn.VOICE, None), + (SubColumn.VOICE, VoiceKind.SAMPLE), + (SubColumn.VOICE, VoiceKind.INSTRUMENT), + } + + def test_each_kind_is_built_in_its_own_color(self, monkeypatch: pytest.MonkeyPatch) -> None: + subcolumn, _, colors = self._built(monkeypatch) + + sample = colors[subcolumn[(SubColumn.VOICE, VoiceKind.SAMPLE)] - 1] + instrument = colors[subcolumn[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] - 1] + + assert (sample.rgba, instrument.rgba) == (TEXT_COLORS.sample.rgba, TEXT_COLORS.instrument.rgba) + + def test_every_theme_has_a_dimmed_twin(self, monkeypatch: pytest.MonkeyPatch) -> None: + subcolumn, muted_subcolumn, _ = self._built(monkeypatch) + + assert set(muted_subcolumn) == set(subcolumn) + + +class TestWhichThemeACellWears: + """A cell reads its shade from the slot it stands in and whether its channel is silenced.""" + + @staticmethod + def _themes() -> TrackerThemes: + themes = TrackerThemes.__new__(TrackerThemes) + themes._subcolumn = {(SubColumn.VOICE, VoiceKind.SAMPLE): 11, (SubColumn.TRANSPOSE, None): 13} + themes._muted_subcolumn = {(SubColumn.VOICE, VoiceKind.SAMPLE): 111, (SubColumn.TRANSPOSE, None): 113} + themes._header = 20 + themes._muted_header = 120 + return themes + + def test_an_audible_cell_takes_the_full_shade(self) -> None: + assert self._themes().cell(SubColumn.VOICE, VoiceKind.SAMPLE, muted=False) == 11 + + def test_a_silenced_cell_takes_the_dimmed_twin(self) -> None: + assert self._themes().cell(SubColumn.VOICE, VoiceKind.SAMPLE, muted=True) == 111 + + def test_an_audible_header_takes_the_full_shade(self) -> None: + assert self._themes().header(muted=False) == 20 + + def test_a_silenced_header_takes_the_dimmed_twin(self) -> None: + assert self._themes().header(muted=True) == 120 From c47ee5dcbb046a11f53566e94a94cc7a7e31aa77 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 01:18:31 +0200 Subject: [PATCH 133/142] Divided: the sequencer tab coordinator into a subpackage --- src/sampletones_application/application.py | 2 +- .../coordinators/tabs/sequencer.py | 1706 ----------------- .../coordinators/tabs/sequencer/__init__.py | 0 .../coordinators/tabs/sequencer/blocks.py | 144 ++ .../tabs/sequencer/coordinator.py | 909 +++++++++ .../coordinators/tabs/sequencer/frames.py | 144 ++ .../coordinators/tabs/sequencer/history.py | 174 ++ .../coordinators/tabs/sequencer/layout.py | 190 ++ .../coordinators/tabs/sequencer/playhead.py | 49 + .../coordinators/tabs/sequencer/project.py | 37 + .../tabs/sequencer/reconstructions.py | 255 +++ .../coordinators/tabs/sequencer/voices.py | 239 +++ src/sampletones_application/shell.py | 2 +- .../coordinators/tabs/test_sequencer.py | 861 +++++---- .../ui/panels/sequencer/test_block_keys.py | 5 +- 15 files changed, 2609 insertions(+), 2108 deletions(-) delete mode 100644 src/sampletones_application/coordinators/tabs/sequencer.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/__init__.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/blocks.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/coordinator.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/frames.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/history.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/layout.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/playhead.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/project.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py create mode 100644 src/sampletones_application/coordinators/tabs/sequencer/voices.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 49bc23ebe..1dbd25f28 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -36,7 +36,7 @@ from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) -from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator +from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator from sampletones_application.exports import build_export_backends from sampletones_application.layout import LayoutConfig, load_layout_config from sampletones_application.logic.export import SongExportLogic diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py deleted file mode 100644 index ea0f094d9..000000000 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ /dev/null @@ -1,1706 +0,0 @@ -from pathlib import Path -from typing import Callable, Optional, ParamSpec, Sequence, Tuple - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType -from sampletones_application.categories.instrument import InstrumentImportMessages -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.config.managers.session import SessionManager -from sampletones_application.constants.playback import FollowMode -from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol -from sampletones_application.coordinators.export import InstrumentExportCoordinator -from sampletones_application.coordinators.original_audio import OriginalAudioLocator -from sampletones_application.coordinators.playback.guard import GuardedPlayer -from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol -from sampletones_application.logic.history.action import HistoryAction -from sampletones_application.logic.history.manager import HistoryManager -from sampletones_application.logic.history.transaction import CoalesceKey -from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.reconstruction.browser.manager import BrowserManager -from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic -from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic -from sampletones_application.logic.sequencer.clipboard import ( - OrderBlockText, - ParsedBlockCache, - ProjectSampleDirectory, - SequencerClipboard, - TrackerBlockText, -) -from sampletones_application.logic.sequencer.history_detail import ( - SequencerHistoryDetail, -) -from sampletones_application.logic.sequencer.order import ( - OrderBlock, - OrderBlockReader, - OrderBlockWriter, - SequencerOrderLogic, -) -from sampletones_application.logic.sequencer.playback.playhead import ( - remap_after_insert, - remap_after_move, - remap_after_remove, -) -from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic -from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer -from sampletones_application.logic.sequencer.tracker import ( - SequencerTrackerLogic, - TrackerBlock, - TrackerBlockReader, - TrackerBlockWriter, - TrackerRegionAdjuster, -) -from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic -from sampletones_application.logic.shared.tree import TreeLogic -from sampletones_application.parameters.sequencer import SequencerTabParameters -from sampletones_application.services.song_player.service import SongPlayerService -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_PANEL_CENTER, - SUF_PANEL_LEFT, - SUF_PANEL_RIGHT, - TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, - TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, - TAG_GLOBAL_TAB_SEQUENCER, - TAG_GLOBAL_TABS, - TAG_GLOBAL_THEME_DEFAULT, - TAG_GLOBAL_THEME_PANEL_GROUND, - TAG_GLOBAL_THEME_PANEL_SURFACE, -) -from sampletones_application.tags.sequencer import ( - TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY, - TAG_SEQUENCER_BROWSER_PANEL, - TAG_SEQUENCER_HISTORY_PANEL, - TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, - TAG_SEQUENCER_MODULE_PANEL, - TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, - TAG_SEQUENCER_TRACKER_PANEL, - TAG_SEQUENCER_VOICES_DIALOG_REMOVE, - TAG_SEQUENCER_VOICES_PANEL, -) -from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns -from sampletones_application.ui.elements.layout.responsive import expanded_side_width -from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel -from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel -from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel -from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel -from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel -from sampletones_application.ui.panels.sequencer.voices.panel import ( - GUISequencerVoicesPanel, -) -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.file_dialogs.api import open_file_dialog -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.result import ignore_none_path -from sampletones_application.utils.gui.clipboard import ( - SystemTextClipboard, - TextClipboard, -) -from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.gui.frame import FrameCallbackManager -from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter -from sampletones_application.utils.gui.shortcuts.source import ShortcutSource -from sampletones_application.view_model.sequencer.channels import ( - SequencerChannelsViewModel, -) -from sampletones_application.view_model.sequencer.history import ( - HistoryEntryViewModel, - HistoryViewModel, -) -from sampletones_application.view_model.sequencer.region import ( - OrderCell, - OrderRegion, - TrackerCell, - TrackerRegion, -) -from sampletones_application.view_model.sequencer.settings import ( - SequencerSettingsViewModel, -) -from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel -from sampletones_application.view_model.sequencer.voices import ( - SequencerVoicesViewModel, -) -from sampletones_application.view_model.shared.history import ( - HistoryDetail, -) -from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import ChannelName, FeatureKey -from sampletones_core.formats.famitracker.voice import ImportedVoice -from sampletones_core.project.song_position import SongPosition -from sampletones_core.reconstructions import Reconstruction -from sampletones_core.structures.tree import FileSystemNode -from sampletones_core.utils.display import display_id -from sampletones_shared.exceptions import LoadInstrumentError, SampleToNESError -from sampletones_shared.logger import logger -from sampletones_shared.paths.extensions import ( - EXT_FILE_INSTRUMENT, - EXT_FILE_RECONSTRUCTION, -) -from sampletones_shared.types.callback import StringCallback, VoidCallback - -_UndoableParams = ParamSpec("_UndoableParams") - -_LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_LEFT) -_CENTER_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_CENTER) -_RIGHT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_RIGHT) - - -class SequencerTabCoordinator: - def __init__( - self, - config_manager: ConfigManager, - session_manager: SessionManager, - audio_device_manager: AudioDeviceManager, - key_router: KeyRouter, - shortcut_source: ShortcutSource, - browser_manager: BrowserManager, - project_controller: ProjectController, - history: HistoryManager, - original_audio_locator: OriginalAudioLocator, - instrument_exports: InstrumentExportCoordinator, - *, - tab_active: ActivePredicate, - layout: SequencerTabParameters, - language_manager: LanguageManager, - dialogs: DialogsRenderer, - status_bar: GUIStatusBar, - on_edit_voice_requested: StringCallback, - on_favorite_changed: Callable[[FileSystemNode], None], - on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None], - on_tab_switch: Callable[[Tab], None], - on_nes_frequency_changed: Callable[[int], None], - on_channels_changed: VoidCallback, - ) -> None: - self._project_controller = project_controller - self._session_manager = session_manager - self._history = history - self._original_audio_locator = original_audio_locator - self._instrument_exports = instrument_exports - self._on_edit_voice_requested = on_edit_voice_requested - self._on_favorite_changed = on_favorite_changed - self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced - self._on_tab_switch = on_tab_switch - self._on_nes_frequency_changed = on_nes_frequency_changed - self._on_channels_changed = on_channels_changed - self._language_manager = language_manager - self._dialogs = dialogs - - self._import_messages = InstrumentImportMessages.build(language_manager) - self._msg_no_project = language_manager["global.dialog.message.no_project_open"] - self._ttl_no_project = language_manager["global.dialog.title.no_project_open"] - self._nes_frequency_change_acknowledged: bool = False - self._playing_position: Optional[SongPosition] = None - self._geometry = layout.geometry - self._side_panel_count: int - self._instruments_width = layout.right_column_width - self._right_height = layout.right_column_height - self._history_expanded_height = layout.history_height - self._history_collapsed_footprint = layout.header_bar_height + 2 * self._geometry.panel_gap - self._inter_card_gap = self._stacked_card_gap() - - self._sequencer_browser_logic: SequencerBrowserLogic = SequencerBrowserLogic( - config_manager, - browser_manager, - project_controller, - ) - self._sequencer_tree_logic: TreeLogic = TreeLogic( - session_manager, - audio_device_manager, - scheduling=layout.scheduling, - ) - self._sequencer_browser_panel: GUISequencerBrowserPanel = GUISequencerBrowserPanel( - self._sequencer_browser_logic.tree, - self._sequencer_tree_logic, - scheduling=layout.scheduling, - language_manager=language_manager, - status_bar=status_bar, - colors=layout.tree_colors, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), - initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), - initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL), - ) - self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) - self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) - self._clipboard: SequencerClipboard = SequencerClipboard() - self._system_clipboard: TextClipboard = SystemTextClipboard() - self._tracker_block_text: TrackerBlockText = TrackerBlockText( - samples=ProjectSampleDirectory(project_controller), - ) - self._order_block_text: OrderBlockText = OrderBlockText() - self._tracker_text_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_block_text.parse) - self._order_text_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_block_text.parse) - self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) - self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) - self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) - self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic) - self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic) - self._sequencer_voices_logic: SequencerVoicesLogic = SequencerVoicesLogic( - project_controller, - session_manager, - audio_device_manager, - scheduling=layout.scheduling, - ) - self._sequencer_channels_logic: SequencerChannelsLogic = SequencerChannelsLogic() - self._song_player_logic: SongPlayerLogic = SongPlayerLogic( - audio_device_manager, - project_controller, - session_manager, - service=SongPlayerService( - audio_device_manager, - RowSynthesizer( - project_controller, - config_manager.config, - active_channels=lambda: self._sequencer_channels_logic.active_channels, - sample_rate=lambda: audio_device_manager.sample_rate, - ), - should_loop=lambda: session_manager.loop_song, - master_gain=lambda: session_manager.master_gain, - ), - ) - self._guarded_player = GuardedPlayer( - self._song_player_logic, - dialogs=dialogs, - error_message=language_manager["global.player.message.audio_playback_error"], - ) - self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( - self._sequencer_tracker_logic.settings, - layout=layout.sequencer, - channel_colors=layout.channel_colors, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL), - initial_octave=session_manager.octave, - language_manager=language_manager, - key_router=key_router, - tab_active=tab_active, - shortcut_source=shortcut_source, - ) - self._sequencer_module_panel: GUISequencerModulePanel = GUISequencerModulePanel( - self._sequencer_tracker_logic.settings, - layout=layout.sequencer, - inputs=layout.inputs, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_MODULE_PANEL), - language_manager=language_manager, - status_bar=status_bar, - ) - self._sequencer_order_panel: GUISequencerOrderPanel = GUISequencerOrderPanel( - layout=layout.sequencer, - channel_colors=layout.channel_colors, - plus_minus_layout=layout.plus_minus, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD), - language_manager=language_manager, - key_router=key_router, - tab_active=tab_active, - shortcut_source=shortcut_source, - ) - self._sequencer_voices_panel: GUISequencerVoicesPanel = GUISequencerVoicesPanel( - layout=layout.sequencer, - detail_color=layout.muted_color, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_VOICES_PANEL), - language_manager=language_manager, - status_bar=status_bar, - key_router=key_router, - tab_active=tab_active, - shortcut_source=shortcut_source, - ) - self._sequencer_history_panel: GUISequencerHistoryPanel = GUISequencerHistoryPanel( - layout=layout.sequencer, - feature_colors=layout.feature_colors, - initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_HISTORY_PANEL), - language_manager=language_manager, - status_bar=status_bar, - ) - self._history_detail: SequencerHistoryDetail = SequencerHistoryDetail( - self._sequencer_tracker_logic, - self._sequencer_voices_logic, - ) - - self._wire_callbacks() - - def _wire_callbacks(self) -> None: - """Connects every panel and logic object this tab owns to the handler that serves it.""" - self._wire_collapse_handlers() - self._wire_module_callbacks() - self._wire_tracker_callbacks() - self._wire_channels_callbacks() - self._wire_order_callbacks() - self._wire_block_callbacks() - self._wire_voices_callbacks() - self._wire_browser_callbacks() - self._wire_playback_callbacks() - self._wire_project_callbacks() - self._wire_history() - - def _wire_collapse_handlers(self) -> None: - for panel in ( - self._sequencer_order_panel, - self._sequencer_tracker_panel, - self._sequencer_module_panel, - self._sequencer_voices_panel, - self._sequencer_history_panel, - ): - panel.set_collapse_handler(self._on_card_collapse_changed) - - def _wire_module_callbacks(self) -> None: - self._sequencer_module_panel.on_nes_frequency = self._request_nes_frequency_change - self._sequencer_module_panel.on_rows_per_pattern = self._undoable( - HistoryAction.SET_ROWS_PER_PATTERN, - self._sequencer_tracker_logic.set_rows_per_pattern, - detail=self._history_detail.value, - coalesce=self._module_setting_key, - ) - self._sequencer_module_panel.on_tempo = self._undoable( - HistoryAction.SET_TEMPO, - self._sequencer_tracker_logic.set_tempo, - detail=self._history_detail.value, - coalesce=self._module_setting_key, - ) - self._sequencer_module_panel.on_speed = self._undoable( - HistoryAction.SET_SPEED, - self._sequencer_tracker_logic.set_speed, - detail=self._history_detail.value, - coalesce=self._module_setting_key, - ) - - def _wire_tracker_callbacks(self) -> None: - self._sequencer_tracker_panel.on_clear_row = self._undoable( - HistoryAction.CLEAR_ROW, - self._sequencer_tracker_logic.clear_cell, - detail=self._history_detail.clear_row, - ) - self._sequencer_tracker_panel.on_clear_subcolumn = self._undoable( - HistoryAction.CLEAR_SUBCOLUMN, - self._sequencer_tracker_logic.clear_cell_subcolumn, - detail=self._history_detail.clear_subcolumn, - ) - self._sequencer_tracker_panel.on_set_row = self._undoable( - HistoryAction.EDIT_ROW, - self._sequencer_tracker_logic.write_cell, - detail=self._history_detail.edit_row, - coalesce=self._edit_row_key, - ) - self._sequencer_tracker_panel.on_set_note_off = self._undoable( - HistoryAction.NOTE_OFF, - self._sequencer_tracker_logic.cut_note, - detail=self._history_detail.note_off, - coalesce=self._cell_key, - ) - self._sequencer_tracker_panel.on_note_typed = self._undoable( - HistoryAction.EDIT_ROW, - self._sequencer_tracker_logic.write_note, - detail=self._history_detail.note_typed, - coalesce=self._note_key, - ) - self._sequencer_tracker_panel.on_octave_changed = self._session_manager.set_octave - self._sequencer_tracker_panel.on_cell_selected = self._on_tracker_cell_focused - self._sequencer_tracker_panel.on_play_from_row = self._on_tracker_play_from_row - self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame - self._sequencer_tracker_panel.on_adjust_transpose = self._undoable( - HistoryAction.ADJUST_TRANSPOSE, - self._tracker_region_adjuster.adjust_transpose, - detail=self._history_detail.adjust_transpose, - coalesce=self._adjustment_key, - ) - self._sequencer_tracker_panel.on_adjust_volume = self._undoable( - HistoryAction.ADJUST_VOLUME, - self._tracker_region_adjuster.adjust_volume, - detail=self._history_detail.adjust_volume, - coalesce=self._adjustment_key, - ) - self._sequencer_tracker_logic.on_settings_changed = self._on_settings_changed - self._sequencer_tracker_logic.on_tracker_changed = self._sequencer_tracker_panel.update_tracker - self._sequencer_tracker_logic.on_frame_changed = self._sequencer_order_panel.select_position - - def _wire_channels_callbacks(self) -> None: - """Connects the tracker's column headers and the order table's row labels to the mute set - the song player reads. - - Both tables name the same channels and switch the same set, so each panel's hooks reach the - channels logic directly and both show every change. Muting is a monitoring gesture, so these - hooks record no history entry. - """ - self._sequencer_channels_logic.on_channels_changed = self._show_channels - for panel in (self._sequencer_tracker_panel, self._sequencer_order_panel): - panel.on_channel_mute_toggled = self._sequencer_channels_logic.toggle - panel.on_channel_soloed = self._sequencer_channels_logic.solo - panel.on_channels_toggled = self._sequencer_channels_logic.toggle_all - panel.on_channels_muted = self._sequencer_channels_logic.mute_all - panel.on_channels_unmuted = self._sequencer_channels_logic.unmute_all - - def _show_channels(self, view_model: SequencerChannelsViewModel) -> None: - """Shows the mute set in both tables and in the menu bar, so a channel reads the same - wherever it appears. - - The menu bar sits above this tab and rebuilds its own state, so it is handed the change - as a signal and reads the mute set back through :attr:`channels`. - """ - self._sequencer_tracker_panel.update_channels(view_model) - self._sequencer_order_panel.update_channels(view_model) - self._on_channels_changed() - - @property - def channels(self) -> SequencerChannelsViewModel: - """The mute set the tables show, for the menu bar that lists the same channels.""" - return self._sequencer_channels_logic.build_channels() - - def toggle_channel(self, channel: ChannelName) -> None: - """Flips one channel between audible and silent, the menu's per-channel gesture.""" - self._sequencer_channels_logic.toggle(channel) - - def unmute_all_channels(self) -> None: - """Returns every channel to audible, the menu's whole-mix gesture.""" - self._sequencer_channels_logic.unmute_all() - - def set_follow_mode(self, mode: FollowMode) -> None: - """Chooses how far the view chases the playhead, the menu's and keyboard's gesture. - - The player holds the setting and emits a view as it changes, which is what settles the - grid's following and the menu's mark together. - """ - self._song_player_logic.set_follow_mode(mode) - - def _wire_order_callbacks(self) -> None: - self._sequencer_order_logic.on_order_changed = self._sequencer_order_panel.update_order - self._sequencer_order_panel.on_frame_selected = self._on_order_frame_selected - self._sequencer_order_panel.on_remove_requested = self._undoable( - HistoryAction.REMOVE_FRAME, - self._on_order_remove, - detail=self._history_detail.remove_frame, - ) - self._sequencer_order_panel.on_duplicate_requested = self._undoable( - HistoryAction.DUPLICATE_FRAME, - self._on_order_duplicate, - detail=self._history_detail.copy_frame, - ) - self._sequencer_order_panel.on_clone_requested = self._undoable( - HistoryAction.CLONE_FRAME, - self._on_order_clone, - detail=self._history_detail.copy_frame, - ) - self._sequencer_order_panel.on_insert_requested = self._undoable( - HistoryAction.ADD_FRAME, - self._on_order_insert, - detail=self._history_detail.add_frame, - ) - self._sequencer_order_panel.on_clear_requested = self._undoable( - HistoryAction.CLEAR_FRAME, - self._on_order_clear, - detail=self._history_detail.clear_frame, - ) - self._sequencer_order_panel.on_play_from_requested = self._on_order_play_from - self._sequencer_order_panel.on_move_requested = self._undoable( - HistoryAction.MOVE_FRAME, - self._on_order_move, - detail=self._history_detail.move_frame, - ) - self._sequencer_order_panel.on_set_order_entry = self._undoable( - HistoryAction.SET_ORDER_ENTRY, - self._sequencer_order_logic.set_order_entry, - detail=self._history_detail.set_order_entry, - ) - self._sequencer_order_panel.on_set_master_entry = self._undoable( - HistoryAction.SET_ORDER_ENTRY, - self._sequencer_order_logic.set_master_entry, - detail=self._history_detail.set_master_entry, - ) - self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused - - def _wire_block_callbacks(self) -> None: - """Connects the grids' block gestures to the clipboard they copy into. - - A copy reads the project and leaves it as it stands, so it is wired straight through - instead of through :meth:`_undoable`: a transaction over it would record an entry the - history has nothing to restore for. The three gestures that do write are whole ones, each - recording the single entry that takes the grid back to where it stood. - - Each grid also asks whether its own slot holds a block, which is what a menu offering - Paste consults before it is opened. - """ - self._sequencer_tracker_panel.can_paste_block = self._can_paste_tracker_block - self._sequencer_order_panel.can_paste_block = self._can_paste_order_block - self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block - self._sequencer_tracker_panel.on_cut_block = self._undoable( - HistoryAction.CUT_BLOCK, - self._cut_tracker_block, - detail=self._history_detail.tracker_block, - ) - self._sequencer_tracker_panel.on_delete_block = self._undoable( - HistoryAction.DELETE_BLOCK, - self._tracker_block_writer.clear, - detail=self._history_detail.tracker_block, - ) - self._sequencer_tracker_panel.on_paste_block = self._undoable( - HistoryAction.PASTE_BLOCK, - self._paste_tracker_block, - detail=self._history_detail.tracker_paste, - ) - self._sequencer_order_panel.on_copy_block = self._on_order_copy_block - self._sequencer_order_panel.on_cut_block = self._undoable( - HistoryAction.CUT_BLOCK, - self._cut_order_block, - detail=self._history_detail.order_block, - ) - self._sequencer_order_panel.on_delete_block = self._undoable( - HistoryAction.DELETE_BLOCK, - self._order_block_writer.clear, - detail=self._history_detail.order_block, - ) - self._sequencer_order_panel.on_paste_block = self._undoable( - HistoryAction.PASTE_BLOCK, - self._paste_order_block, - detail=self._history_detail.order_paste, - ) - - def _can_paste_tracker_block(self) -> bool: - """Whether the tracker has a block to write, which is what its Paste item is offered on.""" - return self._tracker_block_in_hand() is not None - - def _can_paste_order_block(self) -> bool: - """Whether the order has a block to write, which is what its Paste item is offered on.""" - return self._order_block_in_hand() is not None - - def _tracker_block_in_hand(self) -> Optional[TrackerBlock]: - """The block a tracker paste would write: the system clipboard's while its text is one. - - Text another instance copied reads as a block here, so it stands ahead of the slot the - tracker copied into, and text from anywhere else leaves that slot's own block in hand. - """ - parsed = self._tracker_text_cache.block(self._system_clipboard.read()) - if parsed is not None: - return parsed - - return self._clipboard.tracker_block - - def _order_block_in_hand(self) -> Optional[OrderBlock]: - """The block an order paste would write: the system clipboard's while its text is one. - - Text another instance copied reads as a block here, so it stands ahead of the slot the - order copied into, and text from anywhere else leaves that slot's own block in hand. - """ - parsed = self._order_text_cache.block(self._system_clipboard.read()) - if parsed is not None: - return parsed - - return self._clipboard.order_block - - def _on_tracker_copy_block(self, region: TrackerRegion) -> None: - """Puts the tracker's selected block on both clipboards, for a paste to replay. - - The slot keeps the block exactly, and the system clipboard keeps the text form of it, so - the same copy reaches a paste here and a paste in another instance. - """ - block = self._tracker_block_reader.read(region) - self._clipboard.store_tracker_block(block) - self._system_clipboard.write(self._tracker_block_text.state(block, region)) - - def _cut_tracker_block(self, region: TrackerRegion) -> None: - """Takes the block a region covers onto the clipboard, then empties what it covered.""" - self._on_tracker_copy_block(region) - self._tracker_block_writer.clear(region) - - def _paste_tracker_block(self, cell: TrackerCell) -> None: - """Writes the block the tracker has in hand at a cell, while a copy has been made.""" - block = self._tracker_block_in_hand() - if block is not None: - self._tracker_block_writer.write(block, cell) - - def _on_order_copy_block(self, region: OrderRegion) -> None: - """Puts the order's selected block on both clipboards, for a paste to replay. - - The slot keeps the block exactly, and the system clipboard keeps the text form of it, so - the same copy reaches a paste here and a paste in another instance. - """ - block = self._order_block_reader.read(region) - self._clipboard.store_order_block(block) - self._system_clipboard.write(self._order_block_text.state(block, region)) - - def _cut_order_block(self, region: OrderRegion) -> None: - """Takes the block a region covers onto the clipboard, then silences what it covered.""" - self._on_order_copy_block(region) - self._order_block_writer.clear(region) - - def _paste_order_block(self, cell: OrderCell) -> None: - """Writes the block the order has in hand at a cell, while a copy has been made.""" - block = self._order_block_in_hand() - if block is not None: - self._order_block_writer.write(block, cell) - - def _wire_voices_callbacks(self) -> None: - self._sequencer_voices_logic.on_voices_changed = self._on_voices_changed - self._sequencer_voices_logic.on_edit_voice_requested = self._dispatch_edit_voice - self._sequencer_voices_logic.on_autoplay_error = self._on_preview_error - self._sequencer_voices_panel.voice_footprint = self._sequencer_voices_logic.build_voice_footprint - self._sequencer_voices_panel.on_voice_selected = self._on_voice_selected - self._sequencer_voices_panel.on_voice_edit_requested = self._sequencer_voices_logic.request_edit - self._sequencer_voices_panel.on_remove_requested = self._remove_voice - self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice - self._sequencer_voices_panel.on_move_requested = self._undoable( - HistoryAction.MOVE_VOICE, - self._sequencer_voices_logic.move_voice, - detail=self._history_detail.move_voice, - ) - self._sequencer_voices_panel.on_rename_committed = self._submit_rename - self._sequencer_voices_panel.on_duplicate_requested = self._undoable( - HistoryAction.DUPLICATE_VOICE, - self._sequencer_voices_logic.duplicate_voice, - detail=self._history_detail.duplicate_voice, - ) - self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument - self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file - self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument - self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments - self._sequencer_voices_panel.on_export_instrument_requested = self._instrument_exports.request_voice - self._sequencer_voices_panel.instrument_channels = self._sequencer_voices_logic.instrument_channels - self._sequencer_voices_panel.on_instrument_from_channel_requested = self.add_instrument_from_channel - - def add_instrument(self) -> None: - """Appends a hand-written voice, named for the position it takes in the list. - - An instrument arrives sustaining at full volume, so it plays as soon as it is placed and the - envelopes stay the reader's to write; naming it by its position gives the list a readable - entry until they rename it. - """ - name = self._language_manager["sequencer.voices.template.instrument_name"].format( - position=display_id(self._project_controller.voice_count), - ) - with self._history.transaction( - HistoryAction.ADD_INSTRUMENT, - detail=self._history_detail.add_instrument(name), - ): - self._sequencer_voices_logic.add_new_instrument(name) - - def add_instrument_from_channel( - self, - voice_id: str, - channel_name: ChannelName, - ) -> None: - """Takes what one channel of a voice plays as an instrument of its own, then opens it. - - A recording states its channels as frames, and this reads one of them back as envelopes, - so what the conversion found becomes a voice the reader edits by hand. The new voice is - brought up where it is edited, since seeing those envelopes is what taking the channel out - was for. - - Args: - voice_id: The voice the channel belongs to. - channel_name: The channel whose envelopes the instrument takes. - """ - instrument = self._sequencer_voices_logic.instrument_from_channel(voice_id, channel_name) - if instrument is None: - return - - with self._history.transaction( - HistoryAction.ADD_INSTRUMENT, - detail=self._history_detail.add_instrument(instrument.name), - ): - self._sequencer_voices_logic.add_instrument(instrument) - - self._sequencer_voices_logic.request_edit(instrument.id) - - def add_sample_from_file(self) -> None: - """Brings a reconstruction saved anywhere on disk into the pool as a sample. - - The tree beside the list reaches the reconstructions folder, so a file kept elsewhere - arrives through the system's own browser, which opens on the folder the last one came - from. - """ - filepath = open_file_dialog( - title=self._language_manager["sequencer.voices.title.add_sample_dialog"], - initial_directory=self._session_manager.get_reconstruction_path(), - filters=( - FileFilter.for_extensions( - self._language_manager["global.dialog.filter.reconstruction"], - [EXT_FILE_RECONSTRUCTION], - ), - ), - ) - - self._import_located_reconstruction(filepath) - - @ignore_none_path - def _import_located_reconstruction(self, filepath: Path) -> None: - self._session_manager.set_reconstruction_path(filepath.parent) - self.import_reconstruction(filepath) - - def import_instrument(self) -> None: - """Brings a FamiTracker instrument file into the pool as an instrument voice. - - The file arrives through the system's own browser, which opens on the folder the last - instrument was written to or read from, so an export and the import that follows it meet - in one place. A project is asked for first, since a voice needs a pool to land in. - """ - if not self._project_controller.is_open: - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, - self._msg_no_project, - self._ttl_no_project, - ) - return - - filepath = open_file_dialog( - title=self._language_manager["sequencer.voices.title.import_instrument_dialog"], - initial_directory=self._session_manager.get_instrument_path(), - filters=( - FileFilter.for_extensions( - self._language_manager["global.dialog.filter.famitracker_instrument"], - [EXT_FILE_INSTRUMENT], - ), - ), - ) - - self._import_located_instrument(filepath) - - @ignore_none_path - def _import_located_instrument(self, filepath: Path) -> None: - """Reads a located instrument file into the pool, then reports what it held. - - The file is read before the pool is touched, so a file the reader cannot use leaves the - project as it stands and the history without an entry. - """ - self._session_manager.set_instrument_path(filepath.parent) - imported = self._read_instrument(filepath) - if imported is None: - return - - with self._history.transaction( - HistoryAction.ADD_INSTRUMENT, - detail=self._history_detail.add_instrument(imported.voice.name), - ): - self._sequencer_voices_logic.add_instrument(imported.voice) - - self._report_import(imported) - - def _read_instrument(self, filepath: Path) -> Optional[ImportedVoice]: - """Reads an instrument file, reporting a file the reader cannot take as a voice. - - Returns: - Optional[ImportedVoice]: The voice the file describes, or ``None`` once the failure - has been shown. - """ - try: - return self._sequencer_voices_logic.read_instrument(filepath) - except FileNotFoundError as exception: - logger.error_with_traceback(exception, f"No instrument file at {filepath}") - self._dialogs.show_file_not_found( - filepath, - self._language_manager["sequencer.voices.message.instrument_not_found"], - ) - except (LoadInstrumentError, OSError) as exception: - logger.error_with_traceback(exception, f"Failed to read an instrument from {filepath}") - self._dialogs.show_error(exception) - - return None - - def _report_import(self, imported: ImportedVoice) -> None: - """Names what the instrument file carried beyond the voice the pool took from it.""" - notice = self._import_messages.notice(imported.voice.name, imported.omissions) - if notice is not None: - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, - notice, - self._import_messages.title, - ) - - def _wire_browser_callbacks(self) -> None: - self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed) - self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed - self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction - self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open - self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction - self._sequencer_browser_panel.replace_in_sequencer_label = self._replace_target_label - self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate - self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree - self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled - self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed - self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility - self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error - - def _wire_playback_callbacks(self) -> None: - self._song_player_logic.on_position_changed = self._on_player_position_changed - self._song_player_logic.on_view_changed = self._on_player_view_changed - self._song_player_logic.on_error = self._on_player_error - - def _wire_project_callbacks(self) -> None: - self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings - self._project_controller.on_song_changed = self._on_song_changed - self._project_controller.on_voices_changed = self._sequencer_voices_logic.push_voices - self._project_controller.on_project_replaced = self._on_project_replaced - - def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: - """Persists a card's collapsed state so it restores on the next launch.""" - self._session_manager.set_card_collapsed(card_tag, collapsed) - if card_tag == TAG_SEQUENCER_HISTORY_PANEL: - self._sync_voices_height() - - def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None: - """Persists the browser panel's collapse, then docks or restores the width of the column it fills.""" - self._session_manager.set_card_collapsed(card_tag, collapsed) - self._sync_browser_width() - - def _on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None: - """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" - self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) - - def sync_responsive_layout(self) -> None: - """Refits this tab's side column to the current viewport, the entry the resize handler calls.""" - self._sync_browser_width() - - def _sync_browser_width(self) -> None: - """Shrinks the browser column to the collapse rail when collapsed, else sizes it to the viewport width.""" - if self._sequencer_browser_panel.collapsed: - width = self._geometry.rail_width - else: - width = expanded_side_width( - self._geometry.side_width, - dpg.get_viewport_client_width(), - self._geometry.baseline_viewport_width, - self._side_panel_count, - self._geometry.center_weight, - ) - - dpg_configure_item(_LEFT_COLUMN_TAG, width=width) - - def _stacked_card_gap(self) -> int: - """The rendered vertical gap between two cards stacked in the right column. - - The cards are separated by a ``panel_gap`` spacer, but DearPyGui also lays its ``ItemSpacing.y`` - on each side of that spacer, so the real gap is the spacer plus two of those spacings. The - spacing is read from the base theme, which sets it explicitly, so the gap tracks the theme's - value. - """ - spacing = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT).get_style( - dpg.mvAll, - dpg.mvStyleVar_ItemSpacing, - ) - spacing_y = int(spacing[1]) if spacing is not None else 0 - return self._geometry.panel_gap + 2 * spacing_y - - def _sync_voices_height(self) -> None: - """Reserves the bottom space the history card and its inter-card gap occupy, so samples fills the rest. - - The samples card fills the right column above the history card by reserving that footprint below - it. History carries its own height in both states — filling the reservation while expanded, pinned - to its header bar while collapsed — so this only has to size the reservation: the expanded history - height, or the collapsed bar footprint. The reservation clears the full inter-card gap (see - :meth:`_stacked_card_gap`) so the collapsed bar lands flush at the column bottom. - """ - if self._sequencer_history_panel.collapsed: - footprint = self._history_collapsed_footprint - else: - footprint = self._history_expanded_height - - self._sequencer_voices_panel.set_expanded_height(-(self._inter_card_gap + footprint)) - - def _wire_history(self) -> None: - self._sequencer_history_panel.on_undo = self.undo - self._sequencer_history_panel.on_redo = self.redo - self._sequencer_history_panel.on_jump_to = self.jump_to_history - - def _undoable( - self, - action: HistoryAction, - callback: Callable[_UndoableParams, None], - *, - detail: Optional[Callable[_UndoableParams, HistoryDetail]] = None, - coalesce: Optional[Callable[_UndoableParams, CoalesceKey]] = None, - ) -> Callable[_UndoableParams, None]: - """Wraps a state-changing hook so its whole gesture becomes one undo entry. - - Every mutation the wrapped callback triggers is grouped under ``action``; - a gesture that changes nothing records no entry. ``detail`` computes the - entry's colored description segments from the same arguments the hook - receives, and ``coalesce`` computes the gesture's target key from them: - consecutive gestures sharing the same action and target collapse into a - single entry. - - The gesture is batched inside its transaction, so however many rows it - writes, the panels rebuild once — and they rebuild before the entry that - undoes them is recorded, because the snapshot reads the project rather - than the views. - """ - - def wrapped( - *args: _UndoableParams.args, - **kwargs: _UndoableParams.kwargs, - ) -> None: - description = detail(*args, **kwargs) if detail is not None else () - key = coalesce(*args, **kwargs) if coalesce is not None else None - with ( - self._history.transaction( - action, - detail=description, - coalesce=key, - ), - self._project_controller.batch(), - ): - callback(*args, **kwargs) - - return wrapped - - def _cell_key( - self, - row_index: int, - channel: Optional[ChannelName], - ) -> CoalesceKey: - """Identifies one cell of the displayed frame as a coalescing target. - - The sample column (``channel`` absent) is its own target, distinct from - every channel column. - """ - channel_key = channel if channel is not None else "" - return (self._sequencer_tracker_logic.frame_index, channel_key, row_index) - - def _note_key( - self, - row_index: int, - channel: ChannelName, - _pitch: int, - ) -> CoalesceKey: - """Identifies the cell a typed note landed in, so retyping one note coalesces onto it.""" - return self._cell_key(row_index, channel) - - def _adjustment_key( - self, - region: TrackerRegion, - _delta: int, - ) -> CoalesceKey: - """Identifies the cells an adjustment covers as one coalescing target. - - A streak of nudges over the same block reads as one entry, so holding a transpose key steps - the selection and leaves a single step to undo; moving the cursor or reaching the selection - out starts the next one. - """ - return ( - self._sequencer_tracker_logic.frame_index, - region.first_row, - region.last_row, - region.first_slot, - region.last_slot, - ) - - def _edit_row_key( - self, - row_index: int, - channel: Optional[ChannelName], - voice_id: Optional[str], - transpose: Optional[int], - volume: Optional[int], - ) -> CoalesceKey: - """Extends the cell target with the subcolumns the edit writes. - - Consecutive edits of one cell coalesce only when they write the same - subcolumns, so entering a note and then tweaking its volume stay - separate entries. - """ - return ( - *self._cell_key(row_index, channel), - voice_id is not None, - transpose is not None, - volume is not None, - ) - - def _module_setting_key(self, _value: int) -> CoalesceKey: - """Marks a module-wide setting as one target, shared by its whole streak.""" - return () - - def _on_settings_changed( - self, - view_model: SequencerSettingsViewModel, - ) -> None: - """Hands the project's song settings to the two panels that read them. - - The module panel shows the timing fields themselves; the tracker reads the meter out of - the same view model, so a highlight edited in the project properties retints the grid as - soon as the dialog commits. - """ - self._sequencer_module_panel.update_settings(view_model) - self._sequencer_tracker_panel.update_settings(view_model) - - def _on_project_replaced(self) -> None: - """Realigns the tab with a replaced project, keeping the mute set across history navigation. - - Undo, redo, and history jumps replace the project as well, and the history manager reports - itself restoring throughout, so the channels the user is listening through carry across - them. A new, opened, or closed document begins a fresh listening session instead, with - every channel audible. - """ - if not self._history.is_restoring: - self._sequencer_channels_logic.reset() - - self._history.reset() - self.refresh() - - def play_from_current_frame(self) -> None: - """Plays from the frame the tracker is showing, seeking in place when already playing.""" - self._on_order_play_from(self._sequencer_tracker_logic.frame_index) - - def undo(self) -> None: - self._history.undo() - - def redo(self) -> None: - self._history.redo() - - def jump_to_history(self, index: int) -> None: - self._history.jump_to(index) - - def refresh_history(self) -> None: - """Re-renders the history panel from the manager's current stack. - - Called by the application's history fan-out, which owns the manager's - single ``on_history_changed`` slot and forwards each change here and to - the menu bar. - """ - self._sequencer_history_panel.update_view(self._build_history_view_model()) - - def reconstruction_edit_detail( - self, - voice_id: str, - channel_name: ChannelName, - feature_key: FeatureKey, - ) -> HistoryDetail: - """Describes a reconstruction edit for the project history's detail line.""" - return self._history_detail.edit_reconstruction( - voice_id, - channel_name, - feature_key, - ) - - def instrument_edit_detail( - self, - voice_id: str, - feature_key: FeatureKey, - ) -> HistoryDetail: - """Describes a hand-written voice's edited dimension for the project history.""" - return self._history_detail.edit_instrument(voice_id, feature_key) - - def reconstruction_stem_detail( - self, - voice_id: str, - stem_name: str, - ) -> HistoryDetail: - """Describes a recording taken out of a reconstruction for the project history.""" - return self._history_detail.remove_stem(voice_id, stem_name) - - def _build_history_view_model(self) -> HistoryViewModel: - cursor = self._history.cursor - entries = tuple( - HistoryEntryViewModel( - index=index, - label=self._history_action_label(entry.action), - detail_segments=entry.detail, - is_current=index == cursor, - is_future=index > cursor, - ) - for index, entry in enumerate(self._history.entries) - ) - return HistoryViewModel(entries=entries, cursor=cursor) - - def _history_action_label(self, action: HistoryAction) -> str: - return self._language_manager[ - Page.SEQUENCER, - Panel.HISTORY, - TextType.LABEL, - action, - ] - - def initialize(self) -> None: - """Pushes the current project into every sequencer panel. - - Called once after the GUI is built so the panels reflect the project the - application started with (or restored). - """ - self._song_player_logic.refresh_view() - self.refresh() - - def refresh(self) -> None: - self._nes_frequency_change_acknowledged = False - self._song_player_logic.stop() - self._sequencer_tracker_logic.refresh() - self._sequencer_order_logic.refresh() - self._sequencer_voices_logic.push_voices() - self._sequencer_channels_logic.push_channels() - is_open = self._project_controller.is_open - self._sequencer_module_panel.set_enabled(is_open) - self._sequencer_tracker_panel.set_enabled(is_open) - self._sequencer_order_panel.set_enabled(is_open) - self._sequencer_history_panel.set_enabled(is_open) - - def repaint(self) -> None: - """Draws every table again so its tints take the palette now in place. - - DearPyGui keeps a table's row, column and cell tints as state of the table rather than - as a property of an item, so they take a new color by being issued again. Each panel - answers for the tints it owns, and this is where the palette asks all three. - """ - self._sequencer_tracker_panel.repaint() - self._sequencer_order_panel.repaint() - self._sequencer_voices_panel.repaint() - - def refresh_browser(self) -> None: - self._sequencer_browser_panel.refresh() - - def save_browser_shape(self) -> None: - """Writes down the rows the browser stands open, so a later run brings them back.""" - self._session_manager.set_expanded_rows( - self._sequencer_browser_panel.tag, - self._sequencer_browser_panel.expanded_rows, - ) - - def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: - self._sequencer_browser_panel.update_favorite_indicators(nodes) - - def _on_song_changed(self) -> None: - self._sequencer_tracker_logic.push_settings() - self._sequencer_tracker_logic.push_tracker() - self._sequencer_order_logic.push_order() - - def _on_player_error(self, error: Exception) -> None: - self._dialogs.show_error(error) - - def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: - """Settles the marks the transport owns, and how far the grid chases the playhead. - - The player emits a view on every position update and on every change to the setting, so - reading the follow behavior here keeps the grid in step both while a song sounds and the - moment the reader picks another mode. - """ - self._sequencer_tracker_panel.set_row_following(view_model.follow_mode.follows_row) - if not view_model.is_playing and not view_model.is_paused: - self._playing_position = None - self._mark_playhead() - - def _on_player_position_changed( - self, - order_position: int, - row_index: int, - ) -> None: - """Moves the marks the playhead carries, showing the frame it sounds when following. - - The frame is selected ahead of the marks so the row's mark, and the scroll that reveals it, - land on the pattern the playhead has reached. - """ - self._playing_position = SongPosition( - order_position=order_position, - row_index=row_index, - ) - if self._song_player_logic.follow_mode.follows_pattern: - self._sequencer_tracker_logic.select_frame(order_position) - - self._mark_playhead() - - def _mark_playhead(self) -> None: - """Puts the playhead's marks where it stands, on both grids. - - The order grid marks the frame the playhead sounds; the tracker takes the whole position, - since the row it marks belongs to the pattern of that frame. - """ - position = self._playing_position - self._sequencer_tracker_panel.set_playing_position(position) - self._sequencer_order_panel.set_playing_position( - position.order_position if position is not None else None, - ) - - def _on_order_frame_selected(self, frame_index: int) -> None: - """Selects an order frame in the tracker, and moves the playhead too when following. - - While the view follows the playhead, choosing another order during playback relocates the - playhead to it (the seek no-ops when stopped); otherwise the selection only changes which - pattern is edited, leaving playback where it is. - """ - self._sequencer_tracker_logic.select_frame(frame_index) - if self._song_player_logic.follow_mode.follows_pattern: - self._song_player_logic.seek(frame_index) - - def _on_preview_error(self, exception: Exception) -> None: - FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) - - def _is_project_open(self) -> bool: - return self._project_controller.is_open - - def import_reconstruction(self, filepath: Path) -> None: - if not self._project_controller.is_open: - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, - self._msg_no_project, - self._ttl_no_project, - ) - return - - try: - reconstruction = self._sequencer_browser_logic.load_reconstruction(filepath) - except (SampleToNESError, OSError) as exception: - logger.error_with_traceback( - exception, - f"Failed to load reconstruction from {filepath}", - ) - self._dialogs.show_error(exception) - return - - self._add_reconstruction_with_frequency_check(reconstruction, filepath.stem) - - def import_reconstruction_object(self, reconstruction: Reconstruction, name: str) -> None: - """Adds an in-memory reconstruction — the one open in the Reconstruction tab — as a sample. - - The sample embeds an independent copy, so the open document keeps its own source-audio - location and file backing while the project stores a self-contained, detached sample. - """ - if not self._project_controller.is_open: - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, - self._msg_no_project, - self._ttl_no_project, - ) - return - - self._add_reconstruction_with_frequency_check( - reconstruction.model_copy(deep=True), - name, - ) - - def _add_reconstruction_with_frequency_check( - self, - reconstruction: Reconstruction, - name: str, - ) -> None: - """Adds a loaded reconstruction once its NES frequency is settled against the project's. - - An empty project adopts the incoming frequency, since the rate times nothing there yet; a - project that already holds samples confirms first, because the rate governs how all of them - play back. - """ - self._reconcile_nes_frequency( - reconstruction, - lambda adopt_frequency: self._commit_add_reconstruction( - reconstruction, - name, - adopt_frequency=adopt_frequency, - ), - can_adopt_frequency=not self._project_controller.has_voices, - ) - - def _reconcile_nes_frequency( - self, - reconstruction: Reconstruction, - commit: Callable[[Optional[int]], None], - *, - can_adopt_frequency: bool, - ) -> None: - """Settles a reconstruction's NES frequency against the project's, then commits the gesture. - - A reconstruction renders at the frequency it was generated at, so bringing one recorded at - another rate into the project plays it back wrong. Equal rates commit straight away. A - mismatch the project can absorb — because the rate times nothing that outlives the gesture — - adopts the incoming rate. Otherwise the user decides, seeing both rates, and confirming - keeps the project's rate for the samples already timed by it. - - Args: - reconstruction: The reconstruction being brought into the project. - commit: Performs the gesture, receiving the frequency to adopt, or ``None`` to keep the - project's. - can_adopt_frequency: Whether adopting the incoming rate re-times only what this gesture - itself brings in, which settles a mismatch without asking. - """ - reconstruction_frequency = reconstruction.config.nes_frequency - project_frequency = self._sequencer_tracker_logic.settings.nes_frequency - - if reconstruction_frequency == project_frequency: - commit(None) - return - - if can_adopt_frequency: - commit(reconstruction_frequency) - return - - self._dialogs.show_confirmation( - tag=TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY, - title=self._language_manager["global.dialog.title.frequency_mismatch"], - message=self._language_manager["global.dialog.message.frequency_mismatch"].format( - reconstruction=reconstruction_frequency, - project=project_frequency, - ), - on_confirm=lambda: commit(None), - ok_label=self._language_manager["global.dialog.label.add_anyway"], - ) - - def _commit_add_reconstruction( - self, - reconstruction: Reconstruction, - name: str, - *, - adopt_frequency: Optional[int], - ) -> None: - """Adds a reconstruction as one undoable gesture, optionally adopting its frequency. - - The frequency reconciliation and the sample insertion form a single history - entry, so undoing a freshly-imported sample also restores the prior rate. - """ - with self._history.transaction( - HistoryAction.ADD_SAMPLE, - detail=self._history_detail.add_sample(name), - ): - if adopt_frequency is not None: - self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency) - self._sequencer_browser_logic.add_reconstruction(reconstruction, name) - - self._on_tab_switch(Tab.SEQUENCER) - - def replace_reconstruction(self, filepath: Path) -> None: - """Substitutes the selected sample's reconstruction with a browser file's. - - The sample keeps its id and position, so every pattern row referencing it sounds the - incoming audio while the tracker shows it where it was, and it takes the file's name the way - an import does. The target is whatever the samples panel has selected as the gesture starts, - which is also what named the menu item the user clicked. - """ - selection = self._sequencer_voices_panel.selection - if selection is None: - return - - try: - reconstruction = self._sequencer_browser_logic.load_reconstruction(filepath) - except (SampleToNESError, OSError) as exception: - logger.error_with_traceback(exception, f"Failed to load reconstruction from {filepath}") - self._dialogs.show_error(exception) - return - - self._reconcile_nes_frequency( - reconstruction, - lambda adopt_frequency: self._commit_replace_reconstruction( - selection.voice_id, - reconstruction, - filepath.stem, - adopt_frequency=adopt_frequency, - ), - can_adopt_frequency=self._project_controller.voice_count == 1, - ) - - def _commit_replace_reconstruction( - self, - voice_id: str, - reconstruction: Reconstruction, - name: str, - *, - adopt_frequency: Optional[int], - ) -> None: - """Substitutes a sample's reconstruction as one undoable gesture, renaming it to the source. - - The detail is composed while the sample still holds the outgoing reconstruction, so it reads - the name being replaced alongside the incoming one. The replacement is announced in the same - window, ahead of the substitution, because an editor holding the sample open recognizes it by - the identity of the reconstruction it is about to give up. The frequency adoption, the rename, - and the substitution share a single history entry, so one undo restores the previous rate, - name, and audio together. - """ - detail = self._history_detail.replace_sample(voice_id, name) - with self._history.transaction( - HistoryAction.REPLACE_SAMPLE, - detail=detail, - ): - if adopt_frequency is not None: - self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency) - - self._sequencer_voices_logic.rename_voice(voice_id, name) - self._on_sample_reconstruction_replaced(voice_id, reconstruction) - self._sequencer_browser_logic.replace_reconstruction(voice_id, reconstruction) - - def _replace_target_label(self) -> Optional[str]: - """The indexed label of the sample a browser replacement would overwrite, while one is selected.""" - selection = self._sequencer_voices_panel.selection - if selection is None: - return None - - return selection.label - - def _dispatch_edit_voice(self, voice_id: str) -> None: - self._on_edit_voice_requested(voice_id) - - def _on_tracker_play_from_row(self, row_index: int) -> None: - """Starts playback from the right-clicked row of the frame the tracker is showing.""" - self._song_player_logic.play_from( - self._sequencer_tracker_logic.frame_index, - row_index, - ) - - def _on_voices_changed( - self, - view_model: SequencerVoicesViewModel, - ) -> None: - self._sequencer_voices_panel.update_view(view_model) - self._sequencer_tracker_panel.update_samples(view_model) - - def _on_voice_selected(self, voice_id: str) -> None: - self._sequencer_tracker_panel.deselect_cell() - self._sequencer_order_panel.deselect_cell() - self._sequencer_voices_logic.request_autoplay(voice_id) - logger.debug(f"Sequencer sample selected: {voice_id}") - - def _remove_voice(self, voice_id: str) -> None: - """Removes a voice, confirming first only when a pattern still references it. - - An unused voice is dropped silently; a referenced one would clear every row - that points at it, so the user confirms that loss first. - """ - if not self._sequencer_voices_logic.is_voice_used(voice_id): - self._perform_remove_voice(voice_id) - return - - name = self._sequencer_voices_logic.voice_name(voice_id) - self._dialogs.show_confirmation( - tag=TAG_SEQUENCER_VOICES_DIALOG_REMOVE, - title=self._language_manager["global.dialog.title.remove_voice"], - message=self._language_manager["global.dialog.message.remove_voice"].format(name=name), - on_confirm=lambda: self._perform_remove_voice(voice_id), - ok_label=self._language_manager["global.dialog.label.remove"], - ) - - def _perform_remove_voice(self, voice_id: str) -> None: - detail = self._history_detail.remove_voice(voice_id) - with self._history.transaction( - HistoryAction.REMOVE_VOICE, - detail=detail, - ): - self._sequencer_voices_logic.remove_voice(voice_id) - - def _submit_rename(self, voice_id: str, name: str) -> None: - """Applies an inline rename, ignoring a blank name so the voice keeps its current one.""" - stripped = name.strip() - if stripped: - detail = self._history_detail.rename_voice(voice_id, stripped) - with self._history.transaction( - HistoryAction.RENAME_VOICE, - detail=detail, - ): - self._sequencer_voices_logic.rename_voice(voice_id, stripped) - - def _request_nes_frequency_change(self, nes_frequency: int) -> None: - """Applies a NES-frequency change, confirming first when it would re-time existing samples. - - The rate governs how every sample plays back, so changing it on a project that already - holds samples prompts once (until acknowledged for the session); an empty or acknowledged - project applies silently. Cancelling restores the field to the project's current value. - """ - if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency: - return - - if self._nes_frequency_change_acknowledged or not self._project_controller.has_voices: - self._perform_nes_frequency_change(nes_frequency) - return - - self._dialogs.show_confirmation( - tag=TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, - title=self._language_manager["global.dialog.title.change_nes_frequency"], - message=self._language_manager["global.dialog.message.change_nes_frequency"], - on_confirm=lambda: self._perform_nes_frequency_change(nes_frequency), - ok_label=self._language_manager["global.dialog.label.change_and_retune"], - opt_out_label=self._language_manager["global.dialog.label.dont_ask_again"], - on_opt_out=self._acknowledge_nes_frequency_changes, - on_cancel=self._sequencer_tracker_logic.push_settings, - ) - - def _perform_nes_frequency_change(self, nes_frequency: int) -> None: - """Applies the rate as one undo entry, then requests a retune of the now-stale samples. - - The entry carries a rate-keyed coalesce target so the asynchronous per-sample retune - results fold back into this same ``SET_NES_FREQUENCY`` entry: one undo restores both the - prior rate and the prior reconstructions, and a later change to a different rate appends - a fresh entry. - """ - with self._history.transaction( - HistoryAction.SET_NES_FREQUENCY, - detail=self._history_detail.value(nes_frequency), - coalesce=(nes_frequency,), - ): - self._sequencer_tracker_logic.set_nes_frequency(nes_frequency) - - self._on_nes_frequency_changed(nes_frequency) - - def nes_frequency_detail(self, nes_frequency: int) -> HistoryDetail: - """The history detail for a NES-frequency change, so the retune can reuse its undo entry.""" - return self._history_detail.value(nes_frequency) - - def _acknowledge_nes_frequency_changes(self) -> None: - self._nes_frequency_change_acknowledged = True - - def _on_order_remove(self, position: int) -> None: - length_before = self._project_controller.order_length - self._sequencer_order_logic.remove_from_order(position) - self._relocate_playhead( - lambda playhead: remap_after_remove( - playhead, - position, - length_before - 1, - ) - ) - - def _on_order_duplicate(self, position: int) -> None: - self._sequencer_order_logic.duplicate_frame(position) - self._settle_inserted_frame(position + 1) - - def _on_order_clone(self, position: int) -> None: - self._sequencer_order_logic.clone_frame(position) - self._settle_inserted_frame(position + 1) - - def _settle_inserted_frame(self, position: int) -> None: - """Carries the playhead and the shown frame over a frame that has just been inserted. - - A frame arriving at ``position`` pushes every later frame one along, so a playhead - standing on one of them follows it, and the grid moves to the new frame for the reader - to work on. - """ - self._relocate_playhead( - lambda playhead: remap_after_insert( - playhead, - position, - ) - ) - self._select_frame_when_idle(position) - - def _on_order_insert(self, position: int) -> None: - self._sequencer_order_logic.insert_frame(position + 1) - self._relocate_playhead( - lambda playhead: remap_after_insert( - playhead, - position + 1, - ) - ) - self._select_frame_when_idle(position + 1) - - def _on_order_clear(self, position: int) -> None: - """Clears every channel in the frame; no index shift, so the playhead is left in place. - - A sounding voice keeps ringing across the now-empty frame (only an explicit note-off cuts it). - """ - self._sequencer_order_logic.clear_frame(position) - - def _on_order_move(self, from_position: int, to_position: int) -> None: - self._sequencer_order_logic.move_frame(from_position, to_position) - self._relocate_playhead( - lambda playhead: remap_after_move( - playhead, - from_position, - to_position, - ) - ) - self._sequencer_tracker_logic.select_frame(to_position) - - def _on_order_play_from(self, position: int) -> None: - """Plays from a frame: relocates the playhead when already playing, else starts there.""" - if self._song_player_logic.is_playing(): - self._song_player_logic.seek(position) - else: - self._song_player_logic.play_from(position) - - def _relocate_playhead(self, remap: Callable[[int], int]) -> None: - """Keeps the live playhead on the frame it was sounding after a structural order edit. - - Both grids take the new position straight away, ahead of the worker's next row update, so - rapid edits (e.g. a held Alt+arrow) stay in step, and a paused playhead — which reports no - further rows — is marked on the frame the edit moved it to. - """ - if self._playing_position is None: - return - - order_position = remap(self._playing_position.order_position) - if order_position == self._playing_position.order_position: - return - - self._playing_position = SongPosition( - order_position=order_position, - row_index=self._playing_position.row_index, - ) - self._song_player_logic.relocate(order_position) - self._mark_playhead() - - def _select_frame_when_idle(self, frame_index: int) -> None: - """Moves the editor selection to a frame, unless playback is actively driving it.""" - if not self._song_player_logic.is_playing(): - self._sequencer_tracker_logic.select_frame(frame_index) - - def _on_tracker_cell_focused(self) -> None: - """Drops the order cursor and sample selection when the tracker tracker takes focus. - - The tracker, order, and samples panels each register a key-router scope active only while - it holds a selection; keeping a single selection across the three lets only the focused - panel consume keystrokes. - """ - self._sequencer_order_panel.deselect_cell() - self._sequencer_voices_panel.deselect() - - def _on_order_cell_focused(self) -> None: - """Drops the tracker cursor and sample selection when the order tracker takes focus.""" - self._sequencer_tracker_panel.deselect_cell() - self._sequencer_voices_panel.deselect() - - def create_tab(self) -> None: - with dpg.tab( - tag=TAG_GLOBAL_TAB_SEQUENCER, - parent=TAG_GLOBAL_TABS, - label=self._language_manager["global.menu.label.tab_sequencer"], - ): - self._side_panel_count = TabColumns.build( - panel_gap=self._geometry.panel_gap, - columns=[ - ColumnSpec( - tag=_LEFT_COLUMN_TAG, - build=self._sequencer_browser_panel.create_panel, - theme=TAG_GLOBAL_THEME_PANEL_SURFACE, - width=self._geometry.side_width, - height=self._geometry.side_height, - no_scrollbar=True, - ), - ColumnSpec( - tag=_CENTER_COLUMN_TAG, - build=self._build_center_column, - theme=TAG_GLOBAL_THEME_PANEL_GROUND, - border=False, - ), - ColumnSpec( - tag=_RIGHT_COLUMN_TAG, - build=self._build_right_column, - theme=TAG_GLOBAL_THEME_PANEL_GROUND, - width=self._instruments_width, - height=self._right_height, - border=False, - no_scrollbar=True, - ), - ], - ) - - self._sync_browser_width() - - def _build_center_column(self, parent: str) -> None: - """Stacks the order table and tracker tracker down the center column.""" - self._sequencer_order_panel.create_panel(parent) - dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) - self._sequencer_tracker_panel.create_panel(parent) - - def _build_right_column(self, parent: str) -> None: - """Stacks the module settings, samples, and history cards in the right column.""" - self._sequencer_module_panel.create_panel(parent) - dpg.add_spacer(height=self._geometry.panel_gap) - self._sequencer_voices_panel.create_panel(parent) - dpg.add_spacer(height=self._geometry.panel_gap) - self._sequencer_history_panel.create_panel(parent) - self._sync_voices_height() - - @property - def player(self) -> AudioPlayerProtocol: - return self._guarded_player - - def build_voice_actions(self) -> None: - """States the chosen voice's actions into the menu being built, for the bar's Voice group.""" - self._sequencer_voices_panel.build_voice_actions() - - @property - def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: - """The panels offering editing gestures on what they hold selected. - - The three hold one selection between them — a cursor in either grid, a row in the samples - list — so the menu bar reaches whichever one has it. - """ - return ( - self._sequencer_tracker_panel.edit_surface, - self._sequencer_order_panel.edit_surface, - self._sequencer_voices_panel, - ) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/__init__.py b/src/sampletones_application/coordinators/tabs/sequencer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py new file mode 100644 index 000000000..a7157fc4e --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py @@ -0,0 +1,144 @@ +from typing import Optional + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.clipboard import ( + OrderBlockText, + ParsedBlockCache, + ProjectSampleDirectory, + SequencerClipboard, + TrackerBlockText, +) +from sampletones_application.logic.sequencer.order import ( + OrderBlock, + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlock, + TrackerBlockReader, + TrackerBlockWriter, +) +from sampletones_application.utils.gui.clipboard import TextClipboard +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) + + +class SequencerBlocks: + """The blocks both grids copy, cut and paste, and the two clipboards they travel on. + + A copy lands in this tab's own slot and, as text, on the system clipboard, so the same block + reaches a paste here and a paste in another instance. A paste reads the system clipboard first + while what stands there is a block, which is what lets one instance hand a block to the next. + + Every gesture here reads and writes the project as it stands; recording what a gesture undoes + is the caller's, so the coordinator wraps the writing ones in a history transaction. + """ + + def __init__( + self, + tracker_logic: SequencerTrackerLogic, + order_logic: SequencerOrderLogic, + project_controller: ProjectController, + *, + text_clipboard: TextClipboard, + ) -> None: + self._clipboard: SequencerClipboard = SequencerClipboard() + self._text_clipboard: TextClipboard = text_clipboard + self._tracker_text: TrackerBlockText = TrackerBlockText( + samples=ProjectSampleDirectory(project_controller), + ) + self._order_text: OrderBlockText = OrderBlockText() + self._tracker_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_text.parse) + self._order_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_text.parse) + self._tracker_reader: TrackerBlockReader = TrackerBlockReader(tracker_logic) + self._tracker_writer: TrackerBlockWriter = TrackerBlockWriter(tracker_logic) + self._order_reader: OrderBlockReader = OrderBlockReader(order_logic) + self._order_writer: OrderBlockWriter = OrderBlockWriter(order_logic) + + def can_paste_tracker(self) -> bool: + """Whether the tracker has a block to write, which is what its Paste item is offered on.""" + return self.tracker_in_hand() is not None + + def can_paste_order(self) -> bool: + """Whether the order has a block to write, which is what its Paste item is offered on.""" + return self.order_in_hand() is not None + + def tracker_in_hand(self) -> Optional[TrackerBlock]: + """The block a tracker paste would write: the system clipboard's while its text is one. + + Text another instance copied reads as a block here, so it stands ahead of the slot the + tracker copied into, and text from anywhere else leaves that slot's own block in hand. + """ + parsed = self._tracker_cache.block(self._text_clipboard.read()) + if parsed is not None: + return parsed + + return self._clipboard.tracker_block + + def order_in_hand(self) -> Optional[OrderBlock]: + """The block an order paste would write: the system clipboard's while its text is one. + + Text another instance copied reads as a block here, so it stands ahead of the slot the + order copied into, and text from anywhere else leaves that slot's own block in hand. + """ + parsed = self._order_cache.block(self._text_clipboard.read()) + if parsed is not None: + return parsed + + return self._clipboard.order_block + + def copy_tracker(self, region: TrackerRegion) -> None: + """Puts the tracker's selected block on both clipboards, for a paste to replay. + + The slot keeps the block exactly, and the system clipboard keeps the text form of it, so + the same copy reaches a paste here and a paste in another instance. + """ + block = self._tracker_reader.read(region) + self._clipboard.store_tracker_block(block) + self._text_clipboard.write(self._tracker_text.state(block, region)) + + def cut_tracker(self, region: TrackerRegion) -> None: + """Takes the block a region covers onto the clipboard, then empties what it covered.""" + self.copy_tracker(region) + self._tracker_writer.clear(region) + + def clear_tracker(self, region: TrackerRegion) -> None: + """Empties every cell a tracker region covers, leaving the clipboards as they stand.""" + self._tracker_writer.clear(region) + + def paste_tracker(self, cell: TrackerCell) -> None: + """Writes the block the tracker has in hand at a cell, while a copy has been made.""" + block = self.tracker_in_hand() + if block is not None: + self._tracker_writer.write(block, cell) + + def copy_order(self, region: OrderRegion) -> None: + """Puts the order's selected block on both clipboards, for a paste to replay. + + The slot keeps the block exactly, and the system clipboard keeps the text form of it, so + the same copy reaches a paste here and a paste in another instance. + """ + block = self._order_reader.read(region) + self._clipboard.store_order_block(block) + self._text_clipboard.write(self._order_text.state(block, region)) + + def cut_order(self, region: OrderRegion) -> None: + """Takes the block a region covers onto the clipboard, then silences what it covered.""" + self.copy_order(region) + self._order_writer.clear(region) + + def clear_order(self, region: OrderRegion) -> None: + """Silences every cell an order region covers, leaving the clipboards as they stand.""" + self._order_writer.clear(region) + + def paste_order(self, cell: OrderCell) -> None: + """Writes the block the order has in hand at a cell, while a copy has been made.""" + block = self.order_in_hand() + if block is not None: + self._order_writer.write(block, cell) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py new file mode 100644 index 000000000..686f46146 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -0,0 +1,909 @@ +from pathlib import Path +from typing import Callable, Sequence, Tuple + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.categories.instrument import InstrumentImportMessages +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.playback import FollowMode +from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol +from sampletones_application.coordinators.export import InstrumentExportCoordinator +from sampletones_application.coordinators.original_audio import OriginalAudioLocator +from sampletones_application.coordinators.playback.guard import GuardedPlayer +from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol +from sampletones_application.coordinators.tabs.sequencer.blocks import SequencerBlocks +from sampletones_application.coordinators.tabs.sequencer.frames import SequencerFrames +from sampletones_application.coordinators.tabs.sequencer.history import SequencerHistoryRecorder +from sampletones_application.coordinators.tabs.sequencer.layout import SequencerTabLayout +from sampletones_application.coordinators.tabs.sequencer.playhead import SequencerPlayhead +from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement +from sampletones_application.coordinators.tabs.sequencer.reconstructions import SequencerReconstructions +from sampletones_application.coordinators.tabs.sequencer.voices import SequencerVoices +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic +from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic +from sampletones_application.logic.sequencer.history_detail import ( + SequencerHistoryDetail, +) +from sampletones_application.logic.sequencer.order import ( + SequencerOrderLogic, +) +from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerRegionAdjuster, +) +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic +from sampletones_application.logic.shared.tree import TreeLogic +from sampletones_application.parameters.sequencer import SequencerTabParameters +from sampletones_application.services.song_player.service import SongPlayerService +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_BROWSER_PANEL, + TAG_SEQUENCER_HISTORY_PANEL, + TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, + TAG_SEQUENCER_MODULE_PANEL, + TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, + TAG_SEQUENCER_TRACKER_PANEL, + TAG_SEQUENCER_VOICES_PANEL, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel +from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.voices.panel import ( + GUISequencerVoicesPanel, +) +from sampletones_application.utils.gui.clipboard import SystemTextClipboard +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.sequencer.channels import ( + SequencerChannelsViewModel, +) +from sampletones_application.view_model.sequencer.settings import ( + SequencerSettingsViewModel, +) +from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, +) +from sampletones_application.view_model.shared.history import ( + HistoryDetail, +) +from sampletones_core.audio import AudioDeviceManager +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures.tree import FileSystemNode +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import StringCallback, VoidCallback + + +class SequencerTabCoordinator: + def __init__( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + audio_device_manager: AudioDeviceManager, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + browser_manager: BrowserManager, + project_controller: ProjectController, + history: HistoryManager, + original_audio_locator: OriginalAudioLocator, + instrument_exports: InstrumentExportCoordinator, + *, + tab_active: ActivePredicate, + layout: SequencerTabParameters, + language_manager: LanguageManager, + dialogs: DialogsRenderer, + status_bar: GUIStatusBar, + on_edit_voice_requested: StringCallback, + on_favorite_changed: Callable[[FileSystemNode], None], + on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None], + on_tab_switch: Callable[[Tab], None], + on_nes_frequency_changed: Callable[[int], None], + on_channels_changed: VoidCallback, + ) -> None: + self._project_controller = project_controller + self._session_manager = session_manager + self._history = history + self._original_audio_locator = original_audio_locator + self._instrument_exports = instrument_exports + self._on_edit_voice_requested = on_edit_voice_requested + self._on_favorite_changed = on_favorite_changed + self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced + self._on_tab_switch = on_tab_switch + self._on_nes_frequency_changed = on_nes_frequency_changed + self._on_channels_changed = on_channels_changed + self._language_manager = language_manager + self._dialogs = dialogs + + self._nes_frequency_change_acknowledged: bool = False + + self._sequencer_browser_logic: SequencerBrowserLogic = SequencerBrowserLogic( + config_manager, + browser_manager, + project_controller, + ) + self._sequencer_tree_logic: TreeLogic = TreeLogic( + session_manager, + audio_device_manager, + scheduling=layout.scheduling, + ) + self._sequencer_browser_panel: GUISequencerBrowserPanel = GUISequencerBrowserPanel( + self._sequencer_browser_logic.tree, + self._sequencer_tree_logic, + scheduling=layout.scheduling, + language_manager=language_manager, + status_bar=status_bar, + colors=layout.tree_colors, + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), + initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL), + ) + self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) + self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) + self._blocks: SequencerBlocks = SequencerBlocks( + self._sequencer_tracker_logic, + self._sequencer_order_logic, + project_controller, + text_clipboard=SystemTextClipboard(), + ) + self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) + self._sequencer_voices_logic: SequencerVoicesLogic = SequencerVoicesLogic( + project_controller, + session_manager, + audio_device_manager, + scheduling=layout.scheduling, + ) + self._sequencer_channels_logic: SequencerChannelsLogic = SequencerChannelsLogic() + self._song_player_logic: SongPlayerLogic = SongPlayerLogic( + audio_device_manager, + project_controller, + session_manager, + service=SongPlayerService( + audio_device_manager, + RowSynthesizer( + project_controller, + config_manager.config, + active_channels=lambda: self._sequencer_channels_logic.active_channels, + sample_rate=lambda: audio_device_manager.sample_rate, + ), + should_loop=lambda: session_manager.loop_song, + master_gain=lambda: session_manager.master_gain, + ), + ) + self._guarded_player = GuardedPlayer( + self._song_player_logic, + dialogs=dialogs, + error_message=language_manager["global.player.message.audio_playback_error"], + ) + self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( + self._sequencer_tracker_logic.settings, + layout=layout.sequencer, + channel_colors=layout.channel_colors, + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL), + initial_octave=session_manager.octave, + language_manager=language_manager, + key_router=key_router, + tab_active=tab_active, + shortcut_source=shortcut_source, + ) + self._sequencer_module_panel: GUISequencerModulePanel = GUISequencerModulePanel( + self._sequencer_tracker_logic.settings, + layout=layout.sequencer, + inputs=layout.inputs, + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_MODULE_PANEL), + language_manager=language_manager, + status_bar=status_bar, + ) + self._sequencer_order_panel: GUISequencerOrderPanel = GUISequencerOrderPanel( + layout=layout.sequencer, + channel_colors=layout.channel_colors, + plus_minus_layout=layout.plus_minus, + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD), + language_manager=language_manager, + key_router=key_router, + tab_active=tab_active, + shortcut_source=shortcut_source, + ) + self._sequencer_voices_panel: GUISequencerVoicesPanel = GUISequencerVoicesPanel( + layout=layout.sequencer, + detail_color=layout.muted_color, + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_VOICES_PANEL), + language_manager=language_manager, + status_bar=status_bar, + key_router=key_router, + tab_active=tab_active, + shortcut_source=shortcut_source, + ) + self._sequencer_history_panel: GUISequencerHistoryPanel = GUISequencerHistoryPanel( + layout=layout.sequencer, + feature_colors=layout.feature_colors, + initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_HISTORY_PANEL), + language_manager=language_manager, + status_bar=status_bar, + ) + self._layout: SequencerTabLayout = SequencerTabLayout( + self._sequencer_browser_panel, + self._sequencer_order_panel, + self._sequencer_tracker_panel, + self._sequencer_module_panel, + self._sequencer_voices_panel, + self._sequencer_history_panel, + layout=layout, + session_manager=session_manager, + language_manager=language_manager, + ) + self._playhead: SequencerPlayhead = SequencerPlayhead( + self._sequencer_tracker_panel, + self._sequencer_order_panel, + ) + self._frames: SequencerFrames = SequencerFrames( + self._sequencer_order_logic, + self._sequencer_tracker_logic, + self._song_player_logic, + project_controller, + self._playhead, + ) + self._open_project: OpenProjectRequirement = OpenProjectRequirement( + project_controller, + dialogs=dialogs, + language_manager=language_manager, + ) + self._history_detail: SequencerHistoryDetail = SequencerHistoryDetail( + self._sequencer_tracker_logic, + self._sequencer_voices_logic, + ) + self._voices: SequencerVoices = SequencerVoices( + self._sequencer_voices_logic, + history, + self._history_detail, + project_controller, + session_manager, + self._open_project, + dialogs=dialogs, + language_manager=language_manager, + import_messages=InstrumentImportMessages.build(language_manager), + import_reconstruction=self.import_reconstruction, + ) + self._reconstructions: SequencerReconstructions = SequencerReconstructions( + self._sequencer_browser_logic, + self._sequencer_tracker_logic, + self._sequencer_voices_logic, + self._sequencer_voices_panel, + history, + self._history_detail, + project_controller, + self._open_project, + dialogs=dialogs, + language_manager=language_manager, + on_tab_switch=on_tab_switch, + on_sample_reconstruction_replaced=on_sample_reconstruction_replaced, + ) + self._recorder: SequencerHistoryRecorder = SequencerHistoryRecorder( + history, + project_controller, + self._sequencer_tracker_logic, + language_manager=language_manager, + ) + + self._wire_callbacks() + + def _wire_callbacks(self) -> None: + """Connects every panel and logic object this tab owns to the handler that serves it.""" + self._wire_collapse_handlers() + self._wire_module_callbacks() + self._wire_tracker_callbacks() + self._wire_channels_callbacks() + self._wire_order_callbacks() + self._wire_block_callbacks() + self._wire_voices_callbacks() + self._wire_browser_callbacks() + self._wire_playback_callbacks() + self._wire_project_callbacks() + self._wire_history() + + def _wire_collapse_handlers(self) -> None: + for panel in ( + self._sequencer_order_panel, + self._sequencer_tracker_panel, + self._sequencer_module_panel, + self._sequencer_voices_panel, + self._sequencer_history_panel, + ): + panel.set_collapse_handler(self._layout.on_card_collapse_changed) + + def _wire_module_callbacks(self) -> None: + self._sequencer_module_panel.on_nes_frequency = self._request_nes_frequency_change + self._sequencer_module_panel.on_rows_per_pattern = self._recorder.undoable( + HistoryAction.SET_ROWS_PER_PATTERN, + self._sequencer_tracker_logic.set_rows_per_pattern, + detail=self._history_detail.value, + coalesce=self._recorder.module_setting_key, + ) + self._sequencer_module_panel.on_tempo = self._recorder.undoable( + HistoryAction.SET_TEMPO, + self._sequencer_tracker_logic.set_tempo, + detail=self._history_detail.value, + coalesce=self._recorder.module_setting_key, + ) + self._sequencer_module_panel.on_speed = self._recorder.undoable( + HistoryAction.SET_SPEED, + self._sequencer_tracker_logic.set_speed, + detail=self._history_detail.value, + coalesce=self._recorder.module_setting_key, + ) + + def _wire_tracker_callbacks(self) -> None: + self._sequencer_tracker_panel.on_clear_row = self._recorder.undoable( + HistoryAction.CLEAR_ROW, + self._sequencer_tracker_logic.clear_cell, + detail=self._history_detail.clear_row, + ) + self._sequencer_tracker_panel.on_clear_subcolumn = self._recorder.undoable( + HistoryAction.CLEAR_SUBCOLUMN, + self._sequencer_tracker_logic.clear_cell_subcolumn, + detail=self._history_detail.clear_subcolumn, + ) + self._sequencer_tracker_panel.on_set_row = self._recorder.undoable( + HistoryAction.EDIT_ROW, + self._sequencer_tracker_logic.write_cell, + detail=self._history_detail.edit_row, + coalesce=self._recorder.edit_row_key, + ) + self._sequencer_tracker_panel.on_set_note_off = self._recorder.undoable( + HistoryAction.NOTE_OFF, + self._sequencer_tracker_logic.cut_note, + detail=self._history_detail.note_off, + coalesce=self._recorder.cell_key, + ) + self._sequencer_tracker_panel.on_note_typed = self._recorder.undoable( + HistoryAction.EDIT_ROW, + self._sequencer_tracker_logic.write_note, + detail=self._history_detail.note_typed, + coalesce=self._recorder.note_key, + ) + self._sequencer_tracker_panel.on_octave_changed = self._session_manager.set_octave + self._sequencer_tracker_panel.on_cell_selected = self._on_tracker_cell_focused + self._sequencer_tracker_panel.on_play_from_row = self._on_tracker_play_from_row + self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame + self._sequencer_tracker_panel.on_adjust_transpose = self._recorder.undoable( + HistoryAction.ADJUST_TRANSPOSE, + self._tracker_region_adjuster.adjust_transpose, + detail=self._history_detail.adjust_transpose, + coalesce=self._recorder.adjustment_key, + ) + self._sequencer_tracker_panel.on_adjust_volume = self._recorder.undoable( + HistoryAction.ADJUST_VOLUME, + self._tracker_region_adjuster.adjust_volume, + detail=self._history_detail.adjust_volume, + coalesce=self._recorder.adjustment_key, + ) + self._sequencer_tracker_logic.on_settings_changed = self._on_settings_changed + self._sequencer_tracker_logic.on_tracker_changed = self._sequencer_tracker_panel.update_tracker + self._sequencer_tracker_logic.on_frame_changed = self._sequencer_order_panel.select_position + + def _wire_channels_callbacks(self) -> None: + """Connects the tracker's column headers and the order table's row labels to the mute set + the song player reads. + + Both tables name the same channels and switch the same set, so each panel's hooks reach the + channels logic directly and both show every change. Muting is a monitoring gesture, so these + hooks record no history entry. + """ + self._sequencer_channels_logic.on_channels_changed = self._show_channels + for panel in (self._sequencer_tracker_panel, self._sequencer_order_panel): + panel.on_channel_mute_toggled = self._sequencer_channels_logic.toggle + panel.on_channel_soloed = self._sequencer_channels_logic.solo + panel.on_channels_toggled = self._sequencer_channels_logic.toggle_all + panel.on_channels_muted = self._sequencer_channels_logic.mute_all + panel.on_channels_unmuted = self._sequencer_channels_logic.unmute_all + + def _show_channels(self, view_model: SequencerChannelsViewModel) -> None: + """Shows the mute set in both tables and in the menu bar, so a channel reads the same + wherever it appears. + + The menu bar sits above this tab and rebuilds its own state, so it is handed the change + as a signal and reads the mute set back through :attr:`channels`. + """ + self._sequencer_tracker_panel.update_channels(view_model) + self._sequencer_order_panel.update_channels(view_model) + self._on_channels_changed() + + @property + def channels(self) -> SequencerChannelsViewModel: + """The mute set the tables show, for the menu bar that lists the same channels.""" + return self._sequencer_channels_logic.build_channels() + + def toggle_channel(self, channel: ChannelName) -> None: + """Flips one channel between audible and silent, the menu's per-channel gesture.""" + self._sequencer_channels_logic.toggle(channel) + + def unmute_all_channels(self) -> None: + """Returns every channel to audible, the menu's whole-mix gesture.""" + self._sequencer_channels_logic.unmute_all() + + def set_follow_mode(self, mode: FollowMode) -> None: + """Chooses how far the view chases the playhead, the menu's and keyboard's gesture. + + The player holds the setting and emits a view as it changes, which is what settles the + grid's following and the menu's mark together. + """ + self._song_player_logic.set_follow_mode(mode) + + def _wire_order_callbacks(self) -> None: + self._sequencer_order_logic.on_order_changed = self._sequencer_order_panel.update_order + self._sequencer_order_panel.on_frame_selected = self._frames.select + self._sequencer_order_panel.on_remove_requested = self._recorder.undoable( + HistoryAction.REMOVE_FRAME, + self._frames.remove, + detail=self._history_detail.remove_frame, + ) + self._sequencer_order_panel.on_duplicate_requested = self._recorder.undoable( + HistoryAction.DUPLICATE_FRAME, + self._frames.duplicate, + detail=self._history_detail.copy_frame, + ) + self._sequencer_order_panel.on_clone_requested = self._recorder.undoable( + HistoryAction.CLONE_FRAME, + self._frames.clone, + detail=self._history_detail.copy_frame, + ) + self._sequencer_order_panel.on_insert_requested = self._recorder.undoable( + HistoryAction.ADD_FRAME, + self._frames.insert, + detail=self._history_detail.add_frame, + ) + self._sequencer_order_panel.on_clear_requested = self._recorder.undoable( + HistoryAction.CLEAR_FRAME, + self._frames.clear, + detail=self._history_detail.clear_frame, + ) + self._sequencer_order_panel.on_play_from_requested = self._frames.play_from + self._sequencer_order_panel.on_move_requested = self._recorder.undoable( + HistoryAction.MOVE_FRAME, + self._frames.move, + detail=self._history_detail.move_frame, + ) + self._sequencer_order_panel.on_set_order_entry = self._recorder.undoable( + HistoryAction.SET_ORDER_ENTRY, + self._sequencer_order_logic.set_order_entry, + detail=self._history_detail.set_order_entry, + ) + self._sequencer_order_panel.on_set_master_entry = self._recorder.undoable( + HistoryAction.SET_ORDER_ENTRY, + self._sequencer_order_logic.set_master_entry, + detail=self._history_detail.set_master_entry, + ) + self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused + + def _wire_block_callbacks(self) -> None: + """Connects the grids' block gestures to the clipboard they copy into. + + A copy reads the project and leaves it as it stands, so it is wired straight through + instead of through :meth:`_undoable`: a transaction over it would record an entry the + history has nothing to restore for. The three gestures that do write are whole ones, each + recording the single entry that takes the grid back to where it stood. + + Each grid also asks whether its own slot holds a block, which is what a menu offering + Paste consults before it is opened. + """ + self._sequencer_tracker_panel.can_paste_block = self._blocks.can_paste_tracker + self._sequencer_order_panel.can_paste_block = self._blocks.can_paste_order + self._sequencer_tracker_panel.on_copy_block = self._blocks.copy_tracker + self._sequencer_tracker_panel.on_cut_block = self._recorder.undoable( + HistoryAction.CUT_BLOCK, + self._blocks.cut_tracker, + detail=self._history_detail.tracker_block, + ) + self._sequencer_tracker_panel.on_delete_block = self._recorder.undoable( + HistoryAction.DELETE_BLOCK, + self._blocks.clear_tracker, + detail=self._history_detail.tracker_block, + ) + self._sequencer_tracker_panel.on_paste_block = self._recorder.undoable( + HistoryAction.PASTE_BLOCK, + self._blocks.paste_tracker, + detail=self._history_detail.tracker_paste, + ) + self._sequencer_order_panel.on_copy_block = self._blocks.copy_order + self._sequencer_order_panel.on_cut_block = self._recorder.undoable( + HistoryAction.CUT_BLOCK, + self._blocks.cut_order, + detail=self._history_detail.order_block, + ) + self._sequencer_order_panel.on_delete_block = self._recorder.undoable( + HistoryAction.DELETE_BLOCK, + self._blocks.clear_order, + detail=self._history_detail.order_block, + ) + self._sequencer_order_panel.on_paste_block = self._recorder.undoable( + HistoryAction.PASTE_BLOCK, + self._blocks.paste_order, + detail=self._history_detail.order_paste, + ) + + def _wire_voices_callbacks(self) -> None: + self._sequencer_voices_logic.on_voices_changed = self._on_voices_changed + self._sequencer_voices_logic.on_edit_voice_requested = self._dispatch_edit_voice + self._sequencer_voices_logic.on_autoplay_error = self._on_preview_error + self._sequencer_voices_panel.voice_footprint = self._sequencer_voices_logic.build_voice_footprint + self._sequencer_voices_panel.on_voice_selected = self._on_voice_selected + self._sequencer_voices_panel.on_voice_edit_requested = self._sequencer_voices_logic.request_edit + self._sequencer_voices_panel.on_remove_requested = self._voices.remove + self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice + self._sequencer_voices_panel.on_move_requested = self._recorder.undoable( + HistoryAction.MOVE_VOICE, + self._sequencer_voices_logic.move_voice, + detail=self._history_detail.move_voice, + ) + self._sequencer_voices_panel.on_rename_committed = self._voices.submit_rename + self._sequencer_voices_panel.on_duplicate_requested = self._recorder.undoable( + HistoryAction.DUPLICATE_VOICE, + self._sequencer_voices_logic.duplicate_voice, + detail=self._history_detail.duplicate_voice, + ) + self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument + self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file + self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument + self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments + self._sequencer_voices_panel.on_export_instrument_requested = self._instrument_exports.request_voice + self._sequencer_voices_panel.instrument_channels = self._sequencer_voices_logic.instrument_channels + self._sequencer_voices_panel.on_instrument_from_channel_requested = self.add_instrument_from_channel + + def _wire_browser_callbacks(self) -> None: + self._sequencer_browser_panel.set_collapse_handler(self._layout.on_browser_collapse_changed) + self._sequencer_browser_panel.on_favorites_filter_changed = self._layout.on_browser_favorites_filter_changed + self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction + self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open + self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction + self._sequencer_browser_panel.replace_in_sequencer_label = self._reconstructions.replace_target_label + self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate + self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree + self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled + self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed + self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility + self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error + + def _wire_playback_callbacks(self) -> None: + self._song_player_logic.on_position_changed = self._on_player_position_changed + self._song_player_logic.on_view_changed = self._on_player_view_changed + self._song_player_logic.on_error = self._on_player_error + + def _wire_project_callbacks(self) -> None: + self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings + self._project_controller.on_song_changed = self._on_song_changed + self._project_controller.on_voices_changed = self._sequencer_voices_logic.push_voices + self._project_controller.on_project_replaced = self._on_project_replaced + + def _wire_history(self) -> None: + self._sequencer_history_panel.on_undo = self.undo + self._sequencer_history_panel.on_redo = self.redo + self._sequencer_history_panel.on_jump_to = self.jump_to_history + + def _on_settings_changed( + self, + view_model: SequencerSettingsViewModel, + ) -> None: + """Hands the project's song settings to the two panels that read them. + + The module panel shows the timing fields themselves; the tracker reads the meter out of + the same view model, so a highlight edited in the project properties retints the grid as + soon as the dialog commits. + """ + self._sequencer_module_panel.update_settings(view_model) + self._sequencer_tracker_panel.update_settings(view_model) + + def _on_project_replaced(self) -> None: + """Realigns the tab with a replaced project, keeping the mute set across history navigation. + + Undo, redo, and history jumps replace the project as well, and the history manager reports + itself restoring throughout, so the channels the user is listening through carry across + them. A new, opened, or closed document begins a fresh listening session instead, with + every channel audible. + """ + if not self._history.is_restoring: + self._sequencer_channels_logic.reset() + + self._history.reset() + self.refresh() + + def play_from_current_frame(self) -> None: + """Plays from the frame the tracker is showing, seeking in place when already playing.""" + self._frames.play_from(self._sequencer_tracker_logic.frame_index) + + def add_instrument(self) -> None: + """Appends a hand-written voice to the pool, the menu bar's entry to the gesture.""" + self._voices.add_instrument() + + def add_instrument_from_channel( + self, + voice_id: str, + channel_name: ChannelName, + ) -> None: + """Takes what one channel of a voice plays as an instrument of its own, then opens it. + + Args: + voice_id: The voice the channel belongs to. + channel_name: The channel whose envelopes the instrument takes. + """ + self._voices.add_instrument_from_channel(voice_id, channel_name) + + def add_sample_from_file(self) -> None: + """Brings a reconstruction saved anywhere on disk into the pool as a sample.""" + self._voices.add_sample_from_file() + + def import_instrument(self) -> None: + """Brings a FamiTracker instrument file into the pool as an instrument voice.""" + self._voices.import_instrument() + + def undo(self) -> None: + self._history.undo() + + def redo(self) -> None: + self._history.redo() + + def jump_to_history(self, index: int) -> None: + self._history.jump_to(index) + + def refresh_history(self) -> None: + """Re-renders the history panel from the manager's current stack. + + Called by the application's history fan-out, which owns the manager's + single ``on_history_changed`` slot and forwards each change here and to + the menu bar. + """ + self._sequencer_history_panel.update_view(self._recorder.view_model()) + + def reconstruction_edit_detail( + self, + voice_id: str, + channel_name: ChannelName, + feature_key: FeatureKey, + ) -> HistoryDetail: + """Describes a reconstruction edit for the project history's detail line.""" + return self._history_detail.edit_reconstruction( + voice_id, + channel_name, + feature_key, + ) + + def instrument_edit_detail( + self, + voice_id: str, + feature_key: FeatureKey, + ) -> HistoryDetail: + """Describes a hand-written voice's edited dimension for the project history.""" + return self._history_detail.edit_instrument(voice_id, feature_key) + + def reconstruction_stem_detail( + self, + voice_id: str, + stem_name: str, + ) -> HistoryDetail: + """Describes a recording taken out of a reconstruction for the project history.""" + return self._history_detail.remove_stem(voice_id, stem_name) + + def initialize(self) -> None: + """Pushes the current project into every sequencer panel. + + Called once after the GUI is built so the panels reflect the project the + application started with (or restored). + """ + self._song_player_logic.refresh_view() + self.refresh() + + def refresh(self) -> None: + self._nes_frequency_change_acknowledged = False + self._song_player_logic.stop() + self._sequencer_tracker_logic.refresh() + self._sequencer_order_logic.refresh() + self._sequencer_voices_logic.push_voices() + self._sequencer_channels_logic.push_channels() + is_open = self._project_controller.is_open + self._sequencer_module_panel.set_enabled(is_open) + self._sequencer_tracker_panel.set_enabled(is_open) + self._sequencer_order_panel.set_enabled(is_open) + self._sequencer_history_panel.set_enabled(is_open) + + def repaint(self) -> None: + """Draws every table again so its tints take the palette now in place. + + DearPyGui keeps a table's row, column and cell tints as state of the table rather than + as a property of an item, so they take a new color by being issued again. Each panel + answers for the tints it owns, and this is where the palette asks all three. + """ + self._sequencer_tracker_panel.repaint() + self._sequencer_order_panel.repaint() + self._sequencer_voices_panel.repaint() + + def refresh_browser(self) -> None: + self._sequencer_browser_panel.refresh() + + def save_browser_shape(self) -> None: + """Writes down the rows the browser stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._sequencer_browser_panel.tag, + self._sequencer_browser_panel.expanded_rows, + ) + + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: + self._sequencer_browser_panel.update_favorite_indicators(nodes) + + def _on_song_changed(self) -> None: + self._sequencer_tracker_logic.push_settings() + self._sequencer_tracker_logic.push_tracker() + self._sequencer_order_logic.push_order() + + def _on_player_error(self, error: Exception) -> None: + self._dialogs.show_error(error) + + def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: + """Settles the marks the transport owns, and how far the grid chases the playhead. + + The player emits a view on every position update and on every change to the setting, so + reading the follow behavior here keeps the grid in step both while a song sounds and the + moment the reader picks another mode. + """ + self._sequencer_tracker_panel.set_row_following(view_model.follow_mode.follows_row) + if not view_model.is_playing and not view_model.is_paused: + self._playhead.stop() + + def _on_player_position_changed( + self, + order_position: int, + row_index: int, + ) -> None: + """Moves the marks the playhead carries, showing the frame it sounds when following. + + The frame is selected ahead of the marks so the row's mark, and the scroll that reveals it, + land on the pattern the playhead has reached. + """ + self._playhead.stand_at(order_position, row_index) + if self._song_player_logic.follow_mode.follows_pattern: + self._sequencer_tracker_logic.select_frame(order_position) + + self._playhead.mark() + + def _on_preview_error(self, exception: Exception) -> None: + FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) + + def _is_project_open(self) -> bool: + return self._project_controller.is_open + + def import_reconstruction(self, filepath: Path) -> None: + """Brings a reconstruction file into the pool as a sample.""" + self._reconstructions.import_from_file(filepath) + + def import_reconstruction_object(self, reconstruction: Reconstruction, name: str) -> None: + """Adds an in-memory reconstruction — the one open in the Reconstruction tab — as a sample.""" + self._reconstructions.import_object(reconstruction, name) + + def replace_reconstruction(self, filepath: Path) -> None: + """Substitutes the selected sample's reconstruction with a browser file's.""" + self._reconstructions.replace_from_file(filepath) + + def _dispatch_edit_voice(self, voice_id: str) -> None: + self._on_edit_voice_requested(voice_id) + + def _on_tracker_play_from_row(self, row_index: int) -> None: + """Starts playback from the right-clicked row of the frame the tracker is showing.""" + self._song_player_logic.play_from( + self._sequencer_tracker_logic.frame_index, + row_index, + ) + + def _on_voices_changed( + self, + view_model: SequencerVoicesViewModel, + ) -> None: + self._sequencer_voices_panel.update_view(view_model) + self._sequencer_tracker_panel.update_samples(view_model) + + def _on_voice_selected(self, voice_id: str) -> None: + self._sequencer_tracker_panel.deselect_cell() + self._sequencer_order_panel.deselect_cell() + self._sequencer_voices_logic.request_autoplay(voice_id) + logger.debug(f"Sequencer sample selected: {voice_id}") + + def _request_nes_frequency_change(self, nes_frequency: int) -> None: + """Applies a NES-frequency change, confirming first when it would re-time existing samples. + + The rate governs how every sample plays back, so changing it on a project that already + holds samples prompts once (until acknowledged for the session); an empty or acknowledged + project applies silently. Cancelling restores the field to the project's current value. + """ + if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency: + return + + if self._nes_frequency_change_acknowledged or not self._project_controller.has_voices: + self._perform_nes_frequency_change(nes_frequency) + return + + self._dialogs.show_confirmation( + tag=TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, + title=self._language_manager["global.dialog.title.change_nes_frequency"], + message=self._language_manager["global.dialog.message.change_nes_frequency"], + on_confirm=lambda: self._perform_nes_frequency_change(nes_frequency), + ok_label=self._language_manager["global.dialog.label.change_and_retune"], + opt_out_label=self._language_manager["global.dialog.label.dont_ask_again"], + on_opt_out=self._acknowledge_nes_frequency_changes, + on_cancel=self._sequencer_tracker_logic.push_settings, + ) + + def _perform_nes_frequency_change(self, nes_frequency: int) -> None: + """Applies the rate as one undo entry, then requests a retune of the now-stale samples. + + The entry carries a rate-keyed coalesce target so the asynchronous per-sample retune + results fold back into this same ``SET_NES_FREQUENCY`` entry: one undo restores both the + prior rate and the prior reconstructions, and a later change to a different rate appends + a fresh entry. + """ + with self._history.transaction( + HistoryAction.SET_NES_FREQUENCY, + detail=self._history_detail.value(nes_frequency), + coalesce=(nes_frequency,), + ): + self._sequencer_tracker_logic.set_nes_frequency(nes_frequency) + + self._on_nes_frequency_changed(nes_frequency) + + def nes_frequency_detail(self, nes_frequency: int) -> HistoryDetail: + """The history detail for a NES-frequency change, so the retune can reuse its undo entry.""" + return self._history_detail.value(nes_frequency) + + def _acknowledge_nes_frequency_changes(self) -> None: + self._nes_frequency_change_acknowledged = True + + def _on_tracker_cell_focused(self) -> None: + """Drops the order cursor and sample selection when the tracker tracker takes focus. + + The tracker, order, and samples panels each register a key-router scope active only while + it holds a selection; keeping a single selection across the three lets only the focused + panel consume keystrokes. + """ + self._sequencer_order_panel.deselect_cell() + self._sequencer_voices_panel.deselect() + + def _on_order_cell_focused(self) -> None: + """Drops the tracker cursor and sample selection when the order tracker takes focus.""" + self._sequencer_tracker_panel.deselect_cell() + self._sequencer_voices_panel.deselect() + + def create_tab(self) -> None: + """Builds this tab, which the layout holds and refits from here on.""" + self._layout.create_tab() + + def sync_responsive_layout(self) -> None: + """Refits this tab's columns to the current viewport, the entry the resize handler calls.""" + self._layout.sync_responsive_layout() + + @property + def player(self) -> AudioPlayerProtocol: + return self._guarded_player + + def build_voice_actions(self) -> None: + """States the chosen voice's actions into the menu being built, for the bar's Voice group.""" + self._sequencer_voices_panel.build_voice_actions() + + @property + def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: + """The panels offering editing gestures on what they hold selected. + + The three hold one selection between them — a cursor in either grid, a row in the samples + list — so the menu bar reaches whichever one has it. + """ + return ( + self._sequencer_tracker_panel.edit_surface, + self._sequencer_order_panel.edit_surface, + self._sequencer_voices_panel, + ) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/frames.py b/src/sampletones_application/coordinators/tabs/sequencer/frames.py new file mode 100644 index 000000000..5a6b74a1b --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/frames.py @@ -0,0 +1,144 @@ +from typing import Callable + +from sampletones_application.coordinators.tabs.sequencer.playhead import SequencerPlayhead +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.order import SequencerOrderLogic +from sampletones_application.logic.sequencer.playback.playhead import ( + remap_after_insert, + remap_after_move, + remap_after_remove, +) +from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic +from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic + + +class SequencerFrames: + """The gestures that reshape the order, each carrying the playhead over what it moved. + + Adding, removing and moving a frame shifts every frame after it, so a playhead standing on + one of them follows it to where it went, and the grid moves to the frame the reader is now + working on. A gesture that leaves the order's shape alone leaves the playhead alone too. + """ + + def __init__( + self, + order_logic: SequencerOrderLogic, + tracker_logic: SequencerTrackerLogic, + song_player_logic: SongPlayerLogic, + project_controller: ProjectController, + playhead: SequencerPlayhead, + ) -> None: + self._order_logic = order_logic + self._tracker_logic = tracker_logic + self._song_player_logic = song_player_logic + self._project_controller = project_controller + self._playhead = playhead + + def remove(self, position: int) -> None: + """Takes a frame out of the order, carrying the playhead over the frames that close up.""" + length_before = self._project_controller.order_length + self._order_logic.remove_from_order(position) + self._relocate_playhead( + lambda playhead: remap_after_remove( + playhead, + position, + length_before - 1, + ) + ) + + def duplicate(self, position: int) -> None: + """Puts a copy of a frame after it, sharing the patterns the original names.""" + self._order_logic.duplicate_frame(position) + self._settle_inserted_frame(position + 1) + + def clone(self, position: int) -> None: + """Puts a copy of a frame after it, over patterns of its own.""" + self._order_logic.clone_frame(position) + self._settle_inserted_frame(position + 1) + + def insert(self, position: int) -> None: + """Puts an empty frame after a frame, and moves to it.""" + self._order_logic.insert_frame(position + 1) + self._relocate_playhead( + lambda playhead: remap_after_insert( + playhead, + position + 1, + ) + ) + self._select_frame_when_idle(position + 1) + + def clear(self, position: int) -> None: + """Clears every channel in the frame; no index shift, so the playhead is left in place. + + A sounding voice keeps ringing across the now-empty frame (only an explicit note-off cuts it). + """ + self._order_logic.clear_frame(position) + + def move(self, from_position: int, to_position: int) -> None: + """Moves a frame to another place in the order, and shows it where it landed.""" + self._order_logic.move_frame(from_position, to_position) + self._relocate_playhead( + lambda playhead: remap_after_move( + playhead, + from_position, + to_position, + ) + ) + self._tracker_logic.select_frame(to_position) + + def play_from(self, position: int) -> None: + """Plays from a frame: relocates the playhead when already playing, else starts there.""" + if self._song_player_logic.is_playing(): + self._song_player_logic.seek(position) + else: + self._song_player_logic.play_from(position) + + def select(self, frame_index: int) -> None: + """Selects an order frame in the tracker, and moves the playhead too when following. + + While the view follows the playhead, choosing another order during playback relocates the + playhead to it (the seek no-ops when stopped); otherwise the selection only changes which + pattern is edited, leaving playback where it is. + """ + self._tracker_logic.select_frame(frame_index) + if self._song_player_logic.follow_mode.follows_pattern: + self._song_player_logic.seek(frame_index) + + def _settle_inserted_frame(self, position: int) -> None: + """Carries the playhead and the shown frame over a frame that has just been inserted. + + A frame arriving at ``position`` pushes every later frame one along, so a playhead + standing on one of them follows it, and the grid moves to the new frame for the reader + to work on. + """ + self._relocate_playhead( + lambda playhead: remap_after_insert( + playhead, + position, + ) + ) + self._select_frame_when_idle(position) + + def _relocate_playhead(self, remap: Callable[[int], int]) -> None: + """Keeps the live playhead on the frame it was sounding after a structural order edit. + + Both grids take the new position straight away, ahead of the worker's next row update, so + rapid edits (e.g. a held Alt+arrow) stay in step, and a paused playhead — which reports no + further rows — is marked on the frame the edit moved it to. + """ + position = self._playhead.position + if position is None: + return + + order_position = remap(position.order_position) + if order_position == position.order_position: + return + + self._playhead.stand_at(order_position, position.row_index) + self._song_player_logic.relocate(order_position) + self._playhead.mark() + + def _select_frame_when_idle(self, frame_index: int) -> None: + """Moves the editor selection to a frame, unless playback is actively driving it.""" + if not self._song_player_logic.is_playing(): + self._tracker_logic.select_frame(frame_index) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/history.py b/src/sampletones_application/coordinators/tabs/sequencer/history.py new file mode 100644 index 000000000..587c0290e --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/history.py @@ -0,0 +1,174 @@ +from typing import Callable, Optional, ParamSpec + +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.history.transaction import CoalesceKey +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.history import ( + HistoryEntryViewModel, + HistoryViewModel, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.shared.history import HistoryDetail +from sampletones_core.constants.enums import ChannelName + +_GestureParams = ParamSpec("_GestureParams") + + +class SequencerHistoryRecorder: + """The path every sequencer gesture takes into the project history. + + A gesture is handed here as the callback the panel fires, and comes back wrapped in the + transaction that records the single entry undoing it. What that entry says, and which target + it coalesces onto, are computed from the very arguments the gesture receives, so a hook keeps + the signature its panel calls it with. + + The panel's own view of the stack is built here too, since the labels an entry prints and the + action it was recorded under are read from the same place. + """ + + def __init__( + self, + history: HistoryManager, + project_controller: ProjectController, + tracker_logic: SequencerTrackerLogic, + *, + language_manager: LanguageManager, + ) -> None: + self._history = history + self._project_controller = project_controller + self._tracker_logic = tracker_logic + self._language_manager = language_manager + + def undoable( + self, + action: HistoryAction, + callback: Callable[_GestureParams, None], + *, + detail: Optional[Callable[_GestureParams, HistoryDetail]] = None, + coalesce: Optional[Callable[_GestureParams, CoalesceKey]] = None, + ) -> Callable[_GestureParams, None]: + """Wraps a state-changing hook so its whole gesture becomes one undo entry. + + Every mutation the wrapped callback triggers is grouped under ``action``; + a gesture that changes nothing records no entry. ``detail`` computes the + entry's colored description segments from the same arguments the hook + receives, and ``coalesce`` computes the gesture's target key from them: + consecutive gestures sharing the same action and target collapse into a + single entry. + + The gesture is batched inside its transaction, so however many rows it + writes, the panels rebuild once — and they rebuild before the entry that + undoes them is recorded, because the snapshot reads the project rather + than the views. + """ + + def wrapped( + *args: _GestureParams.args, + **kwargs: _GestureParams.kwargs, + ) -> None: + description = detail(*args, **kwargs) if detail is not None else () + key = coalesce(*args, **kwargs) if coalesce is not None else None + with ( + self._history.transaction( + action, + detail=description, + coalesce=key, + ), + self._project_controller.batch(), + ): + callback(*args, **kwargs) + + return wrapped + + def cell_key( + self, + row_index: int, + channel: Optional[ChannelName], + ) -> CoalesceKey: + """Identifies one cell of the displayed frame as a coalescing target. + + The sample column (``channel`` absent) is its own target, distinct from + every channel column. + """ + channel_key = channel if channel is not None else "" + return (self._tracker_logic.frame_index, channel_key, row_index) + + def note_key( + self, + row_index: int, + channel: ChannelName, + _pitch: int, + ) -> CoalesceKey: + """Identifies the cell a typed note landed in, so retyping one note coalesces onto it.""" + return self.cell_key(row_index, channel) + + def adjustment_key( + self, + region: TrackerRegion, + _delta: int, + ) -> CoalesceKey: + """Identifies the cells an adjustment covers as one coalescing target. + + A streak of nudges over the same block reads as one entry, so holding a transpose key steps + the selection and leaves a single step to undo; moving the cursor or reaching the selection + out starts the next one. + """ + return ( + self._tracker_logic.frame_index, + region.first_row, + region.last_row, + region.first_slot, + region.last_slot, + ) + + def edit_row_key( + self, + row_index: int, + channel: Optional[ChannelName], + voice_id: Optional[str], + transpose: Optional[int], + volume: Optional[int], + ) -> CoalesceKey: + """Extends the cell target with the subcolumns the edit writes. + + Consecutive edits of one cell coalesce only when they write the same + subcolumns, so entering a note and then tweaking its volume stay + separate entries. + """ + return ( + *self.cell_key(row_index, channel), + voice_id is not None, + transpose is not None, + volume is not None, + ) + + def module_setting_key(self, _value: int) -> CoalesceKey: + """Marks a module-wide setting as one target, shared by its whole streak.""" + return () + + def view_model(self) -> HistoryViewModel: + """The stack as the history panel draws it, each entry labeled by its action.""" + cursor = self._history.cursor + entries = tuple( + HistoryEntryViewModel( + index=index, + label=self._action_label(entry.action), + detail_segments=entry.detail, + is_current=index == cursor, + is_future=index > cursor, + ) + for index, entry in enumerate(self._history.entries) + ) + return HistoryViewModel(entries=entries, cursor=cursor) + + def _action_label(self, action: HistoryAction) -> str: + return self._language_manager[ + Page.SEQUENCER, + Panel.HISTORY, + TextType.LABEL, + action, + ] diff --git a/src/sampletones_application/coordinators/tabs/sequencer/layout.py b/src/sampletones_application/coordinators/tabs/sequencer/layout.py new file mode 100644 index 000000000..8353cf870 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/layout.py @@ -0,0 +1,190 @@ +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.parameters.sequencer import SequencerTabParameters +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_PANEL_CENTER, + SUF_PANEL_LEFT, + SUF_PANEL_RIGHT, + TAG_GLOBAL_TAB_SEQUENCER, + TAG_GLOBAL_TABS, + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_PANEL_GROUND, + TAG_GLOBAL_THEME_PANEL_SURFACE, +) +from sampletones_application.tags.sequencer import TAG_SEQUENCER_HISTORY_PANEL +from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns +from sampletones_application.ui.elements.layout.responsive import expanded_side_width +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel +from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item + +LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_LEFT) +CENTER_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_CENTER) +RIGHT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_RIGHT) + + +class SequencerTabLayout: + """The three columns this tab stands in, and how they refit as the window and cards change. + + The browser fills the left column, the two grids stack down the center, and the module, + samples and history cards stack down the right. A column keeps its share of the viewport as + the window is resized, and a card that collapses hands its space back to the card above it. + + Each collapse is written to the session as it happens, so the tab opens next launch showing + exactly the cards it was left showing. + """ + + def __init__( + self, + browser_panel: GUISequencerBrowserPanel, + order_panel: GUISequencerOrderPanel, + tracker_panel: GUISequencerTrackerPanel, + module_panel: GUISequencerModulePanel, + voices_panel: GUISequencerVoicesPanel, + history_panel: GUISequencerHistoryPanel, + *, + layout: SequencerTabParameters, + session_manager: SessionManager, + language_manager: LanguageManager, + ) -> None: + self._browser_panel = browser_panel + self._order_panel = order_panel + self._tracker_panel = tracker_panel + self._module_panel = module_panel + self._voices_panel = voices_panel + self._history_panel = history_panel + self._session_manager = session_manager + self._language_manager = language_manager + self._geometry = layout.geometry + self._side_panel_count: int + self._instruments_width = layout.right_column_width + self._right_height = layout.right_column_height + self._history_expanded_height = layout.history_height + self._history_collapsed_footprint = layout.header_bar_height + 2 * self._geometry.panel_gap + self._inter_card_gap = self._stacked_card_gap() + + def create_tab(self) -> None: + """Builds the tab and the three columns its panels are drawn into.""" + with dpg.tab( + tag=TAG_GLOBAL_TAB_SEQUENCER, + parent=TAG_GLOBAL_TABS, + label=self._language_manager["global.menu.label.tab_sequencer"], + ): + self._side_panel_count = TabColumns.build( + panel_gap=self._geometry.panel_gap, + columns=[ + ColumnSpec( + tag=LEFT_COLUMN_TAG, + build=self._browser_panel.create_panel, + theme=TAG_GLOBAL_THEME_PANEL_SURFACE, + width=self._geometry.side_width, + height=self._geometry.side_height, + no_scrollbar=True, + ), + ColumnSpec( + tag=CENTER_COLUMN_TAG, + build=self._build_center_column, + theme=TAG_GLOBAL_THEME_PANEL_GROUND, + border=False, + ), + ColumnSpec( + tag=RIGHT_COLUMN_TAG, + build=self._build_right_column, + theme=TAG_GLOBAL_THEME_PANEL_GROUND, + width=self._instruments_width, + height=self._right_height, + border=False, + no_scrollbar=True, + ), + ], + ) + + self._sync_browser_width() + + def sync_responsive_layout(self) -> None: + """Refits this tab's side column to the current viewport, the entry the resize handler calls.""" + self._sync_browser_width() + + def on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: + """Persists a card's collapsed state so it restores on the next launch.""" + self._session_manager.set_card_collapsed(card_tag, collapsed) + if card_tag == TAG_SEQUENCER_HISTORY_PANEL: + self._sync_voices_height() + + def on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None: + """Persists the browser panel's collapse, then docks or restores the width of the column it fills.""" + self._session_manager.set_card_collapsed(card_tag, collapsed) + self._sync_browser_width() + + def on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None: + """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" + self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) + + def _build_center_column(self, parent: str) -> None: + """Stacks the order table and tracker tracker down the center column.""" + self._order_panel.create_panel(parent) + dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) + self._tracker_panel.create_panel(parent) + + def _build_right_column(self, parent: str) -> None: + """Stacks the module settings, samples, and history cards in the right column.""" + self._module_panel.create_panel(parent) + dpg.add_spacer(height=self._geometry.panel_gap) + self._voices_panel.create_panel(parent) + dpg.add_spacer(height=self._geometry.panel_gap) + self._history_panel.create_panel(parent) + self._sync_voices_height() + + def _sync_browser_width(self) -> None: + """Shrinks the browser column to the collapse rail when collapsed, else sizes it to the viewport width.""" + if self._browser_panel.collapsed: + width = self._geometry.rail_width + else: + width = expanded_side_width( + self._geometry.side_width, + dpg.get_viewport_client_width(), + self._geometry.baseline_viewport_width, + self._side_panel_count, + self._geometry.center_weight, + ) + + dpg_configure_item(LEFT_COLUMN_TAG, width=width) + + def _stacked_card_gap(self) -> int: + """The rendered vertical gap between two cards stacked in the right column. + + The cards are separated by a ``panel_gap`` spacer, but DearPyGui also lays its ``ItemSpacing.y`` + on each side of that spacer, so the real gap is the spacer plus two of those spacings. The + spacing is read from the base theme, which sets it explicitly, so the gap tracks the theme's + value. + """ + spacing = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT).get_style( + dpg.mvAll, + dpg.mvStyleVar_ItemSpacing, + ) + spacing_y = int(spacing[1]) if spacing is not None else 0 + return self._geometry.panel_gap + 2 * spacing_y + + def _sync_voices_height(self) -> None: + """Reserves the bottom space the history card and its inter-card gap occupy, so samples fills the rest. + + The samples card fills the right column above the history card by reserving that footprint below + it. History carries its own height in both states — filling the reservation while expanded, pinned + to its header bar while collapsed — so this only has to size the reservation: the expanded history + height, or the collapsed bar footprint. The reservation clears the full inter-card gap (see + :meth:`_stacked_card_gap`) so the collapsed bar lands flush at the column bottom. + """ + if self._history_panel.collapsed: + footprint = self._history_collapsed_footprint + else: + footprint = self._history_expanded_height + + self._voices_panel.set_expanded_height(-(self._inter_card_gap + footprint)) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/playhead.py b/src/sampletones_application/coordinators/tabs/sequencer/playhead.py new file mode 100644 index 000000000..6c155a2c1 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/playhead.py @@ -0,0 +1,49 @@ +from typing import Optional + +from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_core.project.song_position import SongPosition + + +class SequencerPlayhead: + """Where the song is sounding, and the marks both grids carry to show it. + + The order grid marks the frame the playhead sounds; the tracker takes the whole position, + since the row it marks belongs to the pattern of that frame. Standing the playhead somewhere + and marking it are separate steps, so a caller that also moves the shown frame can do that + first and leave the marks landing on the pattern already in view. + """ + + def __init__( + self, + tracker_panel: GUISequencerTrackerPanel, + order_panel: GUISequencerOrderPanel, + ) -> None: + self._tracker_panel = tracker_panel + self._order_panel = order_panel + self._position: Optional[SongPosition] = None + + @property + def position(self) -> Optional[SongPosition]: + """The position the song is sounding, absent while nothing is.""" + return self._position + + def stand_at(self, order_position: int, row_index: int) -> None: + """Puts the playhead on a row of a frame, leaving the marks for :meth:`mark`.""" + self._position = SongPosition( + order_position=order_position, + row_index=row_index, + ) + + def stop(self) -> None: + """Takes the playhead off the song, clearing the marks it carried.""" + self._position = None + self.mark() + + def mark(self) -> None: + """Puts the playhead's marks where it stands, on both grids.""" + position = self._position + self._tracker_panel.set_playing_position(position) + self._order_panel.set_playing_position( + position.order_position if position is not None else None, + ) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/project.py b/src/sampletones_application/coordinators/tabs/sequencer/project.py new file mode 100644 index 000000000..12bc642af --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/project.py @@ -0,0 +1,37 @@ +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN +from sampletones_application.utils.gui.dialogs import DialogsRenderer + + +class OpenProjectRequirement: + """What a gesture needing somewhere to put its result asks before it starts. + + A voice, a reconstruction and an import all land in the open project, so each asks here + first. Answering that nothing is open also tells the reader so, leaving the caller with + only the decision to stop. + """ + + def __init__( + self, + project_controller: ProjectController, + *, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + ) -> None: + self._project_controller = project_controller + self._dialogs = dialogs + self._message = language_manager["global.dialog.message.no_project_open"] + self._title = language_manager["global.dialog.title.no_project_open"] + + def met(self) -> bool: + """Whether a project stands open, showing the notice when none does.""" + if self._project_controller.is_open: + return True + + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, + self._message, + self._title, + ) + return False diff --git a/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py new file mode 100644 index 000000000..905d75825 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py @@ -0,0 +1,255 @@ +from pathlib import Path +from typing import Callable, Optional + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic +from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail +from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic +from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.logger import logger + + +class SequencerReconstructions: + """How a reconstruction joins the pool, or takes the place of one already in it. + + A reconstruction renders at the NES frequency it was generated at, so every gesture here + settles that rate against the project's before it commits: equal rates go straight through, + a mismatch the project can absorb adopts the incoming rate, and one it cannot is put to the + reader with both rates named. + + The rate and what it times are settled in one history entry, so a single undo restores the + project's previous rate together with the audio it was timing. + """ + + def __init__( + self, + browser_logic: SequencerBrowserLogic, + tracker_logic: SequencerTrackerLogic, + voices_logic: SequencerVoicesLogic, + voices_panel: GUISequencerVoicesPanel, + history: HistoryManager, + history_detail: SequencerHistoryDetail, + project_controller: ProjectController, + open_project: OpenProjectRequirement, + *, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + on_tab_switch: Callable[[Tab], None], + on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None], + ) -> None: + self._browser_logic = browser_logic + self._tracker_logic = tracker_logic + self._voices_logic = voices_logic + self._voices_panel = voices_panel + self._history = history + self._history_detail = history_detail + self._project_controller = project_controller + self._open_project = open_project + self._dialogs = dialogs + self._language_manager = language_manager + self._on_tab_switch = on_tab_switch + self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced + + def import_from_file(self, filepath: Path) -> None: + """Reads a reconstruction file and brings what it holds into the pool as a sample.""" + if not self._open_project.met(): + return + + reconstruction = self._loaded(filepath) + if reconstruction is None: + return + + self._add_with_frequency_check(reconstruction, filepath.stem) + + def import_object(self, reconstruction: Reconstruction, name: str) -> None: + """Adds an in-memory reconstruction — the one open in the Reconstruction tab — as a sample. + + The sample embeds an independent copy, so the open document keeps its own source-audio + location and file backing while the project stores a self-contained, detached sample. + """ + if not self._open_project.met(): + return + + self._add_with_frequency_check( + reconstruction.model_copy(deep=True), + name, + ) + + def replace_from_file(self, filepath: Path) -> None: + """Substitutes the selected sample's reconstruction with a browser file's. + + The sample keeps its id and position, so every pattern row referencing it sounds the + incoming audio while the tracker shows it where it was, and it takes the file's name the way + an import does. The target is whatever the samples panel has selected as the gesture starts, + which is also what named the menu item the user clicked. + """ + selection = self._voices_panel.selection + if selection is None: + return + + reconstruction = self._loaded(filepath) + if reconstruction is None: + return + + self._reconcile_nes_frequency( + reconstruction, + lambda adopt_frequency: self._commit_replace( + selection.voice_id, + reconstruction, + filepath.stem, + adopt_frequency=adopt_frequency, + ), + can_adopt_frequency=self._project_controller.voice_count == 1, + ) + + def replace_target_label(self) -> Optional[str]: + """The indexed label of the sample a browser replacement would overwrite, while one is selected.""" + selection = self._voices_panel.selection + if selection is None: + return None + + return selection.label + + def _loaded(self, filepath: Path) -> Optional[Reconstruction]: + """Reads a reconstruction file, reporting one the reader cannot take. + + Returns: + Optional[Reconstruction]: What the file holds, or ``None`` once the failure has been + shown. + """ + try: + return self._browser_logic.load_reconstruction(filepath) + except (SampleToNESError, OSError) as exception: + logger.error_with_traceback( + exception, + f"Failed to load reconstruction from {filepath}", + ) + self._dialogs.show_error(exception) + + return None + + def _add_with_frequency_check( + self, + reconstruction: Reconstruction, + name: str, + ) -> None: + """Adds a loaded reconstruction once its NES frequency is settled against the project's. + + An empty project adopts the incoming frequency, since the rate times nothing there yet; a + project that already holds samples confirms first, because the rate governs how all of them + play back. + """ + self._reconcile_nes_frequency( + reconstruction, + lambda adopt_frequency: self._commit_add( + reconstruction, + name, + adopt_frequency=adopt_frequency, + ), + can_adopt_frequency=not self._project_controller.has_voices, + ) + + def _reconcile_nes_frequency( + self, + reconstruction: Reconstruction, + commit: Callable[[Optional[int]], None], + *, + can_adopt_frequency: bool, + ) -> None: + """Settles a reconstruction's NES frequency against the project's, then commits the gesture. + + A reconstruction renders at the frequency it was generated at, so bringing one recorded at + another rate into the project plays it back wrong. Equal rates commit straight away. A + mismatch the project can absorb — because the rate times nothing that outlives the gesture — + adopts the incoming rate. Otherwise the user decides, seeing both rates, and confirming + keeps the project's rate for the samples already timed by it. + + Args: + reconstruction: The reconstruction being brought into the project. + commit: Performs the gesture, receiving the frequency to adopt, or ``None`` to keep the + project's. + can_adopt_frequency: Whether adopting the incoming rate re-times only what this gesture + itself brings in, which settles a mismatch without asking. + """ + reconstruction_frequency = reconstruction.config.nes_frequency + project_frequency = self._tracker_logic.settings.nes_frequency + + if reconstruction_frequency == project_frequency: + commit(None) + return + + if can_adopt_frequency: + commit(reconstruction_frequency) + return + + self._dialogs.show_confirmation( + tag=TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY, + title=self._language_manager["global.dialog.title.frequency_mismatch"], + message=self._language_manager["global.dialog.message.frequency_mismatch"].format( + reconstruction=reconstruction_frequency, + project=project_frequency, + ), + on_confirm=lambda: commit(None), + ok_label=self._language_manager["global.dialog.label.add_anyway"], + ) + + def _commit_add( + self, + reconstruction: Reconstruction, + name: str, + *, + adopt_frequency: Optional[int], + ) -> None: + """Adds a reconstruction as one undoable gesture, optionally adopting its frequency. + + The frequency reconciliation and the sample insertion form a single history + entry, so undoing a freshly-imported sample also restores the prior rate. + """ + with self._history.transaction( + HistoryAction.ADD_SAMPLE, + detail=self._history_detail.add_sample(name), + ): + if adopt_frequency is not None: + self._tracker_logic.set_nes_frequency(adopt_frequency) + self._browser_logic.add_reconstruction(reconstruction, name) + + self._on_tab_switch(Tab.SEQUENCER) + + def _commit_replace( + self, + voice_id: str, + reconstruction: Reconstruction, + name: str, + *, + adopt_frequency: Optional[int], + ) -> None: + """Substitutes a sample's reconstruction as one undoable gesture, renaming it to the source. + + The detail is composed while the sample still holds the outgoing reconstruction, so it reads + the name being replaced alongside the incoming one. The replacement is announced in the same + window, ahead of the substitution, because an editor holding the sample open recognizes it by + the identity of the reconstruction it is about to give up. The frequency adoption, the rename, + and the substitution share a single history entry, so one undo restores the previous rate, + name, and audio together. + """ + detail = self._history_detail.replace_sample(voice_id, name) + with self._history.transaction( + HistoryAction.REPLACE_SAMPLE, + detail=detail, + ): + if adopt_frequency is not None: + self._tracker_logic.set_nes_frequency(adopt_frequency) + + self._voices_logic.rename_voice(voice_id, name) + self._on_sample_reconstruction_replaced(voice_id, reconstruction) + self._browser_logic.replace_reconstruction(voice_id, reconstruction) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/voices.py b/src/sampletones_application/coordinators/tabs/sequencer/voices.py new file mode 100644 index 000000000..d6ec751a4 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/voices.py @@ -0,0 +1,239 @@ +from pathlib import Path +from typing import Callable, Optional + +from sampletones_application.categories.instrument import InstrumentImportMessages +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail +from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_DIALOG_REMOVE +from sampletones_application.utils.file_dialogs.api import open_file_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.voice import ImportedVoice +from sampletones_core.utils.display import display_id +from sampletones_shared.exceptions import LoadInstrumentError +from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_RECONSTRUCTION + + +class SequencerVoices: + """The gestures that change what the pool holds: adding, importing, removing, renaming. + + Every one of them is a whole gesture, so each records the single history entry that takes the + pool back to where it stood. A gesture reaching a file reads it before the pool is touched, + which leaves a file the reader cannot use with the project and the history as they were. + """ + + def __init__( + self, + voices_logic: SequencerVoicesLogic, + history: HistoryManager, + history_detail: SequencerHistoryDetail, + project_controller: ProjectController, + session_manager: SessionManager, + open_project: OpenProjectRequirement, + *, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + import_messages: InstrumentImportMessages, + import_reconstruction: Callable[[Path], None], + ) -> None: + self._voices_logic = voices_logic + self._history = history + self._history_detail = history_detail + self._project_controller = project_controller + self._session_manager = session_manager + self._open_project = open_project + self._dialogs = dialogs + self._language_manager = language_manager + self._import_messages = import_messages + self._import_reconstruction = import_reconstruction + + def add_instrument(self) -> None: + """Appends a hand-written voice, named for the position it takes in the list. + + An instrument arrives sustaining at full volume, so it plays as soon as it is placed and the + envelopes stay the reader's to write; naming it by its position gives the list a readable + entry until they rename it. + """ + name = self._language_manager["sequencer.voices.template.instrument_name"].format( + position=display_id(self._project_controller.voice_count), + ) + with self._history.transaction( + HistoryAction.ADD_INSTRUMENT, + detail=self._history_detail.add_instrument(name), + ): + self._voices_logic.add_new_instrument(name) + + def add_instrument_from_channel( + self, + voice_id: str, + channel_name: ChannelName, + ) -> None: + """Takes what one channel of a voice plays as an instrument of its own, then opens it. + + A recording states its channels as frames, and this reads one of them back as envelopes, + so what the conversion found becomes a voice the reader edits by hand. The new voice is + brought up where it is edited, since seeing those envelopes is what taking the channel out + was for. + + Args: + voice_id: The voice the channel belongs to. + channel_name: The channel whose envelopes the instrument takes. + """ + instrument = self._voices_logic.instrument_from_channel(voice_id, channel_name) + if instrument is None: + return + + with self._history.transaction( + HistoryAction.ADD_INSTRUMENT, + detail=self._history_detail.add_instrument(instrument.name), + ): + self._voices_logic.add_instrument(instrument) + + self._voices_logic.request_edit(instrument.id) + + def add_sample_from_file(self) -> None: + """Brings a reconstruction saved anywhere on disk into the pool as a sample. + + The tree beside the list reaches the reconstructions folder, so a file kept elsewhere + arrives through the system's own browser, which opens on the folder the last one came + from. + """ + filepath = open_file_dialog( + title=self._language_manager["sequencer.voices.title.add_sample_dialog"], + initial_directory=self._session_manager.get_reconstruction_path(), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.reconstruction"], + [EXT_FILE_RECONSTRUCTION], + ), + ), + ) + + self._import_located_reconstruction(filepath) + + @ignore_none_path + def _import_located_reconstruction(self, filepath: Path) -> None: + self._session_manager.set_reconstruction_path(filepath.parent) + self._import_reconstruction(filepath) + + def import_instrument(self) -> None: + """Brings a FamiTracker instrument file into the pool as an instrument voice. + + The file arrives through the system's own browser, which opens on the folder the last + instrument was written to or read from, so an export and the import that follows it meet + in one place. A project is asked for first, since a voice needs a pool to land in. + """ + if not self._open_project.met(): + return + + filepath = open_file_dialog( + title=self._language_manager["sequencer.voices.title.import_instrument_dialog"], + initial_directory=self._session_manager.get_instrument_path(), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.famitracker_instrument"], + [EXT_FILE_INSTRUMENT], + ), + ), + ) + + self._import_located_instrument(filepath) + + @ignore_none_path + def _import_located_instrument(self, filepath: Path) -> None: + """Reads a located instrument file into the pool, then reports what it held. + + The file is read before the pool is touched, so a file the reader cannot use leaves the + project as it stands and the history without an entry. + """ + self._session_manager.set_instrument_path(filepath.parent) + imported = self._read_instrument(filepath) + if imported is None: + return + + with self._history.transaction( + HistoryAction.ADD_INSTRUMENT, + detail=self._history_detail.add_instrument(imported.voice.name), + ): + self._voices_logic.add_instrument(imported.voice) + + self._report_import(imported) + + def remove(self, voice_id: str) -> None: + """Removes a voice, confirming first only when a pattern still references it. + + An unused voice is dropped silently; a referenced one would clear every row + that points at it, so the user confirms that loss first. + """ + if not self._voices_logic.is_voice_used(voice_id): + self._perform_remove(voice_id) + return + + name = self._voices_logic.voice_name(voice_id) + self._dialogs.show_confirmation( + tag=TAG_SEQUENCER_VOICES_DIALOG_REMOVE, + title=self._language_manager["global.dialog.title.remove_voice"], + message=self._language_manager["global.dialog.message.remove_voice"].format(name=name), + on_confirm=lambda: self._perform_remove(voice_id), + ok_label=self._language_manager["global.dialog.label.remove"], + ) + + def submit_rename(self, voice_id: str, name: str) -> None: + """Applies an inline rename, ignoring a blank name so the voice keeps its current one.""" + stripped = name.strip() + if stripped: + detail = self._history_detail.rename_voice(voice_id, stripped) + with self._history.transaction( + HistoryAction.RENAME_VOICE, + detail=detail, + ): + self._voices_logic.rename_voice(voice_id, stripped) + + def _perform_remove(self, voice_id: str) -> None: + detail = self._history_detail.remove_voice(voice_id) + with self._history.transaction( + HistoryAction.REMOVE_VOICE, + detail=detail, + ): + self._voices_logic.remove_voice(voice_id) + + def _read_instrument(self, filepath: Path) -> Optional[ImportedVoice]: + """Reads an instrument file, reporting a file the reader cannot take as a voice. + + Returns: + Optional[ImportedVoice]: The voice the file describes, or ``None`` once the failure + has been shown. + """ + try: + return self._voices_logic.read_instrument(filepath) + except FileNotFoundError as exception: + logger.error_with_traceback(exception, f"No instrument file at {filepath}") + self._dialogs.show_file_not_found( + filepath, + self._language_manager["sequencer.voices.message.instrument_not_found"], + ) + except (LoadInstrumentError, OSError) as exception: + logger.error_with_traceback(exception, f"Failed to read an instrument from {filepath}") + self._dialogs.show_error(exception) + + return None + + def _report_import(self, imported: ImportedVoice) -> None: + """Names what the instrument file carried beyond the voice the pool took from it.""" + notice = self._import_messages.notice(imported.voice.name, imported.omissions) + if notice is not None: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, + notice, + self._import_messages.title, + ) diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 55f22f2b0..51ade2c2d 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -17,7 +17,7 @@ from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) -from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator +from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator from sampletones_application.layout import LayoutConfig from sampletones_application.tags.general import ( TAG_GLOBAL_STATUS_WINDOW, diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 4baf14383..7ac4373e7 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -11,8 +11,15 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.coordinators.playback.guard import GuardedPlayer -from sampletones_application.coordinators.tabs import sequencer as sequencer_module -from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator +from sampletones_application.coordinators.tabs.sequencer import voices as voices_module +from sampletones_application.coordinators.tabs.sequencer.blocks import SequencerBlocks +from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator +from sampletones_application.coordinators.tabs.sequencer.frames import SequencerFrames +from sampletones_application.coordinators.tabs.sequencer.history import SequencerHistoryRecorder +from sampletones_application.coordinators.tabs.sequencer.playhead import SequencerPlayhead +from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement +from sampletones_application.coordinators.tabs.sequencer.reconstructions import SequencerReconstructions +from sampletones_application.coordinators.tabs.sequencer.voices import SequencerVoices from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.history.snapshot import HistoryEntry @@ -22,25 +29,14 @@ ALL_CHANNELS, SequencerChannelsLogic, ) -from sampletones_application.logic.sequencer.clipboard import ( - OrderBlockText, - ParsedBlockCache, - ProjectSampleDirectory, - SequencerClipboard, - TrackerBlockText, -) from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) from sampletones_application.logic.sequencer.order import ( - OrderBlockReader, - OrderBlockWriter, SequencerOrderLogic, ) from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, - TrackerBlockReader, - TrackerBlockWriter, ) from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN @@ -87,30 +83,60 @@ } +def _reconstructions( + browser_logic: MagicMock, + tracker_logic: MagicMock, + project_controller: MagicMock, + dialogs: MagicMock, + voices_panel: MagicMock, + on_tab_switch: MagicMock, +) -> SequencerReconstructions: + """The reconstruction gestures over the collaborators a test states, with the rest mocked.""" + language_manager = FakeLanguageManager(TEXTS) + return SequencerReconstructions( + browser_logic, + tracker_logic, + MagicMock(), + voices_panel, + MagicMock(), + MagicMock(), + project_controller, + OpenProjectRequirement( + project_controller, + dialogs=dialogs, + language_manager=language_manager, + ), + dialogs=dialogs, + language_manager=language_manager, + on_tab_switch=on_tab_switch, + on_sample_reconstruction_replaced=MagicMock(), + ) + + @pytest.fixture -def coordinator() -> SequencerTabCoordinator: - """A coordinator with only the collaborators ``import_reconstruction`` touches. +def coordinator() -> SequencerReconstructions: + """The reconstruction gestures with only the collaborators an import touches. - The full constructor builds the sequencer's GUI subtree (themes, fonts, synthesiser), which - is out of scope here; only the import orchestration is under test. Defaults to an open project - with samples and a matching reconstruction frequency (60 Hz); individual tests override. + The tab's full constructor builds the sequencer's GUI subtree (themes, fonts, synthesiser), + which is out of scope here; only the import orchestration is under test. Defaults to an open + project with samples and a matching reconstruction frequency (60 Hz); individual tests + override. """ - instance = object.__new__(SequencerTabCoordinator) - instance._history = MagicMock() - instance._history_detail = MagicMock() - instance._project_controller = MagicMock() - instance._project_controller.is_open = True - instance._project_controller.has_voices = True - instance._sequencer_browser_logic = MagicMock() - instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 - instance._sequencer_tracker_logic = MagicMock() - instance._sequencer_tracker_logic.settings.nes_frequency = 60 - instance._dialogs = MagicMock() - instance._on_tab_switch = MagicMock() - instance._language_manager = FakeLanguageManager(TEXTS) - instance._msg_no_project = "no project" - instance._ttl_no_project = "No project open" - return instance + browser_logic = MagicMock() + browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 + tracker_logic = MagicMock() + tracker_logic.settings.nes_frequency = 60 + project_controller = MagicMock() + project_controller.is_open = True + project_controller.has_voices = True + return _reconstructions( + browser_logic, + tracker_logic, + project_controller, + MagicMock(), + MagicMock(), + MagicMock(), + ) INSTRUMENT_FILE: Final[Path] = Path("/instruments/Lead.fti") @@ -125,24 +151,42 @@ def _imported(*omissions: InstrumentOmission) -> ImportedVoice: return ImportedVoice(voice=IMPORTED_VOICE, omissions=omissions) +def _voices( + voices_logic: MagicMock, + project_controller: MagicMock, + session_manager: MagicMock, + dialogs: MagicMock, +) -> SequencerVoices: + """The pool gestures over the collaborators a test states, with the rest mocked.""" + language_manager = FakeLanguageManager(TEXTS) + return SequencerVoices( + voices_logic, + MagicMock(), + MagicMock(), + project_controller, + session_manager, + OpenProjectRequirement( + project_controller, + dialogs=dialogs, + language_manager=language_manager, + ), + dialogs=dialogs, + language_manager=language_manager, + import_messages=InstrumentImportMessages.build(LanguageManager(LANG_EN)), + import_reconstruction=MagicMock(), + ) + + @pytest.fixture -def instrument_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the collaborators ``import_instrument`` touches.""" - instance = object.__new__(SequencerTabCoordinator) - instance._history = MagicMock() - instance._history_detail = MagicMock() - instance._project_controller = MagicMock() - instance._project_controller.is_open = True - instance._sequencer_voices_logic = MagicMock() - instance._sequencer_voices_logic.read_instrument.return_value = _imported() - instance._session_manager = MagicMock() - instance._session_manager.get_instrument_path.return_value = INSTRUMENT_FILE.parent - instance._dialogs = MagicMock() - instance._language_manager = FakeLanguageManager(TEXTS) - instance._import_messages = InstrumentImportMessages.build(LanguageManager(LANG_EN)) - instance._msg_no_project = "no project" - instance._ttl_no_project = "No project open" - return instance +def instrument_voices() -> SequencerVoices: + """The pool gestures with only the collaborators ``import_instrument`` touches.""" + voices_logic = MagicMock() + voices_logic.read_instrument.return_value = _imported() + project_controller = MagicMock() + project_controller.is_open = True + session_manager = MagicMock() + session_manager.get_instrument_path.return_value = INSTRUMENT_FILE.parent + return _voices(voices_logic, project_controller, session_manager, MagicMock()) @pytest.fixture @@ -154,13 +198,13 @@ def _open(**kwargs: object) -> Path: opened.append(kwargs) return INSTRUMENT_FILE - monkeypatch.setattr(sequencer_module, "open_file_dialog", _open) + monkeypatch.setattr(voices_module, "open_file_dialog", _open) return opened @pytest.fixture def canceled_dialog(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(sequencer_module, "open_file_dialog", lambda **_kwargs: None) + monkeypatch.setattr(voices_module, "open_file_dialog", lambda **_kwargs: None) class TestImportInstrument: @@ -168,168 +212,162 @@ class TestImportInstrument: def test_a_project_is_asked_for_before_a_file_is( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: """A voice needs a pool to land in, so a closed project stops the gesture at the door.""" - instrument_coordinator._project_controller.is_open = False + instrument_voices._project_controller.is_open = False - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() assert located_file == [] - instrument_coordinator._dialogs.show_info.assert_called_once() + instrument_voices._dialogs.show_info.assert_called_once() def test_the_dialog_opens_where_the_last_instrument_was( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() assert located_file[0]["initial_directory"] == INSTRUMENT_FILE.parent def test_the_folder_the_file_came_from_is_remembered( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - instrument_coordinator._session_manager.set_instrument_path.assert_called_once_with(INSTRUMENT_FILE.parent) + instrument_voices._session_manager.set_instrument_path.assert_called_once_with(INSTRUMENT_FILE.parent) def test_a_canceled_dialog_leaves_the_pool_as_it_stands( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, canceled_dialog: None, ) -> None: - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - instrument_coordinator._sequencer_voices_logic.read_instrument.assert_not_called() - instrument_coordinator._sequencer_voices_logic.add_instrument.assert_not_called() + instrument_voices._voices_logic.read_instrument.assert_not_called() + instrument_voices._voices_logic.add_instrument.assert_not_called() def test_the_voice_the_file_made_joins_the_pool( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - logic = instrument_coordinator._sequencer_voices_logic + logic = instrument_voices._voices_logic logic.read_instrument.assert_called_once_with(INSTRUMENT_FILE) logic.add_instrument.assert_called_once_with(IMPORTED_VOICE) def test_the_whole_gesture_is_one_history_entry( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - action = instrument_coordinator._history.transaction.call_args.args[0] + action = instrument_voices._history.transaction.call_args.args[0] assert action is HistoryAction.ADD_INSTRUMENT - instrument_coordinator._history_detail.add_instrument.assert_called_once_with(IMPORTED_VOICE.name) + instrument_voices._history_detail.add_instrument.assert_called_once_with(IMPORTED_VOICE.name) def test_what_the_file_held_past_the_voice_is_reported( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - logic = instrument_coordinator._sequencer_voices_logic + logic = instrument_voices._voices_logic logic.read_instrument.return_value = _imported(InstrumentOmission.PITCH) - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - notice = instrument_coordinator._dialogs.show_info.call_args.args[1] - assert instrument_coordinator._import_messages.omissions[InstrumentOmission.PITCH] in notice + notice = instrument_voices._dialogs.show_info.call_args.args[1] + assert instrument_voices._import_messages.omissions[InstrumentOmission.PITCH] in notice def test_a_file_holding_the_voice_alone_is_reported_nowhere( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: """An import that lost nothing interrupts the reader with nothing.""" - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - instrument_coordinator._dialogs.show_info.assert_not_called() + instrument_voices._dialogs.show_info.assert_not_called() def test_a_file_that_is_not_there_is_reported_as_missing( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - logic = instrument_coordinator._sequencer_voices_logic + logic = instrument_voices._voices_logic logic.read_instrument.side_effect = FileNotFoundError(INSTRUMENT_FILE) - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - instrument_coordinator._dialogs.show_file_not_found.assert_called_once() + instrument_voices._dialogs.show_file_not_found.assert_called_once() logic.add_instrument.assert_not_called() def test_a_file_the_reader_cannot_take_is_reported( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: - logic = instrument_coordinator._sequencer_voices_logic + logic = instrument_voices._voices_logic logic.read_instrument.side_effect = MalformedInstrumentError("truncated") - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - instrument_coordinator._dialogs.show_error.assert_called_once() + instrument_voices._dialogs.show_error.assert_called_once() logic.add_instrument.assert_not_called() def test_a_file_the_reader_cannot_take_records_no_history( self, - instrument_coordinator: SequencerTabCoordinator, + instrument_voices: SequencerVoices, located_file: List[Dict[str, object]], ) -> None: """The file is read before the pool is touched, so a refusal leaves the project as it was.""" - logic = instrument_coordinator._sequencer_voices_logic + logic = instrument_voices._voices_logic logic.read_instrument.side_effect = MalformedInstrumentError("truncated") - instrument_coordinator.import_instrument() + instrument_voices.import_instrument() - instrument_coordinator._history.transaction.assert_not_called() + instrument_voices._history.transaction.assert_not_called() @pytest.fixture -def samples_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the collaborators the samples-menu handlers touch.""" - instance = object.__new__(SequencerTabCoordinator) - instance._history = MagicMock() - instance._history_detail = MagicMock() - instance._sequencer_voices_logic = MagicMock() - instance._dialogs = MagicMock() - instance._language_manager = FakeLanguageManager(TEXTS) - return instance +def samples_voices() -> SequencerVoices: + """The pool gestures with only the collaborators the samples-menu handlers touch.""" + return _voices(MagicMock(), MagicMock(), MagicMock(), MagicMock()) class TestRemoveSample: def test_unused_sample_is_removed_without_confirmation( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - samples_coordinator._sequencer_voices_logic.is_voice_used.return_value = False + samples_voices._voices_logic.is_voice_used.return_value = False - samples_coordinator._remove_voice("abc") + samples_voices.remove("abc") - samples_coordinator._sequencer_voices_logic.remove_voice.assert_called_once_with("abc") - samples_coordinator._dialogs.show_confirmation.assert_not_called() + samples_voices._voices_logic.remove_voice.assert_called_once_with("abc") + samples_voices._dialogs.show_confirmation.assert_not_called() def test_used_sample_prompts_confirmation_before_removing( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - logic = samples_coordinator._sequencer_voices_logic + logic = samples_voices._voices_logic logic.is_voice_used.return_value = True logic.voice_name.return_value = "lead" - samples_coordinator._remove_voice("abc") + samples_voices.remove("abc") - samples_coordinator._dialogs.show_confirmation.assert_called_once() + samples_voices._dialogs.show_confirmation.assert_called_once() logic.remove_voice.assert_not_called() - confirmation = samples_coordinator._dialogs.show_confirmation.call_args.kwargs + confirmation = samples_voices._dialogs.show_confirmation.call_args.kwargs assert confirmation["message"] == "Remove lead?" confirmation["on_confirm"]() @@ -340,84 +378,84 @@ class TestTakingAChannelAsAnInstrument: """A channel of a recording becomes a voice of its own, recorded and brought up to edit.""" @staticmethod - def _taken(samples_coordinator: SequencerTabCoordinator) -> Instrument: + def _taken(samples_voices: SequencerVoices) -> Instrument: instrument = Instrument(name="Bass (triangle)", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,)))) - samples_coordinator._sequencer_voices_logic.instrument_from_channel.return_value = instrument + samples_voices._voices_logic.instrument_from_channel.return_value = instrument return instrument def test_the_channel_named_is_the_one_taken( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - self._taken(samples_coordinator) + self._taken(samples_voices) - samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) - samples_coordinator._sequencer_voices_logic.instrument_from_channel.assert_called_once_with( + samples_voices._voices_logic.instrument_from_channel.assert_called_once_with( "bass-id", ChannelName.TRIANGLE, ) def test_the_new_voice_lands_in_the_pool( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - instrument = self._taken(samples_coordinator) + instrument = self._taken(samples_voices) - samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) - samples_coordinator._sequencer_voices_logic.add_instrument.assert_called_once_with(instrument) + samples_voices._voices_logic.add_instrument.assert_called_once_with(instrument) def test_the_history_names_the_voice_that_arrived( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - instrument = self._taken(samples_coordinator) + instrument = self._taken(samples_voices) - samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) - samples_coordinator._history_detail.add_instrument.assert_called_once_with(instrument.name) - assert samples_coordinator._history.transaction.call_args.args[0] is HistoryAction.ADD_INSTRUMENT + samples_voices._history_detail.add_instrument.assert_called_once_with(instrument.name) + assert samples_voices._history.transaction.call_args.args[0] is HistoryAction.ADD_INSTRUMENT def test_the_new_voice_is_brought_up_where_it_is_edited( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: """Seeing the envelopes that came across is what taking the channel out was for.""" - instrument = self._taken(samples_coordinator) + instrument = self._taken(samples_voices) - samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) + samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE) - samples_coordinator._sequencer_voices_logic.request_edit.assert_called_once_with(instrument.id) + samples_voices._voices_logic.request_edit.assert_called_once_with(instrument.id) def test_a_channel_that_plays_nothing_leaves_the_pool_as_it_stands( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - samples_coordinator._sequencer_voices_logic.instrument_from_channel.return_value = None + samples_voices._voices_logic.instrument_from_channel.return_value = None - samples_coordinator.add_instrument_from_channel("bass-id", ChannelName.NOISE) + samples_voices.add_instrument_from_channel("bass-id", ChannelName.NOISE) - samples_coordinator._sequencer_voices_logic.add_instrument.assert_not_called() - samples_coordinator._history.transaction.assert_not_called() + samples_voices._voices_logic.add_instrument.assert_not_called() + samples_voices._history.transaction.assert_not_called() class TestSubmitRename: def test_submit_rename_trims_whitespace( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - samples_coordinator._submit_rename("abc", " bass ") + samples_voices.submit_rename("abc", " bass ") - samples_coordinator._sequencer_voices_logic.rename_voice.assert_called_once_with("abc", "bass") + samples_voices._voices_logic.rename_voice.assert_called_once_with("abc", "bass") def test_submit_rename_ignores_blank_name( self, - samples_coordinator: SequencerTabCoordinator, + samples_voices: SequencerVoices, ) -> None: - samples_coordinator._submit_rename("abc", " ") + samples_voices.submit_rename("abc", " ") - samples_coordinator._sequencer_voices_logic.rename_voice.assert_not_called() + samples_voices._voices_logic.rename_voice.assert_not_called() @pytest.fixture @@ -545,7 +583,17 @@ def playback_coordinator() -> SequencerTabCoordinator: instance._sequencer_tracker_logic = MagicMock() instance._sequencer_tracker_panel = MagicMock() instance._sequencer_order_panel = MagicMock() - instance._playing_position = None + instance._playhead = SequencerPlayhead( + instance._sequencer_tracker_panel, + instance._sequencer_order_panel, + ) + instance._frames = SequencerFrames( + MagicMock(), + instance._sequencer_tracker_logic, + instance._song_player_logic, + MagicMock(), + instance._playhead, + ) return instance @@ -610,7 +658,7 @@ def test_order_selection_seeks_the_playhead_while_following( """Choosing a frame always picks what is edited, and moves the playhead when following.""" playback_coordinator._song_player_logic.follow_mode = mode - playback_coordinator._on_order_frame_selected(3) + playback_coordinator._frames.select(3) playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3) assert playback_coordinator._song_player_logic.seek.called is mode.follows_pattern @@ -627,327 +675,323 @@ def test_a_chosen_mode_reaches_the_player( @pytest.fixture -def order_ops_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the collaborators the order-frame handlers touch.""" - instance = object.__new__(SequencerTabCoordinator) - instance._sequencer_order_logic = MagicMock() - instance._sequencer_tracker_logic = MagicMock() - instance._sequencer_order_panel = MagicMock() - instance._sequencer_tracker_panel = MagicMock() - instance._song_player_logic = MagicMock() - instance._project_controller = MagicMock() - instance._playing_position = None - return instance +def order_ops_frames() -> SequencerFrames: + """The frame gestures over mocked collaborators, behind a playhead marking mocked grids.""" + return SequencerFrames( + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + SequencerPlayhead(MagicMock(), MagicMock()), + ) class TestOrderFrameOperations: def test_insert_adds_a_frame_after_the_target( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._song_player_logic.is_playing.return_value = False + frames = order_ops_frames + frames._song_player_logic.is_playing.return_value = False - coordinator._on_order_insert(2) + frames.insert(2) - coordinator._sequencer_order_logic.insert_frame.assert_called_once_with(3) + frames._order_logic.insert_frame.assert_called_once_with(3) def test_remove_pulls_playhead_earlier_when_playing( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._playing_position = _playhead(3, SOUNDING_ROW) - coordinator._project_controller.order_length = 5 + frames = order_ops_frames + frames._playhead.stand_at(3, SOUNDING_ROW) + frames._project_controller.order_length = 5 - coordinator._on_order_remove(1) + frames.remove(1) - coordinator._sequencer_order_logic.remove_from_order.assert_called_once_with(1) - coordinator._song_player_logic.relocate.assert_called_once_with(2) + frames._order_logic.remove_from_order.assert_called_once_with(1) + frames._song_player_logic.relocate.assert_called_once_with(2) def test_remove_does_not_relocate_when_not_playing( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._playing_position = None - coordinator._project_controller.order_length = 5 + frames = order_ops_frames + frames._playhead.stop() + frames._project_controller.order_length = 5 - coordinator._on_order_remove(1) + frames.remove(1) - coordinator._song_player_logic.relocate.assert_not_called() + frames._song_player_logic.relocate.assert_not_called() def test_duplicate_before_playhead_shifts_it_later( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._playing_position = _playhead(2, SOUNDING_ROW) - coordinator._song_player_logic.is_playing.return_value = True + frames = order_ops_frames + frames._playhead.stand_at(2, SOUNDING_ROW) + frames._song_player_logic.is_playing.return_value = True - coordinator._on_order_duplicate(0) + frames.duplicate(0) - coordinator._sequencer_order_logic.duplicate_frame.assert_called_once_with(0) - coordinator._song_player_logic.relocate.assert_called_once_with(3) + frames._order_logic.duplicate_frame.assert_called_once_with(0) + frames._song_player_logic.relocate.assert_called_once_with(3) def test_move_makes_the_playing_frame_follow_itself( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._playing_position = _playhead(2, SOUNDING_ROW) - coordinator._song_player_logic.is_playing.return_value = True + frames = order_ops_frames + frames._playhead.stand_at(2, SOUNDING_ROW) + frames._song_player_logic.is_playing.return_value = True - coordinator._on_order_move(2, 5) + frames.move(2, 5) - coordinator._sequencer_order_logic.move_frame.assert_called_once_with(2, 5) - coordinator._song_player_logic.relocate.assert_called_once_with(5) + frames._order_logic.move_frame.assert_called_once_with(2, 5) + frames._song_player_logic.relocate.assert_called_once_with(5) def test_move_advances_cursor_and_highlight_immediately( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: # The cursor and playing highlight must advance on the keypress, not on the next row # update, so a rapid second Alt+arrow acts on the moved frame rather than snapping back. - coordinator = order_ops_coordinator - coordinator._playing_position = _playhead(2, SOUNDING_ROW) - coordinator._song_player_logic.is_playing.return_value = True + frames = order_ops_frames + frames._playhead.stand_at(2, SOUNDING_ROW) + frames._song_player_logic.is_playing.return_value = True - coordinator._on_order_move(2, 3) + frames.move(2, 3) - coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3) - coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(3) + frames._tracker_logic.select_frame.assert_called_once_with(3) + frames._playhead._order_panel.set_playing_position.assert_called_once_with(3) def test_move_carries_the_sounding_row_to_the_frame_it_lands_on( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: """The tracker's mark belongs to a frame, so an edit that moves the frame moves the mark.""" - coordinator = order_ops_coordinator - coordinator._playing_position = _playhead(2, SOUNDING_ROW) - coordinator._song_player_logic.is_playing.return_value = True + frames = order_ops_frames + frames._playhead.stand_at(2, SOUNDING_ROW) + frames._song_player_logic.is_playing.return_value = True - coordinator._on_order_move(2, 5) + frames.move(2, 5) - panel = coordinator._sequencer_tracker_panel + panel = frames._playhead._tracker_panel panel.set_playing_position.assert_called_once_with(_playhead(5, SOUNDING_ROW)) def test_clear_leaves_the_playhead_in_place( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._playing_position = _playhead(2, SOUNDING_ROW) + frames = order_ops_frames + frames._playhead.stand_at(2, SOUNDING_ROW) - coordinator._on_order_clear(2) + frames.clear(2) - coordinator._sequencer_order_logic.clear_frame.assert_called_once_with(2) - coordinator._song_player_logic.relocate.assert_not_called() + frames._order_logic.clear_frame.assert_called_once_with(2) + frames._song_player_logic.relocate.assert_not_called() def test_play_from_seeks_when_already_playing( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._song_player_logic.is_playing.return_value = True + frames = order_ops_frames + frames._song_player_logic.is_playing.return_value = True - coordinator._on_order_play_from(3) + frames.play_from(3) - coordinator._song_player_logic.seek.assert_called_once_with(3) - coordinator._song_player_logic.play_from.assert_not_called() + frames._song_player_logic.seek.assert_called_once_with(3) + frames._song_player_logic.play_from.assert_not_called() def test_play_from_starts_playback_when_stopped( self, - order_ops_coordinator: SequencerTabCoordinator, + order_ops_frames: SequencerFrames, ) -> None: - coordinator = order_ops_coordinator - coordinator._song_player_logic.is_playing.return_value = False + frames = order_ops_frames + frames._song_player_logic.is_playing.return_value = False - coordinator._on_order_play_from(3) + frames.play_from(3) - coordinator._song_player_logic.play_from.assert_called_once_with(3) - coordinator._song_player_logic.seek.assert_not_called() + frames._song_player_logic.play_from.assert_called_once_with(3) + frames._song_player_logic.seek.assert_not_called() class TestImportReconstruction: def test_closed_project_shows_dialog_and_does_not_import( self, - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, ) -> None: coordinator._project_controller.is_open = False - coordinator.import_reconstruction(Path("reconstruction.stn")) + coordinator.import_from_file(Path("reconstruction.stn")) coordinator._dialogs.show_info.assert_called_once() - coordinator._sequencer_browser_logic.load_reconstruction.assert_not_called() + coordinator._browser_logic.load_reconstruction.assert_not_called() coordinator._on_tab_switch.assert_not_called() def test_successful_import_switches_to_sequencer_tab( self, - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, ) -> None: - reconstruction = coordinator._sequencer_browser_logic.load_reconstruction.return_value + reconstruction = coordinator._browser_logic.load_reconstruction.return_value - coordinator.import_reconstruction(Path("reconstruction.stn")) + coordinator.import_from_file(Path("reconstruction.stn")) - coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once_with( - reconstruction, "reconstruction" - ) + coordinator._browser_logic.add_reconstruction.assert_called_once_with(reconstruction, "reconstruction") coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER) coordinator._dialogs.show_info.assert_not_called() coordinator._dialogs.show_confirmation.assert_not_called() def test_failed_load_shows_error_and_does_not_switch_tab( self, - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, ) -> None: - coordinator._sequencer_browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError( + coordinator._browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError( "invalid", ValueError("inner"), ) - coordinator.import_reconstruction(Path("reconstruction.stn")) + coordinator.import_from_file(Path("reconstruction.stn")) coordinator._dialogs.show_error.assert_called_once() - coordinator._sequencer_browser_logic.add_reconstruction.assert_not_called() + coordinator._browser_logic.add_reconstruction.assert_not_called() coordinator._on_tab_switch.assert_not_called() class TestImportFrequencyCheck: def test_matching_frequency_adds_without_prompt_or_adopt( self, - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, ) -> None: - coordinator._sequencer_tracker_logic.settings.nes_frequency = 60 - coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 + coordinator._tracker_logic.settings.nes_frequency = 60 + coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 - coordinator.import_reconstruction(Path("reconstruction.stn")) + coordinator.import_from_file(Path("reconstruction.stn")) coordinator._dialogs.show_confirmation.assert_not_called() - coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called() - coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once() + coordinator._tracker_logic.set_nes_frequency.assert_not_called() + coordinator._browser_logic.add_reconstruction.assert_called_once() def test_empty_project_adopts_reconstruction_frequency_silently( self, - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, ) -> None: coordinator._project_controller.has_voices = False - coordinator._sequencer_tracker_logic.settings.nes_frequency = 60 - coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 + coordinator._tracker_logic.settings.nes_frequency = 60 + coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 - coordinator.import_reconstruction(Path("reconstruction.stn")) + coordinator.import_from_file(Path("reconstruction.stn")) - coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(50) - coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once() + coordinator._tracker_logic.set_nes_frequency.assert_called_once_with(50) + coordinator._browser_logic.add_reconstruction.assert_called_once() coordinator._dialogs.show_confirmation.assert_not_called() coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER) def test_mismatch_with_samples_confirms_before_adding( self, - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, ) -> None: coordinator._project_controller.has_voices = True - coordinator._sequencer_tracker_logic.settings.nes_frequency = 60 - coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 + coordinator._tracker_logic.settings.nes_frequency = 60 + coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 - coordinator.import_reconstruction(Path("reconstruction.stn")) + coordinator.import_from_file(Path("reconstruction.stn")) coordinator._dialogs.show_confirmation.assert_called_once() - coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called() - coordinator._sequencer_browser_logic.add_reconstruction.assert_not_called() + coordinator._tracker_logic.set_nes_frequency.assert_not_called() + coordinator._browser_logic.add_reconstruction.assert_not_called() coordinator._on_tab_switch.assert_not_called() confirmation = coordinator._dialogs.show_confirmation.call_args.kwargs assert confirmation["message"] == "recon 50 vs project 60" confirmation["on_confirm"]() - coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once() + coordinator._browser_logic.add_reconstruction.assert_called_once() coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER) @pytest.fixture -def replace_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the collaborators the browser replacement touches. +def replace_coordinator() -> SequencerReconstructions: + """The reconstruction gestures with only the collaborators the browser replacement touches. Defaults to a two-sample project holding ``1A: bass`` selected, against a reconstruction at the project's frequency (60 Hz); individual tests override. ``_on_tab_switch`` stays absent, so a replacement reaching for it would fail the test — the browser already lives in this tab. """ - instance = object.__new__(SequencerTabCoordinator) - instance._history = MagicMock() - instance._history_detail = MagicMock() - instance._project_controller = MagicMock() - instance._project_controller.voice_count = 2 - instance._sequencer_browser_logic = MagicMock() - instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 - instance._sequencer_tracker_logic = MagicMock() - instance._sequencer_tracker_logic.settings.nes_frequency = 60 - instance._sequencer_voices_logic = MagicMock() - instance._sequencer_voices_panel = MagicMock() - instance._sequencer_voices_panel.selection = VoiceSelection( + browser_logic = MagicMock() + browser_logic.load_reconstruction.return_value.config.nes_frequency = 60 + tracker_logic = MagicMock() + tracker_logic.settings.nes_frequency = 60 + project_controller = MagicMock() + project_controller.voice_count = 2 + voices_panel = MagicMock() + voices_panel.selection = VoiceSelection( voice_id="bass-id", position=26, name="bass", kind=VoiceKind.SAMPLE, ) - instance._dialogs = MagicMock() - instance._on_sample_reconstruction_replaced = MagicMock() - instance._language_manager = FakeLanguageManager(TEXTS) - return instance + return _reconstructions( + browser_logic, + tracker_logic, + project_controller, + MagicMock(), + voices_panel, + MagicMock(), + ) class TestReplaceReconstruction: def test_absent_selection_replaces_nothing( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - replace_coordinator._sequencer_voices_panel.selection = None + replace_coordinator._voices_panel.selection = None - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) - replace_coordinator._sequencer_browser_logic.load_reconstruction.assert_not_called() - replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called() + replace_coordinator._browser_logic.load_reconstruction.assert_not_called() + replace_coordinator._browser_logic.replace_reconstruction.assert_not_called() def test_failed_load_shows_error_and_replaces_nothing( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - replace_coordinator._sequencer_browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError( + replace_coordinator._browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError( "invalid", ValueError("inner"), ) - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) replace_coordinator._dialogs.show_error.assert_called_once() - replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called() - replace_coordinator._sequencer_voices_logic.rename_voice.assert_not_called() + replace_coordinator._browser_logic.replace_reconstruction.assert_not_called() + replace_coordinator._voices_logic.rename_voice.assert_not_called() replace_coordinator._on_sample_reconstruction_replaced.assert_not_called() def test_selected_sample_is_renamed_and_substituted( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - reconstruction = replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value + reconstruction = replace_coordinator._browser_logic.load_reconstruction.return_value - replace_coordinator.replace_reconstruction(Path("/reconstructions/kick_02.stn")) + replace_coordinator.replace_from_file(Path("/reconstructions/kick_02.stn")) - replace_coordinator._sequencer_voices_logic.rename_voice.assert_called_once_with( + replace_coordinator._voices_logic.rename_voice.assert_called_once_with( "bass-id", "kick_02", ) - replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once_with( + replace_coordinator._browser_logic.replace_reconstruction.assert_called_once_with( "bass-id", reconstruction, ) replace_coordinator._dialogs.show_confirmation.assert_not_called() - replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called() + replace_coordinator._tracker_logic.set_nes_frequency.assert_not_called() def test_rename_and_substitution_share_one_history_entry( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) replace_coordinator._history.transaction.assert_called_once_with( HistoryAction.REPLACE_SAMPLE, @@ -956,35 +1000,35 @@ def test_rename_and_substitution_share_one_history_entry( def test_detail_reads_the_sample_before_it_is_substituted( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: """The detail names the outgoing reconstruction, which the sample only holds until the swap.""" order = MagicMock() order.attach_mock(replace_coordinator._history_detail.replace_sample, "detail") order.attach_mock( - replace_coordinator._sequencer_browser_logic.replace_reconstruction, + replace_coordinator._browser_logic.replace_reconstruction, "replace", ) - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) assert [call[0] for call in order.mock_calls] == ["detail", "replace"] replace_coordinator._history_detail.replace_sample.assert_called_once_with("bass-id", "kick_02") def test_replacement_is_announced_before_the_substitution( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: """An editor holding the sample open identifies it by the reconstruction the swap replaces.""" - reconstruction = replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value + reconstruction = replace_coordinator._browser_logic.load_reconstruction.return_value order = MagicMock() order.attach_mock(replace_coordinator._on_sample_reconstruction_replaced, "announce") order.attach_mock( - replace_coordinator._sequencer_browser_logic.replace_reconstruction, + replace_coordinator._browser_logic.replace_reconstruction, "replace", ) - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) assert [call[0] for call in order.mock_calls] == ["announce", "replace"] replace_coordinator._on_sample_reconstruction_replaced.assert_called_once_with( @@ -994,65 +1038,75 @@ def test_replacement_is_announced_before_the_substitution( def test_sole_sample_adopts_the_reconstruction_frequency_silently( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: replace_coordinator._project_controller.voice_count = 1 - replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 + replace_coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) - replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(50) - replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once() + replace_coordinator._tracker_logic.set_nes_frequency.assert_called_once_with(50) + replace_coordinator._browser_logic.replace_reconstruction.assert_called_once() replace_coordinator._dialogs.show_confirmation.assert_not_called() def test_mismatch_beside_other_samples_confirms_before_replacing( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 + replace_coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50 - replace_coordinator.replace_reconstruction(Path("kick_02.stn")) + replace_coordinator.replace_from_file(Path("kick_02.stn")) replace_coordinator._dialogs.show_confirmation.assert_called_once() - replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called() + replace_coordinator._browser_logic.replace_reconstruction.assert_not_called() replace_coordinator._on_sample_reconstruction_replaced.assert_not_called() confirmation = replace_coordinator._dialogs.show_confirmation.call_args.kwargs assert confirmation["message"] == "recon 50 vs project 60" confirmation["on_confirm"]() - replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once() - replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called() + replace_coordinator._browser_logic.replace_reconstruction.assert_called_once() + replace_coordinator._tracker_logic.set_nes_frequency.assert_not_called() class TestReplaceTargetLabel: def test_label_names_the_selected_sample( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - assert replace_coordinator._replace_target_label() == "1A: bass" + assert replace_coordinator.replace_target_label() == "1A: bass" def test_label_is_absent_without_a_selection( self, - replace_coordinator: SequencerTabCoordinator, + replace_coordinator: SequencerReconstructions, ) -> None: - replace_coordinator._sequencer_voices_panel.selection = None + replace_coordinator._voices_panel.selection = None - assert replace_coordinator._replace_target_label() is None + assert replace_coordinator.replace_target_label() is None @pytest.fixture def history_coordinator() -> SequencerTabCoordinator: - """A coordinator with the two collaborators an undoable gesture reaches. + """A coordinator whose history is a mock, so a test reads what a delegation asked of it.""" + instance = object.__new__(SequencerTabCoordinator) + instance._history = MagicMock() + return instance + + +@pytest.fixture +def recorder() -> SequencerHistoryRecorder: + """A recorder with the two collaborators an undoable gesture reaches. The history is a mock, so a test reads the transaction a gesture opens; the controller is real, so a test reads the notifications the gesture's mutations actually produce. """ - instance = object.__new__(SequencerTabCoordinator) - instance._history = MagicMock() - instance._project_controller = ProjectController(ProjectManager()) - return instance + return SequencerHistoryRecorder( + MagicMock(), + ProjectController(ProjectManager()), + MagicMock(), + language_manager=MagicMock(), + ) @pytest.fixture @@ -1084,7 +1138,7 @@ def wired_history_coordinator( class TestHistoryResetWiring: def test_project_replacement_reseeds_history( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator controller = coordinator._project_controller @@ -1101,7 +1155,7 @@ def test_project_replacement_reseeds_history( def test_closing_the_project_empties_history( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator controller = coordinator._project_controller @@ -1115,7 +1169,7 @@ def test_closing_the_project_empties_history( def test_undo_keeps_the_stack_it_navigates( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator controller = coordinator._project_controller @@ -1140,7 +1194,7 @@ class TestChannelMuteLifetime: def test_undo_keeps_the_mute_set( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator controller = coordinator._project_controller @@ -1155,7 +1209,7 @@ def test_undo_keeps_the_mute_set( def test_redo_keeps_the_mute_set( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator controller = coordinator._project_controller @@ -1171,7 +1225,7 @@ def test_redo_keeps_the_mute_set( def test_opening_a_project_restores_every_channel( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator channels = coordinator._sequencer_channels_logic @@ -1183,7 +1237,7 @@ def test_opening_a_project_restores_every_channel( def test_closing_the_project_restores_every_channel( self, - wired_history_coordinator: SequencerTabCoordinator, + wired_history_coordinator: SequencerReconstructions, ) -> None: coordinator = wired_history_coordinator channels = coordinator._sequencer_channels_logic @@ -1226,7 +1280,7 @@ class TestChannelHeaderWiring: def test_header_click_silences_that_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: panel = channels_coordinator._sequencer_tracker_panel @@ -1237,7 +1291,7 @@ def test_header_click_silences_that_channel( def test_a_second_click_returns_the_channel_to_the_mix( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: panel = channels_coordinator._sequencer_tracker_panel @@ -1249,7 +1303,7 @@ def test_a_second_click_returns_the_channel_to_the_mix( def test_ctrl_header_click_solos_that_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL) @@ -1261,7 +1315,7 @@ def test_ctrl_header_click_solos_that_channel( def test_sample_header_click_silences_every_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: panel = channels_coordinator._sequencer_tracker_panel @@ -1272,7 +1326,7 @@ def test_sample_header_click_silences_every_channel( def test_sample_header_click_restores_every_channel_from_full_silence( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: panel = channels_coordinator._sequencer_tracker_panel @@ -1284,7 +1338,7 @@ def test_sample_header_click_restores_every_channel_from_full_silence( def test_the_menu_silences_every_channel_from_a_mixed_set( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: panel = channels_coordinator._sequencer_tracker_panel panel._on_header_clicked(0, True, ChannelName.TRIANGLE) @@ -1296,7 +1350,7 @@ def test_the_menu_silences_every_channel_from_a_mixed_set( def test_the_menu_restores_every_channel_from_a_mixed_set( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: panel = channels_coordinator._sequencer_tracker_panel panel._on_header_clicked(0, True, ChannelName.TRIANGLE) @@ -1312,7 +1366,7 @@ class TestChannelRowLabelWiring: def test_row_label_click_silences_that_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: order_panel = channels_coordinator._sequencer_order_panel @@ -1323,7 +1377,7 @@ def test_row_label_click_silences_that_channel( def test_ctrl_row_label_click_solos_that_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL) @@ -1335,7 +1389,7 @@ def test_ctrl_row_label_click_solos_that_channel( def test_master_row_label_click_silences_every_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: order_panel = channels_coordinator._sequencer_order_panel @@ -1345,7 +1399,7 @@ def test_master_row_label_click_silences_every_channel( def test_a_tracker_click_reaches_the_order_table( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: tracker_panel = channels_coordinator._sequencer_tracker_panel order_panel = channels_coordinator._sequencer_order_panel @@ -1356,7 +1410,7 @@ def test_a_tracker_click_reaches_the_order_table( def test_an_order_click_reaches_the_tracker( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: tracker_panel = channels_coordinator._sequencer_tracker_panel order_panel = channels_coordinator._sequencer_order_panel @@ -1367,7 +1421,7 @@ def test_an_order_click_reaches_the_tracker( def test_the_order_menu_silences_every_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: order_panel = channels_coordinator._sequencer_order_panel order_panel._on_label_clicked(0, True, ChannelName.TRIANGLE) @@ -1378,7 +1432,7 @@ def test_the_order_menu_silences_every_channel( def test_the_order_menu_restores_every_channel( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: order_panel = channels_coordinator._sequencer_order_panel order_panel._on_label_clicked(0, True, ChannelName.TRIANGLE) @@ -1393,7 +1447,7 @@ class TestChannelMenuWiring: def test_the_menu_reads_the_mute_set_back( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: channels_coordinator._sequencer_channels_logic.toggle(ChannelName.NOISE) @@ -1401,7 +1455,7 @@ def test_the_menu_reads_the_mute_set_back( def test_toggling_a_channel_silences_it( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: channels_coordinator.toggle_channel(ChannelName.PULSE1) @@ -1409,7 +1463,7 @@ def test_toggling_a_channel_silences_it( def test_toggling_a_channel_twice_returns_it_to_the_mix( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: channels_coordinator.toggle_channel(ChannelName.PULSE1) channels_coordinator.toggle_channel(ChannelName.PULSE1) @@ -1418,7 +1472,7 @@ def test_toggling_a_channel_twice_returns_it_to_the_mix( def test_the_menu_restores_every_channel_from_a_solo( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: channels_coordinator._sequencer_channels_logic.solo(ChannelName.TRIANGLE) @@ -1428,7 +1482,7 @@ def test_the_menu_restores_every_channel_from_a_solo( def test_a_menu_toggle_shows_in_both_tables( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: channels_coordinator.toggle_channel(ChannelName.TRIANGLE) @@ -1437,7 +1491,7 @@ def test_a_menu_toggle_shows_in_both_tables( def test_a_table_click_tells_the_menu_bar( self, - channels_coordinator: SequencerTabCoordinator, + channels_coordinator: SequencerReconstructions, ) -> None: channels_coordinator._sequencer_tracker_panel._on_header_clicked(0, True, ChannelName.TRIANGLE) @@ -1462,13 +1516,13 @@ def test_jump_delegates_to_history(self, history_coordinator: SequencerTabCoordi class TestUndoableWrapper: - def test_wrapped_call_runs_inside_a_transaction(self, history_coordinator: SequencerTabCoordinator) -> None: + def test_wrapped_call_runs_inside_a_transaction(self, recorder: SequencerHistoryRecorder) -> None: target = MagicMock() - wrapped = history_coordinator._undoable(HistoryAction.SET_TEMPO, target) + wrapped = recorder.undoable(HistoryAction.SET_TEMPO, target) wrapped(150) - history_coordinator._history.transaction.assert_called_once_with( + recorder._history.transaction.assert_called_once_with( HistoryAction.SET_TEMPO, detail=(), coalesce=None, @@ -1477,19 +1531,19 @@ def test_wrapped_call_runs_inside_a_transaction(self, history_coordinator: Seque def test_wrapped_call_passes_computed_detail( self, - history_coordinator: SequencerTabCoordinator, + recorder: SequencerHistoryRecorder, ) -> None: target = MagicMock() segments = (HistoryDetailSegment(text="v150", role=HistoryDetailRole.VALUE),) - wrapped = history_coordinator._undoable( + wrapped = recorder.undoable( HistoryAction.SET_TEMPO, target, detail=lambda _: segments, ) wrapped(150) - history_coordinator._history.transaction.assert_called_once_with( + recorder._history.transaction.assert_called_once_with( HistoryAction.SET_TEMPO, detail=segments, coalesce=None, @@ -1497,18 +1551,18 @@ def test_wrapped_call_passes_computed_detail( def test_wrapped_call_passes_computed_coalesce_key( self, - history_coordinator: SequencerTabCoordinator, + recorder: SequencerHistoryRecorder, ) -> None: target = MagicMock() - wrapped = history_coordinator._undoable( + wrapped = recorder.undoable( HistoryAction.SET_TEMPO, target, coalesce=lambda _: ("tempo",), ) wrapped(150) - history_coordinator._history.transaction.assert_called_once_with( + recorder._history.transaction.assert_called_once_with( HistoryAction.SET_TEMPO, detail=(), coalesce=("tempo",), @@ -1516,9 +1570,9 @@ def test_wrapped_call_passes_computed_coalesce_key( def test_wrapped_call_announces_one_song_change_for_the_whole_gesture( self, - history_coordinator: SequencerTabCoordinator, + recorder: SequencerHistoryRecorder, ) -> None: - controller = history_coordinator._project_controller + controller = recorder._project_controller announcements: List[str] = [] controller.on_song_changed = lambda: announcements.append("song") initial_length = controller.order_length @@ -1527,7 +1581,7 @@ def append_frames(count: int) -> None: for _ in range(count): controller.append_frame() - wrapped = history_coordinator._undoable(HistoryAction.EDIT_ROW, append_frames) + wrapped = recorder.undoable(HistoryAction.EDIT_ROW, append_frames) wrapped(3) assert controller.order_length == initial_length + 3 @@ -1535,12 +1589,14 @@ def append_frames(count: int) -> None: @pytest.fixture -def view_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the collaborators the history view build touches.""" - instance = object.__new__(SequencerTabCoordinator) - instance._history = MagicMock() - instance._language_manager = LanguageManager(LANG_EN) - return instance +def view_recorder() -> SequencerHistoryRecorder: + """A recorder with only the collaborators the history view build touches.""" + return SequencerHistoryRecorder( + MagicMock(), + MagicMock(), + MagicMock(), + language_manager=LanguageManager(LANG_EN), + ) def _detail_entry(value: str) -> HistoryEntry: @@ -1558,14 +1614,14 @@ def _detail_entry(value: str) -> HistoryEntry: class TestHistoryViewModelBuild: def test_an_entry_reaches_the_view_with_the_detail_it_was_committed_with( self, - view_coordinator: SequencerTabCoordinator, + view_recorder: SequencerHistoryRecorder, ) -> None: """A detail is built in the words it is read in, so the view shows what was stored.""" - view_coordinator._history.cursor = 1 + view_recorder._history.cursor = 1 entries = (_detail_entry("01"), _detail_entry("02")) - view_coordinator._history.entries = entries + view_recorder._history.entries = entries - view_model = view_coordinator._build_history_view_model() + view_model = view_recorder.view_model() assert [entry.detail_segments for entry in view_model.entries] == [entry.detail for entry in entries] @@ -1618,6 +1674,13 @@ def write(self, text: str) -> None: self.text = text +def _text_clipboard(coordinator: SequencerTabCoordinator) -> FakeTextClipboard: + """The desktop clipboard this coordinator's blocks were built over.""" + clipboard = coordinator._blocks._text_clipboard + assert isinstance(clipboard, FakeTextClipboard) + return clipboard + + @pytest.fixture def block_coordinator() -> SequencerTabCoordinator: """A coordinator whose block path is real, from the tracker logic through to the clipboard. @@ -1636,17 +1699,19 @@ def block_coordinator() -> SequencerTabCoordinator: instance._project_controller = controller instance._history = history instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) - instance._clipboard = SequencerClipboard() - instance._system_clipboard = FakeTextClipboard() - instance._tracker_block_text = TrackerBlockText(samples=ProjectSampleDirectory(controller)) - instance._order_block_text = OrderBlockText() - instance._tracker_text_cache = ParsedBlockCache(instance._tracker_block_text.parse) - instance._order_text_cache = ParsedBlockCache(instance._order_block_text.parse) - instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) - instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) instance._sequencer_order_logic = SequencerOrderLogic(controller) - instance._order_block_reader = OrderBlockReader(instance._sequencer_order_logic) - instance._order_block_writer = OrderBlockWriter(instance._sequencer_order_logic) + instance._recorder = SequencerHistoryRecorder( + history, + controller, + instance._sequencer_tracker_logic, + language_manager=MagicMock(), + ) + instance._blocks = SequencerBlocks( + instance._sequencer_tracker_logic, + instance._sequencer_order_logic, + controller, + text_clipboard=FakeTextClipboard(), + ) instance._history_detail = SequencerHistoryDetail( instance._sequencer_tracker_logic, MagicMock(), @@ -1658,11 +1723,11 @@ def block_coordinator() -> SequencerTabCoordinator: def _place_transpose( - coordinator: SequencerTabCoordinator, + coordinator: SequencerReconstructions, transpose: int, ) -> None: """Puts one value in the frame, through the same wrapper an edit reaches the history by.""" - edit = coordinator._undoable( + edit = coordinator._recorder.undoable( HistoryAction.EDIT_ROW, coordinator._sequencer_tracker_logic.write_cell, ) @@ -1682,9 +1747,9 @@ def test_a_copy_fills_the_clipboard_with_the_block_it_covers( transpose=5, ) - coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._blocks.copy_tracker(PULSE1_CELL) - block = coordinator._clipboard.tracker_block + block = coordinator._blocks._clipboard.tracker_block assert block is not None assert block.transposes[(0, 1)] == 5 @@ -1697,7 +1762,7 @@ def test_a_copy_leaves_the_history_stack_as_it_stands( _place_transpose(coordinator, 5) recorded = len(coordinator._history.entries) - coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._blocks.copy_tracker(PULSE1_CELL) assert recorded > 0 assert len(coordinator._history.entries) == recorded @@ -1715,7 +1780,7 @@ def test_a_cut_takes_the_block_and_empties_what_it_covered( coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) - block = coordinator._clipboard.tracker_block + block = coordinator._blocks._clipboard.tracker_block assert block is not None assert block.transposes[(0, 1)] == 5 assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).transpose is None @@ -1753,7 +1818,7 @@ def test_a_paste_writes_the_copied_block_in_one_entry( ) -> None: coordinator = block_coordinator _place_transpose(coordinator, 5) - coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._blocks.copy_tracker(PULSE1_CELL) recorded = len(coordinator._history.entries) coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE2)) @@ -1775,7 +1840,7 @@ def test_a_copy_fills_the_clipboard_and_leaves_the_history_as_it_stands( coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME) - block = coordinator._clipboard.order_block + block = coordinator._blocks._clipboard.order_block assert block is not None assert block.entries == {(0, 0): 0} assert len(coordinator._history.entries) == recorded @@ -1851,9 +1916,9 @@ def test_a_copy_states_the_block_as_text( coordinator = block_coordinator _place_transpose(coordinator, 5) - coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._blocks.copy_tracker(PULSE1_CELL) - assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." + assert _text_clipboard(coordinator).text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." def test_an_order_copy_states_its_own_grid( self, @@ -1861,9 +1926,9 @@ def test_an_order_copy_states_its_own_grid( ) -> None: coordinator = block_coordinator - coordinator._on_order_copy_block(PULSE1_FRAME) + coordinator._blocks.copy_order(PULSE1_FRAME) - assert coordinator._system_clipboard.text == "SampleToNES/1 order rows=1 positions=0..0\n00" + assert _text_clipboard(coordinator).text == "SampleToNES/1 order rows=1 positions=0..0\n00" def test_a_cut_states_the_block_it_took( self, @@ -1874,7 +1939,7 @@ def test_a_cut_states_the_block_it_took( coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) - assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." + assert _text_clipboard(coordinator).text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." class TestSystemClipboardPrecedence: @@ -1887,8 +1952,8 @@ def test_a_block_copied_elsewhere_is_the_one_a_paste_writes( """This is a second instance's copy arriving, which is what carries a block between them.""" coordinator = block_coordinator _place_transpose(coordinator, 5) - coordinator._on_tracker_copy_block(PULSE1_CELL) - coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + coordinator._blocks.copy_tracker(PULSE1_CELL) + _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) @@ -1900,8 +1965,8 @@ def test_unrelated_text_leaves_the_copied_block_in_hand( ) -> None: coordinator = block_coordinator _place_transpose(coordinator, 5) - coordinator._on_tracker_copy_block(PULSE1_CELL) - coordinator._system_clipboard.write("a line from a message") + coordinator._blocks.copy_tracker(PULSE1_CELL) + _text_clipboard(coordinator).write("a line from a message") coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) @@ -1913,8 +1978,8 @@ def test_a_truncated_block_leaves_the_copied_block_in_hand( ) -> None: coordinator = block_coordinator _place_transpose(coordinator, 5) - coordinator._on_tracker_copy_block(PULSE1_CELL) - coordinator._system_clipboard.write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .") + coordinator._blocks.copy_tracker(PULSE1_CELL) + _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .") coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) @@ -1926,8 +1991,8 @@ def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( ) -> None: """A tracker copy stands on the clipboard while the order pastes, so each grid keeps its own.""" coordinator = block_coordinator - coordinator._on_order_copy_block(PULSE1_FRAME) - coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + coordinator._blocks.copy_order(PULSE1_FRAME) + _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=1)) @@ -1940,9 +2005,9 @@ def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard( """The menu asks the same question the paste does, so it offers what the next press reaches.""" coordinator = block_coordinator - assert not coordinator._can_paste_tracker_block() + assert not coordinator._blocks.can_paste_tracker() - coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") - assert coordinator._can_paste_tracker_block() - assert not coordinator._can_paste_order_block() + assert coordinator._blocks.can_paste_tracker() + assert not coordinator._blocks.can_paste_order() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index e463d47a0..e7a52d782 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -13,6 +13,7 @@ from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker import adjust from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination @@ -265,7 +266,7 @@ def test_a_cursor_alone_shifts_the_cell_it_stands_on(self, monkeypatch: pytest.M region, delta = gestures.volume_shifted[-1] assert region.rows == range(CURSOR_ROW, CURSOR_ROW + 1) assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),) - assert delta == -tracker_module.VOLUME_FINE_STEP + assert delta == -adjust.VOLUME_FINE_STEP def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = Gestures() @@ -274,7 +275,7 @@ def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPat assert panel._on_key_pressed(_press("Ctrl+Shift+Up")) is True assert panel._on_key_pressed(_press("Alt+Shift+Up")) is True assert gestures.transposed[-1][1] == OCTAVE_SEMITONES - assert gestures.volume_shifted[-1][1] == tracker_module.VOLUME_COARSE_STEP + assert gestures.volume_shifted[-1][1] == adjust.VOLUME_COARSE_STEP def test_a_grid_with_no_cursor_shifts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = Gestures() From ae2540b868e4027398e7aec68fc0c03f53b6366c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 01:39:28 +0200 Subject: [PATCH 134/142] Restated: the architecture ledger and the smaller contracts the review found --- docs/development/architecture.md | 2 +- docs/development/bugs-and-todos.md | 26 ++++++++++ .../logic/reconstruction/editor.py | 4 +- .../logic/sequencer/history_detail.py | 3 +- .../logic/sequencer/tracker/tracker.py | 5 +- .../services/conversion/result.py | 13 ++--- .../services/result.py | 2 +- .../view_model/sequencer/kind.py | 18 +------ .../view_model/sequencer/voices.py | 10 ++++ .../project/voices/instrument.py | 7 ++- src/sampletones_core/project/voices/record.py | 5 -- .../session/application/test_tracker.py | 20 ++++++++ .../logic/reconstruction/test_editor.py | 2 +- .../reconstruction/test_instrument_history.py | 2 +- .../project/voices/test_record.py | 48 +++++++++++++++++++ 15 files changed, 126 insertions(+), 41 deletions(-) create mode 100644 tests/unit/sampletones_application/config/session/application/test_tracker.py create mode 100644 tests/unit/sampletones_core/project/voices/test_record.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index b64c293dc..39fca4369 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -362,7 +362,7 @@ There are two coordinator kinds: | Package | Purpose | |---------|---------| | `config/` | `ConfigManager` (domain generation config), `SessionManager` (runtime session: last paths, audio device, window geometry). Presentation-free: it records load outcomes (`ConfigLoadOutcome`) as domain data for `ConfigCoordinator` to present. Must not import the visual packages, `coordinators/`, or `application.py` | -| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy, the `AbstractElement` base and the panel element enums under `categories/elements/`, and the key grammar under `categories/key/` | +| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy, the `AbstractElement` base and the panel element enums under `categories/elements/`, and the key grammar under `categories/key/`. It also holds the message bundles that resolve a whole conversation's words in one place — `export.py`, `exports.py`, `instrument.py`, `pitch.py` — so a coordinator reads its texts once and hands the bundle to whoever phrases the outcome | | `constants/` | Application-scope facts that carry no behavior, one module per subject — `keybindings.py` names the scheme a build ships, which both the shortcut catalog and the session config read, and `playback.py` names the follow mode, which the session config, the song player, the view models and the menu all state. A fact shared beyond the application belongs to `sampletones_shared/constants/` | | `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` | | `tags/` | DPG widget tags (`TAG_*`), the fragments composing into them (`SUF_*`, `PRE_*`), and `compose_tag` | diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 0e301b222..82b57d029 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -74,6 +74,32 @@ starts carrying. * In-application console * Improve performance of browser favorite scan of the entire tree per click +## Architecture + +Where the codebase stands apart from `docs/development/architecture.md`. A deviation is recorded +here once review has seen it and let it stand, so this section — rather than the code — is the +memory of what is currently out of line, and an entry leaves when the code meets the contract +again. + +* The two sequencer grids state the same machinery twice. `ui/panels/sequencer/order/` and + `ui/panels/sequencer/tracker/` each divide into a panel and its collaborators, and the panel + modules still declare the same block and channel hooks, build the same `ChannelSwitch`, tint a + channel the same way, and run the same held-pointer drag, right-click hit-test and key dispatch. + `ui/panels/sequencer/grid/` is where both already reach for what they share, and it is where + these belong; the `# TODO: to abstract` markers stand at the blocks themselves. Pylint's + duplicate-code report names each pair, so the work is enumerable rather than a matter of + reading. +* `application.py` and `ui/elements/tree/tree.py` each hold several concerns in one module, past + the size at which the sequencer panels and the sequencer tab coordinator were divided into + subpackages. Each divides the same way: a module per concern, with the class that stays holding + the collaborators and the public surface. +* Several directories under `ui/` carry modules without an `__init__.py`, which leaves each one a + namespace package. A tool reading the tree treats such a directory as a root it can import from, + so a module inside one answers for a standard-library name of the same word: `ui/elements/trace.py` + stands against `trace` this way, and `ui/elements/graphs/layers/array.py` did against `array` + until it was given a package of its own. Giving each directory an `__init__.py` closes the + whole class. + ## Bugs * No refreshing after library generation diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py index 31c148b34..fb593d70c 100644 --- a/src/sampletones_application/logic/reconstruction/editor.py +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -77,11 +77,11 @@ def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> No step per value it passed through. Raises: - TypeError: If the tab holds no instrument to write into. + RuntimeError: If the tab holds no instrument to write into. """ instrument = self.instrument if instrument is None: - raise TypeError("The tab holds no instrument to write an envelope into") + raise RuntimeError("The tab holds no instrument to write an envelope into") with self._history.transaction( HistoryAction.EDIT_INSTRUMENT, diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index d6448ed5e..faf115472 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -70,7 +70,8 @@ def _kind_role(kind: Optional[VoiceKind]) -> HistoryDetailRole: """The role a voice reads under, so its line wears the color of the kind it is about. A voice the pool has stopped holding keeps the plain voice role, the same one the tracker's - voice slot wears while it names nothing. + voice slot wears while it names nothing. A tracker cell naming a voice since removed is read + that way, the same as :func:`display_voice` reads its position. """ if kind is None: return HistoryDetailRole.VOICE diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index abdaf1a18..709633b18 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -2,7 +2,6 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.view_model.sequencer.kind import ( - places_across_channels, voice_kind, ) from sampletones_application.view_model.sequencer.settings import ( @@ -247,7 +246,7 @@ def places_in_sample_column(self, voice_id: str) -> bool: if voice is None: return False - return places_across_channels(voice_kind(voice)) + return voice_kind(voice).places_across_channels def cut_note( self, @@ -674,7 +673,7 @@ def _referenced_generators_from_rows( voice = self._controller.project.voices.get(voice_id) if voice is None: relevant.add(channel) - elif places_across_channels(voice_kind(voice)): + elif voice_kind(voice).places_across_channels: relevant.update(self._used_generators(voice)) return frozenset(relevant) diff --git a/src/sampletones_application/services/conversion/result.py b/src/sampletones_application/services/conversion/result.py index 8037303f5..41caa70e4 100644 --- a/src/sampletones_application/services/conversion/result.py +++ b/src/sampletones_application/services/conversion/result.py @@ -1,8 +1,7 @@ +from dataclasses import dataclass from pathlib import Path from typing import Optional, Tuple, Union -from pydantic import BaseModel, ConfigDict - from sampletones_application.services.result import ( ServiceCanceled, ServiceError, @@ -15,7 +14,8 @@ from sampletones_core.reconstructions.stage import ReconstructionStage -class ReconstructionStep(BaseModel): +@dataclass(frozen=True) +class ReconstructionStep: """What the reconstruction under way is doing, in the unit that work counts in. A conversion counts the files it writes, which for one recording — or one set of stems, since @@ -23,14 +23,13 @@ class ReconstructionStep(BaseModel): what a reader watching a single conversion has to go on. """ - model_config = ConfigDict(frozen=True) - stage: ReconstructionStage completed: int total: int -class ConversionItem(BaseModel): +@dataclass(frozen=True) +class ConversionItem: """The reconstruction a conversion is building, and what it is doing to build it. A run knows which recording it is reading from the moment it starts, and hears what that @@ -38,8 +37,6 @@ class ConversionItem(BaseModel): an item that already names its source. """ - model_config = ConfigDict(frozen=True) - source: Path step: Optional[ReconstructionStep] = None diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index 431689d61..dfc64e881 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -38,7 +38,7 @@ class ServiceProgress(Generic[T]): def fraction(self) -> float: """How full the operation stands, the item under way counted for the part of it done.""" if self.total == NOTHING_TO_DO: - return 0.0 + return NOTHING_UNDER_WAY return (self.completed + self.partial) / self.total diff --git a/src/sampletones_application/view_model/sequencer/kind.py b/src/sampletones_application/view_model/sequencer/kind.py index 98e243527..b00335897 100644 --- a/src/sampletones_application/view_model/sequencer/kind.py +++ b/src/sampletones_application/view_model/sequencer/kind.py @@ -23,22 +23,6 @@ def voice_kind(voice: VoiceUnion) -> VoiceKind: return VoiceKind.INSTRUMENT -def places_across_channels(kind: VoiceKind) -> bool: - """Whether the tracker's sample column can place a voice of this kind. - - The column writes a voice to every channel it covers and clears the rest, which a recording - states for itself. A hand-written instrument sounds wherever its envelopes make a frame, so the - channel it plays on is the reader's to name and it is placed in a channel column. - - Args: - kind: The kind of the voice being placed. - - Returns: - bool: Whether the sample column takes it. - """ - return kind is VoiceKind.SAMPLE - - def column_takes(channel: Optional[ChannelName], kind: VoiceKind) -> bool: """Whether the column a cell stands in places a voice of this kind. @@ -56,4 +40,4 @@ def column_takes(channel: Optional[ChannelName], kind: VoiceKind) -> bool: if channel is not None: return True - return places_across_channels(kind) + return kind.places_across_channels diff --git a/src/sampletones_application/view_model/sequencer/voices.py b/src/sampletones_application/view_model/sequencer/voices.py index 4334363bf..580c5bef0 100644 --- a/src/sampletones_application/view_model/sequencer/voices.py +++ b/src/sampletones_application/view_model/sequencer/voices.py @@ -16,6 +16,16 @@ class VoiceKind(StrEnum): SAMPLE = "sample" INSTRUMENT = "instrument" + @property + def places_across_channels(self) -> bool: + """Whether the tracker's sample column can place a voice of this kind. + + The column writes a voice to every channel it covers and clears the rest, which a recording + states for itself. A hand-written instrument sounds wherever its envelopes make a frame, so + the channel it plays on is the reader's to name and it is placed in a channel column. + """ + return self is VoiceKind.SAMPLE + class VoiceEntryViewModel(BaseModel, frozen=True): voice_id: str diff --git a/src/sampletones_core/project/voices/instrument.py b/src/sampletones_core/project/voices/instrument.py index 95a221f30..19f9dabf1 100644 --- a/src/sampletones_core/project/voices/instrument.py +++ b/src/sampletones_core/project/voices/instrument.py @@ -158,7 +158,12 @@ def instructions(self, channel_name: ChannelName) -> List[InstructionUnion]: return self._instructions[channel_name] def invalidate(self) -> None: - """Drops the memoized frames so they are made afresh from the envelopes they describe.""" + """Drops the memoized frames so they are made afresh from the envelopes they describe. + + The frames are read from the envelopes once and kept, so whoever writes ``envelopes`` + calls this in the same breath. That pairing is what keeps what an instrument plays and + what it states the same thing. + """ self.__dict__.pop("_instructions", None) def clone(self) -> Self: diff --git a/src/sampletones_core/project/voices/record.py b/src/sampletones_core/project/voices/record.py index 9929e7ea9..0d0a67b3a 100644 --- a/src/sampletones_core/project/voices/record.py +++ b/src/sampletones_core/project/voices/record.py @@ -19,8 +19,3 @@ class SampleRecord(BaseModel): VoiceRecord = Annotated[Union[SampleRecord, Instrument], Field(discriminator="kind")] -"""The on-disk form of one voice, told apart by its ``kind``. - -A sample is written as a reference to the reconstruction stored beside the document, while an instrument -carries only what it states and is written whole. -""" diff --git a/tests/unit/sampletones_application/config/session/application/test_tracker.py b/tests/unit/sampletones_application/config/session/application/test_tracker.py new file mode 100644 index 000000000..2922dfa4c --- /dev/null +++ b/tests/unit/sampletones_application/config/session/application/test_tracker.py @@ -0,0 +1,20 @@ +import pytest +from pydantic import ValidationError + +from sampletones_application.config.session.application.tracker import TrackerConfig +from sampletones_application.constants.tracker import MAX_OCTAVE, MIN_OCTAVE + + +class TestOctaveBounds: + """The typing octave is held to the range the tracker's own octave field offers.""" + + @pytest.mark.parametrize("octave", [MIN_OCTAVE - 1, MAX_OCTAVE + 1]) + def test_an_octave_outside_the_range_is_rejected(self, octave: int) -> None: + with pytest.raises(ValidationError): + TrackerConfig(octave=octave) + + @pytest.mark.parametrize("octave", [MIN_OCTAVE, MAX_OCTAVE]) + def test_each_end_of_the_range_is_accepted(self, octave: int) -> None: + config = TrackerConfig(octave=octave) + + assert config.octave == octave diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py index 2e71574eb..c2fb0ccc8 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -161,5 +161,5 @@ def test_a_point_written_on_an_envelope_reaches_the_instrument( assert instrument.envelopes.volume.loop_point == 0 def test_a_write_with_no_instrument_in_front_is_refused(self, editor: InstrumentEditor) -> None: - with pytest.raises(TypeError): + with pytest.raises(RuntimeError): editor.write_envelope(FeatureKey.VOLUME, Envelope(items=VOLUME)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py index 9e0dac3aa..54565188d 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py @@ -124,5 +124,5 @@ def test_two_voices_are_two_entries(self, harness: _Harness) -> None: assert [entry.action for entry in harness.history.entries[1:]].count(HistoryAction.EDIT_INSTRUMENT) == 2 def test_writing_with_no_instrument_open_is_refused(self, harness: _Harness) -> None: - with pytest.raises(TypeError): + with pytest.raises(RuntimeError): harness.write(FeatureKey.VOLUME, 15) diff --git a/tests/unit/sampletones_core/project/voices/test_record.py b/tests/unit/sampletones_core/project/voices/test_record.py new file mode 100644 index 000000000..1e7af612c --- /dev/null +++ b/tests/unit/sampletones_core/project/voices/test_record.py @@ -0,0 +1,48 @@ +import pytest +from pydantic import TypeAdapter, ValidationError + +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.record import SampleRecord, VoiceRecord + +SAMPLE_KIND = "sample" +INSTRUMENT_KIND = "instrument" + +_RECORDS = TypeAdapter(VoiceRecord) + + +def _sample_record() -> SampleRecord: + return SampleRecord(id="voice-id", name="Bass", reconstruction_id="reconstruction-id") + + +class TestTheKindADocumentNamesAVoiceBy: + """A ``.stn`` tells its two kinds of voice apart by ``kind``, so the word is the format. + + A document written by any release names a sample ``"sample"`` and a hand-written voice + ``"instrument"``; reading one back turns on those exact words, which is why they are stated + here rather than read from the models. + """ + + def test_a_sample_is_written_under_its_own_word(self) -> None: + assert _sample_record().model_dump()["kind"] == SAMPLE_KIND + + def test_an_instrument_is_written_under_its_own_word(self) -> None: + assert Instrument(name="Pad").model_dump()["kind"] == INSTRUMENT_KIND + + def test_a_sample_record_is_read_back_as_one(self) -> None: + restored = _RECORDS.validate_python(_sample_record().model_dump()) + + assert isinstance(restored, SampleRecord) + assert restored.reconstruction_id == "reconstruction-id" + + def test_an_instrument_record_is_read_back_as_one(self) -> None: + restored = _RECORDS.validate_python(Instrument(name="Pad").model_dump()) + + assert isinstance(restored, Instrument) + assert restored.name == "Pad" + + def test_a_word_neither_kind_answers_to_is_refused(self) -> None: + record = _sample_record().model_dump() + record["kind"] = "recording" + + with pytest.raises(ValidationError): + _RECORDS.validate_python(record) From 0ad2113a7a26dcea13eb9ba71f5ff1a808e2a295 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 03:13:20 +0200 Subject: [PATCH 135/142] Sounded: an audition through to the release the voice states --- docs/development/playback.md | 15 +++ .../constants/instruments.py | 1 + .../coordinators/tabs/reconstruction.py | 1 + .../logic/reconstruction/audition.py | 32 +++++- .../logic/sequencer/voices.py | 4 +- src/sampletones_core/audio/manager.py | 9 +- src/sampletones_core/features/envelope.py | 18 +++ .../formats/famitracker/sequences/features.py | 10 +- src/sampletones_core/performance/audition.py | 45 +++++++- .../logic/reconstruction/test_audition.py | 54 ++++++++- .../logic/sequencer/test_voices.py | 33 ++++++ .../performance/test_audition.py | 105 +++++++++++++++++- 12 files changed, 297 insertions(+), 30 deletions(-) diff --git a/docs/development/playback.md b/docs/development/playback.md index 88102df9f..b4a3886a8 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -51,6 +51,21 @@ types in, and renders the voice through the same two steps a tracker row takes instrument's own pitch to the note, at full volume. The plot card draws the same rendering at the pitch the instrument stands at, so what is seen and what is heard name one generator. +**A voice sounded on its own runs to its release, or to the length it is offered.** A row holds a +voice for as long as the pattern asks, while an audition and the voice list's preview have no row +behind them, so each states a span of its own. A volume dimension ending at silence releases the +voice, and that release is where the sound stops; one circling from a loop point never reaches a +last item, so it sounds for the ticks `AUDITION_TICKS` offers it. Both spans are counted by +`audition_ticks` (`sampletones_core/performance/audition.py`), so the plot card draws exactly the +frames the keyboard sounds. + +**A sounding voice is marked where it has reached.** The device reports its position while it +plays, and the plot card carries that mark along the voice it drew, the same mark a reconstruction's +playback moves. A preview follows its own sound alone: `play` reports whether the request took the +output, and the audition starts following only when it did, so one that yields to playback the +reader asked for leaves that playback's mark where it is. The device reports a final zero as it +winds down, which takes the mark off the card. + **Intentional playback** — the audio a tab is built around: a reconstruction's audio, an instruction's audio, or the sequencer song. It is owned by the source that started it, and it is resumable, seekable, and stoppable. One intentional source at most is engaged at any moment. diff --git a/src/sampletones_application/constants/instruments.py b/src/sampletones_application/constants/instruments.py index 03d802ef9..d7345e6fa 100644 --- a/src/sampletones_application/constants/instruments.py +++ b/src/sampletones_application/constants/instruments.py @@ -4,3 +4,4 @@ INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 AUDITION_GENERATOR: Final[GeneratorName] = GeneratorName.PULSE +AUDITION_TICKS: Final[int] = 30 diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 4d395a16c..4e8891363 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -324,6 +324,7 @@ def __init__( ) self._instrument_audition_logic.on_audition_error = self._on_preview_error self._instrument_audition_logic.on_waveform_changed = self._reconstruction_plot_panel.update_instrument_view + self._instrument_audition_logic.on_position_changed = self._reconstruction_plot_panel.set_playback_position self._reconstruction_instruments_logic.on_display_refreshed = self._instrument_audition_logic.refresh def _on_export_result(self, result: ExportResult) -> None: diff --git a/src/sampletones_application/logic/reconstruction/audition.py b/src/sampletones_application/logic/reconstruction/audition.py index 6bc9e7d7f..5b84be2bd 100644 --- a/src/sampletones_application/logic/reconstruction/audition.py +++ b/src/sampletones_application/logic/reconstruction/audition.py @@ -3,12 +3,13 @@ import numpy as np from sampletones_application.config.managers.session import SessionManager -from sampletones_application.constants.instruments import AUDITION_GENERATOR +from sampletones_application.constants.instruments import AUDITION_GENERATOR, AUDITION_TICKS from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.reconstruction.editing import ( InstrumentAuditionProtocol, ) from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.reconstruction.waveform import ( InstrumentWaveformViewModel, ) @@ -16,7 +17,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, GeneratorName from sampletones_core.features import generator_channel, speaks_in_periods -from sampletones_core.performance.audition import audition_audio +from sampletones_core.performance.audition import audition_audio, audition_ticks from sampletones_core.project.voices.instrument import Instrument from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES from sampletones_shared.exceptions import PlaybackError @@ -49,6 +50,7 @@ def __init__( self.on_audition_error: Optional[Callable[[Exception], None]] = None self.on_waveform_changed: Optional[Callable[[Optional[InstrumentWaveformViewModel]], None]] = None + self.on_position_changed: Optional[Callable[[int], None]] = None def set_generator(self, generator_name: GeneratorName) -> None: """Takes the generator the voice is auditioned as, redrawing it as the one now chosen.""" @@ -79,6 +81,7 @@ def sound(self, semitone: int) -> None: channel_name, self._audition_config(), pitch=self._sounding_pitch(instrument, channel_name, semitone), + ticks=audition_ticks(instrument, cap=AUDITION_TICKS), ) if audio is None: return @@ -102,6 +105,7 @@ def _waveform(self) -> Optional[InstrumentWaveformViewModel]: channel_name, config, pitch=instrument.reference(channel_name), + ticks=audition_ticks(instrument, cap=AUDITION_TICKS), ) if audio is None: return None @@ -138,11 +142,18 @@ def _audition_config(self) -> Config: ) def _play(self, audio: np.ndarray, voice_id: str) -> None: + """Sounds the rendering, following it with a cursor while the audition holds the output. + + A preview yields to playback the reader asked for, so the cursor is followed only once the + audition has the device: an audition that stands aside leaves the mark of whatever is + sounding where it is. + """ try: - self._audio_device_manager.play( + sounding = self._audio_device_manager.play( audio, - update=False, + update=True, priority=PlaybackPriority.PREVIEW, + owner=self, ) except (PlaybackError, ValueError) as exception: logger.error_with_traceback( @@ -150,3 +161,16 @@ def _play(self, audio: np.ndarray, voice_id: str) -> None: f"Failed to audition instrument: {voice_id}", ) self.call(self.on_audition_error, exception) + return + + if sounding: + self._audio_device_manager.set_position_callback(self._on_device_position) + + def _on_device_position(self, position: int) -> None: + """Carries the sounding position from the playback thread to the card that draws it. + + The device reports from the thread writing the audio, and the mark is a widget, so the + report crosses to the render thread the way every other background result does. The device + reports a final zero as it winds down, which is what takes the mark off the card. + """ + CallbackQueue.add(self.call, self.on_position_changed, position) diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index a59ad494e..f00488f85 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -4,6 +4,7 @@ import numpy as np from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.instruments import AUDITION_TICKS from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) @@ -30,7 +31,7 @@ ImportedVoice, instrument_to_voice, ) -from sampletones_core.performance.audition import audition_audio +from sampletones_core.performance.audition import audition_audio, audition_ticks from sampletones_core.project.voices.creation import ( instrument_from_features, new_instrument, @@ -302,6 +303,7 @@ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]: PREVIEW_CHANNEL, self._preview_config(), pitch=instrument.reference(PREVIEW_CHANNEL), + ticks=audition_ticks(instrument, cap=AUDITION_TICKS), ) case _: return None diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 66530df58..37427e8b9 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -521,7 +521,7 @@ def play( update: bool = True, priority: int = 0, owner: Optional[Any] = None, - ) -> None: + ) -> bool: """ Play audio data. @@ -539,6 +539,10 @@ def play( priority: Output-request priority; higher wins. Callers assign the meaning. owner: Identity of the caller owning this playback, matched by :meth:`replace_audio` to swap the live buffer only while its own audio is the one playing. + + Returns: + bool: Whether this request took the output, which is what a caller following its own + playback — drawing a cursor along it — waits for before it starts following. """ external_priority = self.call(self.external_output_priority) with self._lock: @@ -548,7 +552,7 @@ def play( (priority for priority in (internal_priority, external_priority) if priority is not None), default=None ) if held is not None and priority < held: - return + return False self.stop() if external_priority is not None: @@ -572,6 +576,7 @@ def play( ) self._playback_thread.start() + return True def replace_audio( self, diff --git a/src/sampletones_core/features/envelope.py b/src/sampletones_core/features/envelope.py index 9210cec20..feb60b980 100644 --- a/src/sampletones_core/features/envelope.py +++ b/src/sampletones_core/features/envelope.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from sampletones_core.constants.general import SILENT_VOLUME + ItemT = TypeVar("ItemT") @@ -123,3 +125,19 @@ def _holding(self, items: Tuple[ItemT, ...]) -> Envelope[ItemT]: """This dimension carrying ``items``, with the loop point held inside them.""" loop_point = min(self.loop_point, len(items) - 1) if self.loop_point is not None and items else None return type(self)(items=items, loop_point=loop_point) + + +def releases(volume: Envelope[int]) -> bool: + """Whether a volume dimension ends by silencing the note, which is what releases it. + + A dimension holds its last item for as long as a note sounds, so one ending at silence goes + quiet and stays quiet: that is where the note it belongs to ends. One circling from a loop + point never reaches a last item, so it sounds until whoever started it stops asking. + + Args: + volume: The volume dimension of the voice being sounded. + + Returns: + bool: Whether the note ends where the dimension runs out. + """ + return bool(volume.items) and volume.items[-1] == SILENT_VOLUME and not volume.loops diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index a03a1fa21..6ed6fdea1 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -1,10 +1,9 @@ from typing import Dict, Final, Optional from sampletones_core.constants.enums import FeatureKey -from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.feature import Features from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.features.envelope import Envelope +from sampletones_core.features.envelope import Envelope, releases from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( FEATURE_KEY_TO_SEQUENCE_KIND, @@ -53,7 +52,7 @@ def stored_envelope( Returns: Envelope[int]: The dimension within the items the file holds. """ - if feature_key is FeatureKey.VOLUME and _releases(envelope): + if feature_key is FeatureKey.VOLUME and releases(envelope): return _keeping_release(envelope, MAX_SEQUENCE_ITEMS) return envelope.limited(MAX_SEQUENCE_ITEMS) @@ -108,11 +107,6 @@ def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]: } -def _releases(envelope: Envelope[int]) -> bool: - """Whether a volume dimension ends by silencing the note, which is what releases it.""" - return bool(envelope.items) and envelope.items[-1] == SILENT_VOLUME and not envelope.loops - - def _keeping_release(envelope: Envelope[int], limit: int) -> Envelope[int]: """This dimension within ``limit`` items, the last of them the release it ends on.""" if len(envelope.items) <= limit: diff --git a/src/sampletones_core/performance/audition.py b/src/sampletones_core/performance/audition.py index 58bdc87cb..4efff1eb9 100644 --- a/src/sampletones_core/performance/audition.py +++ b/src/sampletones_core/performance/audition.py @@ -5,36 +5,64 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features.envelope import releases from sampletones_core.generators.render import render_instructions from sampletones_core.instructions import InstructionUnion from sampletones_core.performance.modifiers import apply_modifiers from sampletones_core.project.voices.instrument import Instrument +def audition_ticks(instrument: Instrument, *, cap: int) -> int: + """How long an audition of one voice sounds, in ticks. + + A voice whose volume ends at silence releases itself, so it is sounded through to that release + and stops where a tracker row holding it would. One that circles from a loop point goes on for + as long as it is asked to, so it sounds for ``cap`` — the length an audition offers a voice + that never ends of its own accord. + + Args: + instrument: The voice being sounded. + cap: The ticks a voice sounds for where it states no release of its own. + + Returns: + int: The ticks the audition sounds. + """ + volume = instrument.envelopes.volume + if releases(volume): + return len(volume.items) + + return cap + + def audition_instructions( instrument: Instrument, channel_name: ChannelName, *, pitch: int, + ticks: int, ) -> List[InstructionUnion]: """The frames an instrument sounds on one channel at one note, at full volume. An instrument's frames are built at the reference the channel reads, so sounding it at a note is the step from that reference to the note — the same step a tracker row states, taken here - without a row to state it. The whole envelope is sounded through, which is what a listener - hears of a voice standing on its own. + without a row to state it. A voice sounds past the frames it writes the way a held row sounds + it: each dimension circles from its own loop point or holds its last item. Args: instrument: The voice being sounded. channel_name: The channel it is sounded on. pitch: The note it sounds at, read as a period on the noise channel. + ticks: How many ticks to sound, as :func:`audition_ticks` counts them. Returns: List[InstructionUnion]: The frames, empty where the instrument writes no envelope. """ + if not instrument.instructions(channel_name): + return [] + transpose = pitch - instrument.reference(channel_name) return [ - apply_modifiers(instruction, transpose, MAX_VOLUME) for instruction in instrument.instructions(channel_name) + apply_modifiers(instrument.instruction_at(channel_name, tick), transpose, MAX_VOLUME) for tick in range(ticks) ] @@ -44,23 +72,30 @@ def audition_audio( config: Config, *, pitch: int, + ticks: int, ) -> Optional[np.ndarray]: """The audio an instrument sounds on one channel at one note. This is what an audition plays and what a plot of a hand-written voice draws, so both answer - the same generator with the same waveform. + the same generator with the same waveform over the same span. Args: instrument: The voice being sounded. channel_name: The channel it is sounded on. config: The configuration the frames are rendered at. pitch: The note it sounds at, read as a period on the noise channel. + ticks: How many ticks to sound, as :func:`audition_ticks` counts them. Returns: Optional[np.ndarray]: The waveform to play, or ``None`` where the instrument writes no envelope for that channel. """ - instructions = audition_instructions(instrument, channel_name, pitch=pitch) + instructions = audition_instructions( + instrument, + channel_name, + pitch=pitch, + ticks=ticks, + ) if not instructions: return None diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_audition.py b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py index 949cf2196..24b51d54d 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_audition.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py @@ -5,7 +5,7 @@ import pytest from sampletones_application.config.managers.session import SessionManager -from sampletones_application.constants.instruments import AUDITION_GENERATOR +from sampletones_application.constants.instruments import AUDITION_GENERATOR, AUDITION_TICKS from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.reconstruction.audition import InstrumentAuditionLogic @@ -17,7 +17,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, GeneratorName from sampletones_core.features.envelope import Envelope -from sampletones_core.performance.audition import audition_audio +from sampletones_core.performance.audition import audition_audio, audition_ticks from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument from sampletones_shared.exceptions import PlaybackError @@ -110,7 +110,13 @@ def _expected( nes_frequency=settings.nes_frequency, sample_rate=settings.sample_rate, ) - audio = audition_audio(instrument, channel_name, config, pitch=pitch) + audio = audition_audio( + instrument, + channel_name, + config, + pitch=pitch, + ticks=audition_ticks(instrument, cap=AUDITION_TICKS), + ) assert audio is not None return audio @@ -187,6 +193,42 @@ def test_an_audition_yields_to_the_playback_a_reader_asked_for( assert device.play.call_args.kwargs["priority"] is PlaybackPriority.PREVIEW +class TestTheCursorAnAuditionDraws: + """A voice being sounded carries a mark along the waveform the card draws.""" + + def test_an_audition_that_sounds_is_followed( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + """The device reports where it has reached, which is what moves the mark.""" + device.play.return_value = True + logic = _logic(_instrument(), controller, session, device) + marked: List[int] = [] + logic.on_position_changed = marked.append + + logic.sound(SEMITONE_G) + device.set_position_callback.call_args.args[0](512) + + assert device.play.call_args.kwargs["update"] is True + assert marked == [512] + + def test_an_audition_standing_aside_leaves_the_mark_where_it_is( + self, + controller: ProjectController, + session: MagicMock, + device: MagicMock, + ) -> None: + """A preview outranked by playback the reader asked for follows nothing.""" + device.play.return_value = False + logic = _logic(_instrument(), controller, session, device) + + logic.sound(SEMITONE_G) + + device.set_position_callback.assert_not_called() + + class TestWhenNothingSounds: def test_a_tab_holding_no_instrument_plays_nothing( self, @@ -283,7 +325,8 @@ def test_the_voice_is_drawn_under_its_own_name_and_frame_length( session: MagicMock, device: MagicMock, ) -> None: - logic = _logic(_instrument(), controller, session, device) + instrument = _instrument() + logic = _logic(instrument, controller, session, device) drawn: List[Optional[InstrumentWaveformViewModel]] = [] logic.on_waveform_changed = drawn.append @@ -297,7 +340,8 @@ def test_the_voice_is_drawn_under_its_own_name_and_frame_length( assert drawn[-1] is not None assert drawn[-1].name == "lead" assert drawn[-1].frame_length == config.frame_length - assert drawn[-1].audio.shape == (2 * config.frame_length,) + sounded = audition_ticks(instrument, cap=AUDITION_TICKS) + assert drawn[-1].audio.shape == (sounded * config.frame_length,) def test_a_tab_holding_a_recording_leaves_the_card_to_it( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 1d68dbc69..db0b956fb 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -5,12 +5,14 @@ import numpy as np import pytest +from sampletones_application.constants.instruments import AUDITION_TICKS from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.view_model.sequencer.voices import VoiceKind from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel +from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters.naming import instrument_slice_name from sampletones_core.features.envelope import Envelope @@ -454,6 +456,28 @@ def test_an_instrument_previews_through_the_pulse_channel(self) -> None: played = audio_device_manager.play.call_args.args[0] assert played.size > 0 + def test_a_sustaining_instrument_previews_for_the_length_a_voice_is_offered(self) -> None: + """A voice that never releases would otherwise preview as the single frame it writes.""" + controller, logic, _, audio_device_manager = _logic_with_mocks() + instrument = logic.add_new_instrument("lead") + + logic.play_voice(instrument.id) + + played = audio_device_manager.play.call_args.args[0] + frame_length = _preview_config(controller).frame_length + assert played.size == AUDITION_TICKS * frame_length + + def test_an_instrument_releasing_itself_previews_only_that_far(self) -> None: + controller, logic, _, audio_device_manager = _logic_with_mocks() + instrument = logic.add_new_instrument("lead") + controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 8, 0))) + + logic.play_voice(instrument.id) + + played = audio_device_manager.play.call_args.args[0] + frame_length = _preview_config(controller).frame_length + assert played.size == 3 * frame_length + def test_an_instrument_writing_nothing_sounds_no_preview(self) -> None: controller, logic, _, audio_device_manager = _logic_with_mocks() instrument = controller.add_instrument(Instrument(name="lead")) @@ -463,6 +487,15 @@ def test_an_instrument_writing_nothing_sounds_no_preview(self) -> None: audio_device_manager.play.assert_not_called() +def _preview_config(controller: ProjectController) -> Config: + """The configuration a preview renders at, which is the project's own rate and sample rate.""" + settings = controller.project.settings + return Config().with_library( + nes_frequency=settings.nes_frequency, + sample_rate=settings.sample_rate, + ) + + def _tracker_instrument( name: str, *sequences: InstrumentSequence, diff --git a/tests/unit/sampletones_core/performance/test_audition.py b/tests/unit/sampletones_core/performance/test_audition.py index 488ce5111..b8bfd3e41 100644 --- a/tests/unit/sampletones_core/performance/test_audition.py +++ b/tests/unit/sampletones_core/performance/test_audition.py @@ -6,7 +6,11 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.features.envelope import Envelope -from sampletones_core.performance.audition import audition_audio, audition_instructions +from sampletones_core.performance.audition import ( + audition_audio, + audition_instructions, + audition_ticks, +) from sampletones_core.project.voices.envelopes import InstrumentEnvelopes from sampletones_core.project.voices.instrument import Instrument @@ -15,6 +19,8 @@ TYPED_PITCH: Final[int] = 67 NES_FREQUENCY: Final[int] = 60 SAMPLE_RATE: Final[int] = 44100 +WRITTEN_TICKS: Final[int] = 3 +CAP: Final[int] = 24 def _instrument() -> Instrument: @@ -44,6 +50,7 @@ def test_the_reference_cancels_so_a_voice_sounds_at_the_note_asked_for(self) -> _instrument(), ChannelName.PULSE1, pitch=TYPED_PITCH, + ticks=WRITTEN_TICKS, ) assert [instruction.pitch for instruction in instructions] == [ @@ -58,6 +65,7 @@ def test_sounding_at_its_own_reference_leaves_the_frames_as_they_stand(self) -> instrument, ChannelName.PULSE1, pitch=REFERENCE_PITCH, + ticks=WRITTEN_TICKS, ) assert instructions == instrument.instructions(ChannelName.PULSE1) @@ -67,6 +75,7 @@ def test_a_voice_sounds_at_full_volume_however_loud_its_envelope_is(self) -> Non _instrument(), ChannelName.PULSE1, pitch=TYPED_PITCH, + ticks=WRITTEN_TICKS, ) assert [instruction.volume for instruction in instructions] == [15, 12, 9] @@ -76,6 +85,7 @@ def test_the_noise_channel_walks_the_periods_its_arpeggio_names(self) -> None: _instrument(), ChannelName.NOISE, pitch=REFERENCE_PERIOD, + ticks=WRITTEN_TICKS, ) assert [instruction.period for instruction in instructions] == [8, 12, 15] @@ -85,18 +95,89 @@ def test_a_channel_reads_the_dimensions_its_generator_offers(self) -> None: _instrument(), ChannelName.TRIANGLE, pitch=TYPED_PITCH, + ticks=WRITTEN_TICKS, ) assert all(instruction.on for instruction in instructions) +class TestHowLongAnAuditionSounds: + """A voice sounded on its own runs to the release it states, or to the length it is offered.""" + + def test_a_voice_whose_volume_ends_in_silence_stops_where_it_releases(self) -> None: + """The dimension holds its last item forever, so a final zero is where the note ends.""" + instrument = Instrument( + name="decay", + envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15, 9, 3, 0))), + ) + + assert audition_ticks(instrument, cap=CAP) == 4 + + def test_a_voice_that_sustains_sounds_for_the_length_it_is_offered(self) -> None: + """A dimension circling from a loop point never reaches a last item, so nothing ends it.""" + instrument = Instrument( + name="sustain", + envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15,), loop_point=0)), + ) + + assert audition_ticks(instrument, cap=CAP) == CAP + + def test_a_volume_circling_back_through_silence_still_sounds_on(self) -> None: + """A zero the dimension loops past is a silent tick, and the note goes on past it.""" + instrument = Instrument( + name="pulsing", + envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15, 0), loop_point=0)), + ) + + assert audition_ticks(instrument, cap=CAP) == CAP + + def test_a_voice_leaving_its_volume_to_the_channel_sounds_for_the_length_offered(self) -> None: + instrument = Instrument( + name="held", + envelopes=InstrumentEnvelopes(arpeggio=Envelope[int](items=(0, 4))), + ) + + assert audition_ticks(instrument, cap=CAP) == CAP + + +class TestTheFramesAnAuditionSoundsPastWhatIsWritten: + """A voice sounds past its written frames the way a held tracker row sounds it.""" + + def test_a_dimension_holds_its_last_item_once_the_written_frames_run_out(self) -> None: + instrument = _instrument() + + instructions = audition_instructions( + instrument, + ChannelName.PULSE1, + pitch=REFERENCE_PITCH, + ticks=WRITTEN_TICKS + 2, + ) + + assert [instruction.volume for instruction in instructions] == [15, 12, 9, 9, 9] + + def test_a_dimension_circles_from_the_item_it_repeats_from(self) -> None: + instrument = Instrument( + name="looping", + envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15, 12, 9), loop_point=1)), + ) + + instructions = audition_instructions( + instrument, + ChannelName.PULSE1, + pitch=REFERENCE_PITCH, + ticks=6, + ) + + assert [instruction.volume for instruction in instructions] == [15, 12, 9, 12, 9, 12] + + class TestTheAudioAnAuditionPlays: @pytest.mark.parametrize( "channel_name", list(ChannelName.items()), ids=[channel_name.value for channel_name in ChannelName.items()], ) - def test_a_voice_renders_one_frame_per_tick_of_its_envelopes( + def test_a_voice_renders_one_frame_per_tick_it_is_sounded_for( self, channel_name: ChannelName, ) -> None: @@ -106,10 +187,11 @@ def test_a_voice_renders_one_frame_per_tick_of_its_envelopes( channel_name, config, pitch=TYPED_PITCH, + ticks=CAP, ) assert audio is not None - assert audio.shape == (3 * config.frame_length,) + assert audio.shape == (CAP * config.frame_length,) def test_a_voice_writing_no_envelope_sounds_nothing(self) -> None: assert ( @@ -118,6 +200,7 @@ def test_a_voice_writing_no_envelope_sounds_nothing(self) -> None: ChannelName.PULSE1, _config(), pitch=TYPED_PITCH, + ticks=CAP, ) is None ) @@ -125,8 +208,20 @@ def test_a_voice_writing_no_envelope_sounds_nothing(self) -> None: def test_two_notes_of_one_voice_render_to_different_audio(self) -> None: config = _config() instrument = _instrument() - low = audition_audio(instrument, ChannelName.PULSE1, config, pitch=REFERENCE_PITCH) - high = audition_audio(instrument, ChannelName.PULSE1, config, pitch=TYPED_PITCH) + low = audition_audio( + instrument, + ChannelName.PULSE1, + config, + pitch=REFERENCE_PITCH, + ticks=WRITTEN_TICKS, + ) + high = audition_audio( + instrument, + ChannelName.PULSE1, + config, + pitch=TYPED_PITCH, + ticks=WRITTEN_TICKS, + ) assert low is not None and high is not None assert not np.array_equal(low, high) From 268f8aebf3e18b31fffa8f2ab071df053a7812d0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 03:41:40 +0200 Subject: [PATCH 136/142] Added: pitch and hi-pitch envelopes to the instruction model, the generators and the exporters --- src/sampletones_core/constants/general.py | 5 + src/sampletones_core/data/model.py | 18 +++ src/sampletones_core/exporters/__init__.py | 2 + src/sampletones_core/exporters/exporter.py | 17 +++ src/sampletones_core/exporters/feature.py | 6 +- .../exporters/implementation/noise.py | 12 ++ .../exporters/implementation/pulse.py | 8 +- .../exporters/implementation/triangle.py | 8 +- .../exporters/implementation/utils.py | 40 +++++- src/sampletones_core/exporters/tonal.py | 89 ++++++++++++ src/sampletones_core/features/spec.py | 6 + src/sampletones_core/generators/__init__.py | 2 + .../generators/implementation/pulse.py | 10 +- .../generators/implementation/triangle.py | 10 +- src/sampletones_core/generators/tonal.py | 49 +++++++ src/sampletones_core/instructions/__init__.py | 4 + .../instructions/implementation/pulse.py | 5 +- .../instructions/implementation/triangle.py | 9 +- src/sampletones_core/instructions/tonal.py | 54 ++++++++ src/sampletones_core/instructions/types.py | 12 +- .../project/voices/creation.py | 2 + .../project/voices/envelopes.py | 10 ++ .../reconstruction/reconstruction.py | 3 +- .../timers/implementation/phase.py | 27 +++- tests/benchmarks/test_pitch_bend.py | 60 +++++++++ .../services/test_regeneration.py | 12 +- tests/unit/sampletones_core/data/__init__.py | 0 .../unit/sampletones_core/data/test_model.py | 58 ++++++++ .../sampletones_core/exporters/test_bends.py | 127 ++++++++++++++++++ .../exporters/test_exporter.py | 26 +++- .../sampletones_core/features/test_spec.py | 39 ++++-- .../sampletones_core/generators/test_tonal.py | 104 ++++++++++++++ .../instructions/test_tonal.py | 59 ++++++++ .../project/voices/test_instrument.py | 2 +- .../reconstruction/test_reconstruction.py | 29 ++-- 35 files changed, 849 insertions(+), 75 deletions(-) create mode 100644 src/sampletones_core/exporters/tonal.py create mode 100644 src/sampletones_core/generators/tonal.py create mode 100644 src/sampletones_core/instructions/tonal.py create mode 100644 tests/benchmarks/test_pitch_bend.py create mode 100644 tests/unit/sampletones_core/data/__init__.py create mode 100644 tests/unit/sampletones_core/data/test_model.py create mode 100644 tests/unit/sampletones_core/exporters/test_bends.py create mode 100644 tests/unit/sampletones_core/generators/test_tonal.py create mode 100644 tests/unit/sampletones_core/instructions/test_tonal.py diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py index aa3b5dcf9..1f7bc6aa0 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -4,6 +4,7 @@ APU_CLOCK: Final[float] = 1789773.0 TIMER_CYCLE_DIVIDER: Final[int] = 16 +MIN_TIMER: Final[int] = 1 MAX_TIMER: Final[int] = 0x7FF MIN_PITCH: Final[int] = 33 MAX_PITCH: Final[int] = 119 @@ -32,6 +33,10 @@ ARPEGGIO_MIN: Final[int] = -128 ARPEGGIO_MAX: Final[int] = 127 +PITCH_BEND_MIN: Final[int] = -128 +PITCH_BEND_MAX: Final[int] = 127 +HI_PITCH_FACTOR: Final[int] = 16 + # Instruction parameters ranges SILENT_VOLUME: Final[int] = 0 diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index 051c1a177..479f33b04 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -106,9 +106,27 @@ def deserialize_inner( validation: Optional[Callback] = None, fast: bool = True, ) -> Self: + """The model a serialized payload describes, filling in what the payload leaves out. + + A payload written before a field existed states nothing for it, and a field carrying a + default states what it means to say nothing, so the default is what the field takes. This + is what lets a model grow a field while every file already written keeps loading. + + Args: + data: The serialized fields. + validation: A check run over each value as it is read. + fast: Whether to construct without re-running validation. + + Returns: + Self: The model the payload describes. + """ field_values: SerializedData = {} for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation + if field_name not in data and not field_info.is_required(): + field_values[field_name] = field_info.get_default(call_default_factory=True) + continue + raw = data.get(field_name) value = cls._unpack_value( raw, diff --git a/src/sampletones_core/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py index a454db288..d6a35b8dc 100644 --- a/src/sampletones_core/exporters/__init__.py +++ b/src/sampletones_core/exporters/__init__.py @@ -4,6 +4,7 @@ from .implementation.pulse import PulseExporter from .implementation.triangle import TriangleExporter from .maps import CHANNEL_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP +from .tonal import TonalExporter from .types import ExporterClass, ExporterT, ExporterTypeUnion, ExporterUnion __all__ = [ @@ -17,6 +18,7 @@ "Features", "NoiseExporter", "PulseExporter", + "TonalExporter", "TriangleExporter", "playing_channels", ] diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index 283d4c241..2c8ea5080 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -99,6 +99,23 @@ def to_features( } return Features.of(initial_pitch, envelopes).leave_to_channel(held_features) + @classmethod + @abstractmethod + def unstated_features(cls, instructions: List[InstructionT]) -> Tuple[FeatureKey, ...]: + """The dimensions this stream leaves to the channel rather than writing itself. + + A stream states every dimension its frames carry a choice for. Where a dimension carries + nothing but the value a channel holds from the start of a song, the stream has made no + choice at all, and saying so leaves the dimension empty rather than pinning it to a value + it would sound at anyway. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + Tuple[FeatureKey, ...]: The dimensions the channel governs, in dimension order. + """ + @classmethod @abstractmethod def read_envelopes( diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index abe7e9e08..9016955fa 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -23,8 +23,10 @@ class Features(BaseModel): initial_pitch: Reference pitch the arpeggio envelope is measured against. volume: Volume envelope. arpeggio: Arpeggio (relative pitch) envelope. - pitch: Pitch envelope, or ``None`` where the generator lacks the dimension. - hi_pitch: Fine-pitch envelope, or ``None`` where the generator lacks the dimension. + pitch: Divider-bend envelope at one step per unit, or ``None`` where the generator + lacks the dimension. + hi_pitch: Divider-bend envelope at sixteen steps per unit, or ``None`` where the + generator lacks the dimension. duty_cycle: Duty-cycle envelope, or ``None`` where the generator lacks the dimension. """ diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py index 2d2b2b7f3..5de6b8175 100644 --- a/src/sampletones_core/exporters/implementation/noise.py +++ b/src/sampletones_core/exporters/implementation/noise.py @@ -61,6 +61,18 @@ def derive_initial_pitch( initial_period, _, _, _ = cls.extract_data(instructions) return initial_period + @classmethod + def unstated_features(cls, instructions: List[NoiseInstruction]) -> Tuple[FeatureKey, ...]: + """Every dimension the noise channel reads is one its frames choose, so it states them all. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + Tuple[FeatureKey, ...]: No dimension, since the stream writes each one it offers. + """ + return () + @classmethod def read_envelopes( cls, diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py index c85abee12..0504919c1 100644 --- a/src/sampletones_core/exporters/implementation/pulse.py +++ b/src/sampletones_core/exporters/implementation/pulse.py @@ -11,13 +11,15 @@ ) from sampletones_core.utils.frequencies import is_pitch_valid -from ..exporter import Exporter +from ..tonal import TonalExporter -class PulseExporter(Exporter[PulseInstruction]): +class PulseExporter(TonalExporter[PulseInstruction]): _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = { FeatureKey.VOLUME: "volume", FeatureKey.ARPEGGIO: "pitch", + FeatureKey.PITCH: "detune", + FeatureKey.HI_PITCH: "coarse_detune", FeatureKey.DUTY_CYCLE: "duty_cycle", } @@ -72,6 +74,7 @@ def read_envelopes( FeatureKey.VOLUME: tuple(volumes), FeatureKey.ARPEGGIO: tuple(pitch - initial_pitch for pitch in pitches), FeatureKey.DUTY_CYCLE: tuple(duty_cycles), + **cls.read_bends(instructions), } @classmethod @@ -89,6 +92,7 @@ def _features_dictionary_to_instruction( pitch=pitch, volume=int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.VOLUME]]), duty_cycle=int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.DUTY_CYCLE]]), + **cls.bend_fields(dictionary), ) @classmethod diff --git a/src/sampletones_core/exporters/implementation/triangle.py b/src/sampletones_core/exporters/implementation/triangle.py index ed388e083..dafb96366 100644 --- a/src/sampletones_core/exporters/implementation/triangle.py +++ b/src/sampletones_core/exporters/implementation/triangle.py @@ -11,13 +11,15 @@ ) from sampletones_core.utils.frequencies import is_pitch_valid -from ..exporter import Exporter +from ..tonal import TonalExporter -class TriangleExporter(Exporter[TriangleInstruction]): +class TriangleExporter(TonalExporter[TriangleInstruction]): _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = { FeatureKey.VOLUME: "volume", FeatureKey.ARPEGGIO: "pitch", + FeatureKey.PITCH: "detune", + FeatureKey.HI_PITCH: "coarse_detune", } @classmethod @@ -69,6 +71,7 @@ def read_envelopes( return { FeatureKey.VOLUME: tuple(volumes), FeatureKey.ARPEGGIO: tuple(pitch - initial_pitch for pitch in pitches), + **cls.read_bends(instructions), } @classmethod @@ -84,6 +87,7 @@ def _features_dictionary_to_instruction( return TriangleInstruction( on=cls._infer_instruction_on(dictionary), pitch=pitch, + **cls.bend_fields(dictionary), ) @classmethod diff --git a/src/sampletones_core/exporters/implementation/utils.py b/src/sampletones_core/exporters/implementation/utils.py index b6d8182ad..d932520cb 100644 --- a/src/sampletones_core/exporters/implementation/utils.py +++ b/src/sampletones_core/exporters/implementation/utils.py @@ -1,7 +1,9 @@ -from typing import List +from typing import Callable, List, Sequence, Tuple import numpy as np +from sampletones_core.instructions import TonalInstruction + def center_pitch( initial_pitch: int, @@ -30,3 +32,39 @@ def center_pitch( min_value = np.min(array) mean_value = (max_value + min_value) // 2 return int(initial_pitch + mean_value) + + +def held_across_rests( + instructions: Sequence[TonalInstruction], + read: Callable[[TonalInstruction], int], + default: int, +) -> Tuple[int, ...]: + """One value per frame, holding what the last sounding frame stated across the rests. + + A rest states no pitch of its own, so the value a channel carries through it is the one it + last sounded; the rests before the first sounding frame take that frame's value, so a + dimension reads the same however a recording opens. This is the rule ``extract_data`` reads a + contour by, stated once for every dimension a note carries. + + Args: + instructions: The channel's per-frame instructions. + read: What the dimension takes from one sounding frame. + default: The value a channel that never sounds carries. + + Returns: + Tuple[int, ...]: One value per instruction. + """ + values: List[int] = [] + opening: int = default + seen = False + + for instruction in instructions: + if instruction.on: + opening = read(instruction) + if not seen: + seen = True + values = [opening for _ in values] + + values.append(opening) + + return tuple(values) diff --git a/src/sampletones_core/exporters/tonal.py b/src/sampletones_core/exporters/tonal.py new file mode 100644 index 000000000..948bf21aa --- /dev/null +++ b/src/sampletones_core/exporters/tonal.py @@ -0,0 +1,89 @@ +from abc import ABC +from typing import Dict, List, Tuple, TypeVar, Union + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.instructions import TonalInstruction + +from .exporter import Exporter +from .implementation.utils import held_across_rests + +TonalInstructionT = TypeVar("TonalInstructionT", bound=TonalInstruction) + + +class TonalExporter(Exporter[TonalInstructionT], ABC): + """The reading the channels that name a note share: the bend each frame carries. + + A pulse or triangle frame states its note and, beside it, how far off that note's own divider + it sounds. Both dimensions are read the way a contour is read — a rest carries what the last + sounding frame stated — so a bend survives a rest exactly as a pitch does. + """ + + @classmethod + def read_bends( + cls, + instructions: List[TonalInstructionT], + ) -> Dict[FeatureKey, Tuple[int, ...]]: + """The per-tick values the two bend dimensions carry. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + Dict[FeatureKey, Tuple[int, ...]]: One value per frame for each bend dimension. + """ + return { + FeatureKey.PITCH: held_across_rests( + instructions, + lambda instruction: instruction.detune, + CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH], + ), + FeatureKey.HI_PITCH: held_across_rests( + instructions, + lambda instruction: instruction.coarse_detune, + CHANNEL_FEATURE_DEFAULTS[FeatureKey.HI_PITCH], + ), + } + + @classmethod + def unstated_features(cls, instructions: List[TonalInstructionT]) -> Tuple[FeatureKey, ...]: + """The bend dimensions this stream makes no use of. + + A stream that never leaves its notes' own dividers has made no bend, which is what a + channel governing the dimension sounds anyway, so it leaves both dimensions empty. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + Tuple[FeatureKey, ...]: The bend dimensions the channel governs, in dimension order. + """ + written = cls.read_bends(instructions) + return tuple( + feature_key + for feature_key, items in written.items() + if all(item == CHANNEL_FEATURE_DEFAULTS[feature_key] for item in items) + ) + + @classmethod + def bend_fields(cls, dictionary: Dict[str, Union[bool, int]]) -> Dict[str, int]: + """The bend a row of feature values states, as the fields an instruction takes. + + A row naming neither dimension describes a channel that governs both, and a channel holds + no bend from the start of a song, so the frame sounds at its note's own divider. + + Args: + dictionary: One frame's feature values, keyed by the instruction field each fills. + + Returns: + Dict[str, int]: The bend fields to build the instruction with. + """ + return { + cls._ATTRIBUTE_MAP[feature_key]: int( + dictionary.get( + cls._ATTRIBUTE_MAP[feature_key], + CHANNEL_FEATURE_DEFAULTS[feature_key], + ) + ) + for feature_key in (FeatureKey.PITCH, FeatureKey.HI_PITCH) + } diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index 94e9c823a..c46164d34 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -10,6 +10,8 @@ MAX_PERIOD, MAX_VOLUME, NUM_PERIODS, + PITCH_BEND_MAX, + PITCH_BEND_MIN, ) from sampletones_core.utils.frequencies import transpose_period, transpose_pitch @@ -46,11 +48,15 @@ class FeatureRange: GeneratorName.PULSE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), FeatureKey.ARPEGGIO: FeatureRange(ARPEGGIO_MIN, ARPEGGIO_MAX), + FeatureKey.PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX), + FeatureKey.HI_PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX), FeatureKey.DUTY_CYCLE: FeatureRange(0, MAX_DUTY_CYCLE), }, GeneratorName.TRIANGLE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), FeatureKey.ARPEGGIO: FeatureRange(ARPEGGIO_MIN, ARPEGGIO_MAX), + FeatureKey.PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX), + FeatureKey.HI_PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX), }, GeneratorName.NOISE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py index 3bf74800d..e37a76346 100644 --- a/src/sampletones_core/generators/__init__.py +++ b/src/sampletones_core/generators/__init__.py @@ -12,6 +12,7 @@ MIXER_LEVELS, ) from .render import render_channels, render_instructions +from .tonal import TonalGenerator from .types import ( GeneratorClass, GeneratorClassNames, @@ -42,6 +43,7 @@ "GeneratorUnion", "NoiseGenerator", "PulseGenerator", + "TonalGenerator", "TriangleGenerator", "get_generator_by_instruction", "get_generators_by_channels", diff --git a/src/sampletones_core/generators/implementation/pulse.py b/src/sampletones_core/generators/implementation/pulse.py index 766a26370..8103cae42 100644 --- a/src/sampletones_core/generators/implementation/pulse.py +++ b/src/sampletones_core/generators/implementation/pulse.py @@ -14,10 +14,10 @@ from sampletones_core.timers import PhaseTimer from sampletones_shared.types.data import Initials -from ..generator import Generator +from ..tonal import TonalGenerator -class PulseGenerator(Generator[PulseInstruction, PhaseTimer]): +class PulseGenerator(TonalGenerator[PulseInstruction]): def __init__( self, config: Config, @@ -54,12 +54,6 @@ def __call__( return output - def set_timer(self, instruction: PulseInstruction) -> None: - if instruction.on: - self.timer.frequency = self.get_frequency(instruction.pitch) - else: - self.timer.frequency = 0.0 - def apply(self, output: np.ndarray, instruction: PulseInstruction) -> np.ndarray: duty_cycle = DUTY_CYCLES[instruction.duty_cycle] output = np.where(output < duty_cycle, 1.0, -1.0).astype(np.float32) diff --git a/src/sampletones_core/generators/implementation/triangle.py b/src/sampletones_core/generators/implementation/triangle.py index b3771cc71..508624b36 100644 --- a/src/sampletones_core/generators/implementation/triangle.py +++ b/src/sampletones_core/generators/implementation/triangle.py @@ -16,10 +16,10 @@ from sampletones_core.timers import PhaseTimer from sampletones_shared.types.data import Initials -from ..generator import Generator +from ..tonal import TonalGenerator -class TriangleGenerator(Generator[TriangleInstruction, PhaseTimer]): +class TriangleGenerator(TonalGenerator[TriangleInstruction]): def __init__( self, config: Config, @@ -56,12 +56,6 @@ def __call__( return output - def set_timer(self, instruction: TriangleInstruction) -> None: - if instruction.on: - self.timer.frequency = self.get_frequency(instruction.pitch) - else: - self.timer.frequency = 0.0 - def apply(self, output: np.ndarray, instruction: TriangleInstruction) -> np.ndarray: triangle = 1.0 - np.round(np.abs(((output + TRIANGLE_OFFSET) % 1.0) - 0.5) * 30.0) / 7.5 return (triangle * MIXER_TRIANGLE).astype(np.float32) diff --git a/src/sampletones_core/generators/tonal.py b/src/sampletones_core/generators/tonal.py new file mode 100644 index 000000000..86dc5b067 --- /dev/null +++ b/src/sampletones_core/generators/tonal.py @@ -0,0 +1,49 @@ +from abc import ABC +from typing import Dict, TypeVar + +from sampletones_core.configs import Config +from sampletones_core.constants.general import MAX_TIMER, MIN_TIMER +from sampletones_core.instructions import TonalInstruction +from sampletones_core.timers import PhaseTimer, frequency_to_timer +from sampletones_shared.utils.arrays import clamp + +from .generator import Generator + +TonalInstructionT = TypeVar("TonalInstructionT", bound=TonalInstruction) + + +class TonalGenerator(Generator[TonalInstructionT, PhaseTimer], ABC): + """A generator whose channel names a note, reached by loading a divider. + + The pulse and triangle channels sound a pitch the same way — the note resolves to a divider, + and the frame's own bend moves it from there — so both read one table and drive their timer + through one call. The divider stays inside the range the register holds and away from the + value that stops the waveform, which keeps a bend audible wherever it lands. + """ + + def __init__(self, config: Config, name: str) -> None: + super().__init__(config, name) + self.timer_table: Dict[int, int] = { + pitch: frequency_to_timer(frequency) for pitch, frequency in self.frequency_table.items() + } + + def set_timer(self, instruction: TonalInstructionT) -> None: + if instruction.on: + self.timer.timer = self.get_timer(instruction.pitch, instruction.timer_offset) + else: + self.timer.frequency = 0.0 + + def get_timer(self, pitch: int, offset: int) -> int: + """The divider a note sounds at once the frame's bend has moved it. + + Args: + pitch: The note the frame names. + offset: The divider steps the frame is bent by. + + Returns: + int: The divider to run at, within the range the register holds. + + Raises: + KeyError: If the pitch is absent from the generator's tables. + """ + return int(clamp(self.timer_table[pitch] + offset, MIN_TIMER, MAX_TIMER)) diff --git a/src/sampletones_core/instructions/__init__.py b/src/sampletones_core/instructions/__init__.py index 7360d224f..e00bb1e66 100644 --- a/src/sampletones_core/instructions/__init__.py +++ b/src/sampletones_core/instructions/__init__.py @@ -4,12 +4,14 @@ from .implementation.triangle import TriangleInstruction from .instruction import Instruction from .maps import INSTRUCTION_CLASS_MAP +from .tonal import TonalInstruction from .types import ( InstructionClass, InstructionFields, InstructionT, InstructionTypeUnion, InstructionUnion, + TonalInstructionUnion, ) from .utils import get_instruction_by_type @@ -24,6 +26,8 @@ "InstructionUnion", "NoiseInstruction", "PulseInstruction", + "TonalInstruction", + "TonalInstructionUnion", "TriangleInstruction", "get_instruction_by_type", ] diff --git a/src/sampletones_core/instructions/implementation/pulse.py b/src/sampletones_core/instructions/implementation/pulse.py index 494f21335..eea06a967 100644 --- a/src/sampletones_core/instructions/implementation/pulse.py +++ b/src/sampletones_core/instructions/implementation/pulse.py @@ -6,7 +6,6 @@ from sampletones_core.constants.general import ( DUTY_CYCLES, MAX_DUTY_CYCLE, - MAX_PITCH, MAX_VOLUME, MIN_PITCH, PITCH_RANGE, @@ -14,10 +13,10 @@ from sampletones_core.utils.frequencies import pitch_to_name from ..instruction import Instruction +from ..tonal import TonalInstruction -class PulseInstruction(Instruction): - pitch: int = Field(..., ge=MIN_PITCH, le=MAX_PITCH, description="MIDI pitch (0-120)") +class PulseInstruction(TonalInstruction): volume: int = Field(..., ge=0, le=MAX_VOLUME, description="Volume (0-15)") duty_cycle: int = Field( ..., diff --git a/src/sampletones_core/instructions/implementation/triangle.py b/src/sampletones_core/instructions/implementation/triangle.py index fa3112f74..c535c4e48 100644 --- a/src/sampletones_core/instructions/implementation/triangle.py +++ b/src/sampletones_core/instructions/implementation/triangle.py @@ -1,17 +1,14 @@ from __future__ import annotations -from pydantic import Field - from sampletones_core.constants.enums import InstructionClassName -from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH, PITCH_RANGE +from sampletones_core.constants.general import MIN_PITCH, PITCH_RANGE from sampletones_core.utils.frequencies import pitch_to_name from ..instruction import Instruction +from ..tonal import TonalInstruction -class TriangleInstruction(Instruction): - pitch: int = Field(..., ge=MIN_PITCH, le=MAX_PITCH, description="MIDI pitch (0-120)") - +class TriangleInstruction(TonalInstruction): @property def name(self) -> str: pitch = pitch_to_name(self.pitch) diff --git a/src/sampletones_core/instructions/tonal.py b/src/sampletones_core/instructions/tonal.py new file mode 100644 index 000000000..79edf842b --- /dev/null +++ b/src/sampletones_core/instructions/tonal.py @@ -0,0 +1,54 @@ +from abc import ABC + +from pydantic import Field + +from sampletones_core.constants.general import ( + HI_PITCH_FACTOR, + MAX_PITCH, + MIN_PITCH, + PITCH_BEND_MAX, + PITCH_BEND_MIN, +) + +from .instruction import Instruction + + +class TonalInstruction(Instruction, ABC): + """An instruction naming a note, sounded through a timer the frame may bend. + + The pulse and triangle channels reach a pitch by loading a timer, and the timer grid is finer + than the semitone grid everywhere below the top of the range: a step spans well under a cent + at the lowest notes and widens to a whole semitone at the highest. A frame therefore states + the note it plays and, beside it, the offset in timer steps that carries it off the + equal-tempered grid — the two dimensions FamiTracker writes as its pitch and hi-pitch + sequences, one step apiece and one step of sixteen. + + Attributes: + pitch: The note the frame names. + detune: Timer steps the frame is bent by, one step per unit. + coarse_detune: Timer steps the frame is bent by, sixteen steps per unit. + """ + + pitch: int = Field(..., ge=MIN_PITCH, le=MAX_PITCH, description="The note the frame names") + detune: int = Field( + default=0, + ge=PITCH_BEND_MIN, + le=PITCH_BEND_MAX, + description="Timer steps the frame is bent by, one step per unit", + ) + coarse_detune: int = Field( + default=0, + ge=PITCH_BEND_MIN, + le=PITCH_BEND_MAX, + description="Timer steps the frame is bent by, sixteen steps per unit", + ) + + @property + def timer_offset(self) -> int: + """The timer steps this frame stands away from its note, both dimensions together.""" + return self.detune + HI_PITCH_FACTOR * self.coarse_detune + + @property + def bent(self) -> bool: + """Whether the frame sounds anywhere other than its note's own timer.""" + return self.timer_offset != 0 diff --git a/src/sampletones_core/instructions/types.py b/src/sampletones_core/instructions/types.py index 58b8a9002..643facb2b 100644 --- a/src/sampletones_core/instructions/types.py +++ b/src/sampletones_core/instructions/types.py @@ -6,8 +6,18 @@ from .instruction import Instruction InstructionT = TypeVar("InstructionT", bound=Instruction) +TonalInstructionUnion = Union[PulseInstruction, TriangleInstruction] InstructionClass = Type[InstructionT] InstructionUnion = Union[PulseInstruction, TriangleInstruction, NoiseInstruction] InstructionTypeUnion = Union[Type[PulseInstruction], Type[TriangleInstruction], Type[NoiseInstruction]] -InstructionFields = Literal["on", "volume", "pitch", "duty_cycle", "period", "short"] +InstructionFields = Literal[ + "on", + "volume", + "pitch", + "detune", + "coarse_detune", + "duty_cycle", + "period", + "short", +] diff --git a/src/sampletones_core/project/voices/creation.py b/src/sampletones_core/project/voices/creation.py index 77253cbb3..4d23beecd 100644 --- a/src/sampletones_core/project/voices/creation.py +++ b/src/sampletones_core/project/voices/creation.py @@ -63,6 +63,8 @@ def instrument_from_features( envelopes=InstrumentEnvelopes( volume=features.volume, arpeggio=features.arpeggio, + pitch=features.pitch if features.pitch is not None else Envelope[int](), + hi_pitch=features.hi_pitch if features.hi_pitch is not None else Envelope[int](), duty_cycle=features.duty_cycle if features.duty_cycle is not None else Envelope[int](), ), initial_pitch=_initial_pitch(channel_name, features.initial_pitch), diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py index 02d8251b8..8ef13feaa 100644 --- a/src/sampletones_core/project/voices/envelopes.py +++ b/src/sampletones_core/project/voices/envelopes.py @@ -10,12 +10,16 @@ ARPEGGIO_MIN, MAX_DUTY_CYCLE, MAX_VOLUME, + PITCH_BEND_MAX, + PITCH_BEND_MIN, SILENT_VOLUME, ) from sampletones_core.features.envelope import Envelope VolumeItem = Annotated[int, Field(ge=SILENT_VOLUME, le=MAX_VOLUME)] ArpeggioItem = Annotated[int, Field(ge=ARPEGGIO_MIN, le=ARPEGGIO_MAX)] +PitchItem = Annotated[int, Field(ge=PITCH_BEND_MIN, le=PITCH_BEND_MAX)] +HiPitchItem = Annotated[int, Field(ge=PITCH_BEND_MIN, le=PITCH_BEND_MAX)] DutyCycleItem = Annotated[int, Field(ge=0, le=MAX_DUTY_CYCLE)] @@ -31,6 +35,8 @@ class InstrumentEnvelopes(BaseModel): Attributes: volume: Output level per tick. arpeggio: Offset from the instrument's initial pitch per tick. + pitch: Divider steps the note is bent by per tick, one step per unit. + hi_pitch: Divider steps the note is bent by per tick, sixteen steps per unit. duty_cycle: Pulse waveform, or noise mode, per tick. """ @@ -38,6 +44,8 @@ class InstrumentEnvelopes(BaseModel): volume: Envelope[VolumeItem] = Envelope[VolumeItem]() arpeggio: Envelope[ArpeggioItem] = Envelope[ArpeggioItem]() + pitch: Envelope[PitchItem] = Envelope[PitchItem]() + hi_pitch: Envelope[HiPitchItem] = Envelope[HiPitchItem]() duty_cycle: Envelope[DutyCycleItem] = Envelope[DutyCycleItem]() @property @@ -45,6 +53,8 @@ def envelope_map(self) -> Dict[FeatureKey, Envelope[int]]: return { FeatureKey.VOLUME: self.volume, FeatureKey.ARPEGGIO: self.arpeggio, + FeatureKey.PITCH: self.pitch, + FeatureKey.HI_PITCH: self.hi_pitch, FeatureKey.DUTY_CYCLE: self.duty_cycle, } diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 462663723..da7a1ca46 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -218,12 +218,13 @@ def create( instructions_data.append(InstructionsItem.resting(channel_name)) continue + exporter_class = cls._get_exporter_class(channel_instructions[0]) instructions_data.append( InstructionsItem.create( channel_name=channel_name, instructions=channel_instructions, initial_pitch=cls._derive_initial_pitch(channel_instructions), - held_features=(), + held_features=exporter_class.unstated_features(channel_instructions), # type: ignore[arg-type] ) ) diff --git a/src/sampletones_core/timers/implementation/phase.py b/src/sampletones_core/timers/implementation/phase.py index 1c54140b5..72b2bff85 100644 --- a/src/sampletones_core/timers/implementation/phase.py +++ b/src/sampletones_core/timers/implementation/phase.py @@ -75,11 +75,32 @@ def frequency(self) -> float: @frequency.setter def frequency(self, value: float) -> None: self._frequency = value - self._timer = frequency_to_timer(value) - self._timer_ticks = get_timer_ticks(self._timer) + self._load(frequency_to_timer(value)) + + @property + def timer(self) -> int: + """The divider register value the oscillator is running at.""" + return self._timer + + @timer.setter + def timer(self, value: int) -> None: + self._load(value) + + def _load(self, timer: int) -> None: + """Runs the oscillator at a divider value, and states the frequency that produces. + + Both a frequency and a divider reach the oscillator here, so a caller naming either one + leaves the timer in the same state: the divider decides the waveform, and the frequency + the timer reports is the one that divider actually produces. + + Args: + timer: The divider register value to run at. + """ + self._timer = timer + self._timer_ticks = get_timer_ticks(timer) self.round_frequency_by_timer() - self._real_frequency = self.frequency * self.phase_increment + self._real_frequency = self._frequency * self.phase_increment if self.reset_phase: self.reset() diff --git a/tests/benchmarks/test_pitch_bend.py b/tests/benchmarks/test_pitch_bend.py new file mode 100644 index 000000000..e53e73a15 --- /dev/null +++ b/tests/benchmarks/test_pitch_bend.py @@ -0,0 +1,60 @@ +from time import process_time +from typing import Final, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators.render import render_instructions +from sampletones_core.instructions import PulseInstruction + +FRAMES: Final[int] = 6000 +PITCH: Final[int] = 60 +VOLUME: Final[int] = 12 +REPEATS: Final[int] = 3 +BEND_OVERHEAD_LIMIT: Final[float] = 1.25 + + +def _stream(bent: bool) -> List[PulseInstruction]: + """One channel's frames, every one of them bent or none of them.""" + return [ + PulseInstruction( + on=True, + pitch=PITCH, + volume=VOLUME, + duty_cycle=frame % 4, + detune=(frame % 21) - 10 if bent else 0, + ) + for frame in range(FRAMES) + ] + + +def _render_seconds(config: Config, instructions: List[PulseInstruction]) -> float: + """The best of several renders, which is the reading least disturbed by other load.""" + readings: List[float] = [] + for _ in range(REPEATS): + started = process_time() + render_instructions(instructions, ChannelName.PULSE1, config) + readings.append(process_time() - started) + + return min(readings) + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config() + + +class TestBendingCostsNothingToRender: + """A bend moves the divider a frame loads, which is the same work as loading it unbent. + + The reading is a ratio against the same render without a bend, since what a machine renders + a frame in is its own. What the bound catches is a bend that made rendering a different + kind of work — a per-frame table rebuild, a lost cache, a fallback path. + """ + + def test_a_bent_stream_renders_in_what_an_unbent_one_takes(self, config: Config) -> None: + unbent = _render_seconds(config, _stream(bent=False)) + bent = _render_seconds(config, _stream(bent=True)) + + assert bent < unbent * BEND_OVERHEAD_LIMIT, f"unbent {unbent:.4f}s, bent {bent:.4f}s" diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index d488bb96e..485f6072a 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -14,6 +14,7 @@ ) from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.exporters import Features +from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction from tests.conftest import ReconstructionFactory @@ -343,9 +344,8 @@ class TestClearingEveryEnvelope: @staticmethod def _regenerated(reconstruction: Reconstruction) -> Reconstruction: """The reconstruction the service returns once every dimension is left to the channel.""" - features = reconstruction.export()[ChannelName.PULSE1].leave_to_channel( - [FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE] - ) + exported = reconstruction.export()[ChannelName.PULSE1] + features = exported.leave_to_channel(exported.envelopes) service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) @@ -391,10 +391,8 @@ def test_the_cleared_channel_records_every_dimension_as_the_channels( regenerated = self._regenerated(reconstruction) - assert regenerated.held_features[ChannelName.PULSE1] == ( - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - FeatureKey.DUTY_CYCLE, + assert regenerated.held_features[ChannelName.PULSE1] == tuple( + supported_features(CHANNEL_GENERATOR_KIND[ChannelName.PULSE1]) ) assert not regenerated.export()[ChannelName.PULSE1].has_frames diff --git a/tests/unit/sampletones_core/data/__init__.py b/tests/unit/sampletones_core/data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/data/test_model.py b/tests/unit/sampletones_core/data/test_model.py new file mode 100644 index 000000000..16a619326 --- /dev/null +++ b/tests/unit/sampletones_core/data/test_model.py @@ -0,0 +1,58 @@ +from typing import Final + +import msgpack + +from sampletones_core.instructions import PulseInstruction +from sampletones_shared.types.data import SerializedData + +PITCH: Final[int] = 60 +VOLUME: Final[int] = 15 + + +def _without(payload: bytes, *field_names: str) -> bytes: + """The payload as a build that had never heard of ``field_names`` would have written it.""" + data: SerializedData = msgpack.unpackb(payload, raw=False) + for field_name in field_names: + data.pop(field_name, None) + + return bytes(msgpack.packb(data, use_bin_type=True)) + + +class TestFieldsAPayloadLeavesOut: + """A file written before a field existed still loads, taking that field's default. + + Serialization keys every field by name, so a payload from an older build simply says nothing + about a field added since. What it means to say nothing is what the field defaults to, which + is how a stored reconstruction or instruction library keeps loading as the model grows. + """ + + def test_a_field_the_payload_omits_takes_its_default(self) -> None: + instruction = PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=1, detune=7) + + older = _without(instruction.serialize(), "detune", "coarse_detune") + loaded = PulseInstruction.deserialize(older) + + assert loaded.detune == 0 + assert loaded.coarse_detune == 0 + assert loaded.timer_offset == 0 + + def test_the_fields_a_payload_states_come_back_as_written(self) -> None: + instruction = PulseInstruction( + on=True, + pitch=PITCH, + volume=VOLUME, + duty_cycle=1, + detune=-3, + coarse_detune=2, + ) + + assert PulseInstruction.deserialize(instruction.serialize()) == instruction + + def test_the_rest_of_the_payload_survives_the_omission(self) -> None: + instruction = PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=1) + + loaded = PulseInstruction.deserialize(_without(instruction.serialize(), "detune")) + + assert loaded.pitch == PITCH + assert loaded.volume == VOLUME + assert loaded.duty_cycle == 1 diff --git a/tests/unit/sampletones_core/exporters/test_bends.py b/tests/unit/sampletones_core/exporters/test_bends.py new file mode 100644 index 000000000..f3c72f294 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_bends.py @@ -0,0 +1,127 @@ +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import PulseExporter, TriangleExporter +from sampletones_core.features import resting_held_features +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import PulseInstruction, TriangleInstruction + +REFERENCE: Final[int] = 60 +VOLUME: Final[int] = 12 +BENDS: Final[Tuple[int, ...]] = (0, 3, -5, 12) +COARSE_BENDS: Final[Tuple[int, ...]] = (0, 1, -2, 0) + + +def _pulses() -> List[PulseInstruction]: + return [ + PulseInstruction( + on=True, + pitch=REFERENCE, + volume=VOLUME, + duty_cycle=1, + detune=detune, + coarse_detune=coarse_detune, + ) + for detune, coarse_detune in zip(BENDS, COARSE_BENDS) + ] + + +class TestReadingBendsOutOfAStream: + def test_both_dimensions_come_back_frame_by_frame(self) -> None: + written = PulseExporter.read_envelopes(_pulses(), REFERENCE) + + assert written[FeatureKey.PITCH] == BENDS + assert written[FeatureKey.HI_PITCH] == COARSE_BENDS + + def test_a_rest_carries_the_bend_the_last_sounding_frame_stated(self) -> None: + instructions = [ + PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, detune=9), + PulseInstruction.null_instruction(), + PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, detune=-4), + ] + + written = PulseExporter.read_envelopes(instructions, REFERENCE) + + assert written[FeatureKey.PITCH] == (9, 9, -4) + + def test_a_rest_before_the_first_sounding_frame_takes_its_bend(self) -> None: + instructions = [ + PulseInstruction.null_instruction(), + PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, detune=6), + ] + + written = PulseExporter.read_envelopes(instructions, REFERENCE) + + assert written[FeatureKey.PITCH] == (6, 6) + + def test_a_channel_that_never_sounds_carries_no_bend(self) -> None: + written = PulseExporter.read_envelopes([PulseInstruction.null_instruction()] * 3, REFERENCE) + + assert written[FeatureKey.PITCH] == (0, 0, 0) + assert written[FeatureKey.HI_PITCH] == (0, 0, 0) + + +class TestRoundTrip: + def test_a_bent_stream_survives_the_trip_through_its_envelopes(self) -> None: + instructions = _pulses() + + features = PulseExporter.to_features(instructions, REFERENCE, ()) + + assert list(PulseExporter.from_features(features))[: len(instructions)] == instructions + + def test_the_triangle_carries_its_bend_the_same_way(self) -> None: + instructions = [TriangleInstruction(on=True, pitch=REFERENCE, detune=detune) for detune in BENDS] + + features = TriangleExporter.to_features(instructions, REFERENCE, ()) + + assert list(TriangleExporter.from_features(features))[: len(instructions)] == instructions + + def test_a_dimension_left_to_the_channel_sounds_the_note_itself(self) -> None: + features = PulseExporter.to_features( + _pulses(), + REFERENCE, + (FeatureKey.PITCH, FeatureKey.HI_PITCH), + ) + + assert features.envelope(FeatureKey.PITCH).items == () + assert all(not instruction.bent for instruction in PulseExporter.from_features(features)) + + +class TestChannelsThatOfferTheBend: + @pytest.mark.parametrize( + "channel_name", + (ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE), + ids=lambda channel_name: str(channel_name), + ) + def test_a_tonal_channel_records_both_bend_dimensions(self, channel_name: ChannelName) -> None: + held = resting_held_features(channel_name) + + assert FeatureKey.PITCH in held + assert FeatureKey.HI_PITCH in held + + def test_the_noise_channel_records_neither(self) -> None: + held = resting_held_features(ChannelName.NOISE) + + assert FeatureKey.PITCH not in held + assert FeatureKey.HI_PITCH not in held + + +class TestBendEnvelopesOnAnInstrument: + def test_a_bend_envelope_reaches_the_frames_an_instrument_plays(self) -> None: + from sampletones_core.project.voices.envelopes import InstrumentEnvelopes + from sampletones_core.project.voices.instrument import Instrument + + instrument = Instrument( + name="bent", + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(VOLUME, VOLUME)), + pitch=Envelope(items=(4, -4)), + ), + initial_pitch=REFERENCE, + ) + + frames = instrument.instructions(ChannelName.PULSE1) + + assert [frame.detune for frame in frames] == [4, -4] diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index e9f5b40d8..77523414a 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.exporters import ( ExporterTypeUnion, @@ -13,6 +13,11 @@ PulseExporter, TriangleExporter, ) +from sampletones_core.features import ( + CHANNEL_FEATURE_DEFAULTS, + FEATURE_DIMENSION_ORDER, + supports, +) from sampletones_core.features.envelope import Envelope from sampletones_core.instructions import ( InstructionUnion, @@ -30,6 +35,7 @@ PERIOD_STEP: Final[int] = 3 PULSE_VOLUME: Final[int] = 8 NOISE_VOLUME: Final[int] = 10 +UNREAD_VALUE: Final[int] = 3 def _read_pitch(instruction: Any) -> int: @@ -457,15 +463,22 @@ class TestSingleFrameReading(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): exporter: ExporterTypeUnion + kind: GeneratorName instruction: InstructionUnion silent: InstructionUnion reference: int expected: Dict[FeatureKey, int] + @property + def unread(self) -> Tuple[FeatureKey, ...]: + """The dimensions this channel's generator reads nothing from.""" + return tuple(feature_key for feature_key in FEATURE_DIMENSION_ORDER if not supports(self.kind, feature_key)) + test_cases = ( TestCase( label="pulse", exporter=PulseExporter, + kind=GeneratorName.PULSE, instruction=PulseInstruction( on=True, pitch=REFERENCE_PITCH + OCTAVE, @@ -477,23 +490,29 @@ class TestCase(BaseRegularTestCase): expected={ FeatureKey.VOLUME: PULSE_VOLUME, FeatureKey.ARPEGGIO: OCTAVE, + FeatureKey.PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH], + FeatureKey.HI_PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.HI_PITCH], FeatureKey.DUTY_CYCLE: 1, }, ), TestCase( label="triangle", exporter=TriangleExporter, + kind=GeneratorName.TRIANGLE, instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH - OCTAVE), silent=TriangleInstruction.null_instruction(), reference=REFERENCE_PITCH, expected={ FeatureKey.VOLUME: MAX_VOLUME, FeatureKey.ARPEGGIO: -OCTAVE, + FeatureKey.PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH], + FeatureKey.HI_PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.HI_PITCH], }, ), TestCase( label="noise", exporter=NoiseExporter, + kind=GeneratorName.NOISE, instruction=NoiseInstruction( on=True, period=REFERENCE_PERIOD + PERIOD_STEP, @@ -543,12 +562,13 @@ def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> N @pytest.mark.parametrize( "test_case", - test_cases, + tuple(test_case for test_case in test_cases if test_case.unread), ids=lambda test_case: test_case.label, ) def test_a_dimension_the_channel_reads_nothing_from_is_passed_over(self, test_case: TestCase) -> None: """One set of channel values serves every channel, so each takes the dimensions it reads.""" values = dict(test_case.expected) - values[FeatureKey.HI_PITCH] = 3 + for feature_key in test_case.unread: + values[feature_key] = UNREAD_VALUE assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 71482464f..e86ce81f1 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -21,20 +21,31 @@ def test_supported_features_follow_dimension_order() -> None: - assert supported_features(GeneratorName.PULSE) == [ - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - FeatureKey.DUTY_CYCLE, - ] - assert supported_features(GeneratorName.TRIANGLE) == [ - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - ] - assert supported_features(GeneratorName.NOISE) == [ - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - FeatureKey.DUTY_CYCLE, - ] + for generator_name in GeneratorName: + offered = supported_features(generator_name) + + assert offered == [feature_key for feature_key in FEATURE_DIMENSION_ORDER if feature_key in offered] + + +def test_a_generator_offers_every_dimension_it_states_a_range_for() -> None: + for generator_name in GeneratorName: + offered = set(supported_features(generator_name)) + + assert offered == { + feature_key for feature_key in FEATURE_DIMENSION_ORDER if supports(generator_name, feature_key) + } + + +def test_the_noise_channel_reads_no_bend() -> None: + """Its sixteen periods have no finer grid, so a bend would state a resolution it lacks.""" + assert not supports(GeneratorName.NOISE, FeatureKey.PITCH) + assert not supports(GeneratorName.NOISE, FeatureKey.HI_PITCH) + + +def test_the_tonal_channels_read_both_bend_dimensions() -> None: + for generator_name in (GeneratorName.PULSE, GeneratorName.TRIANGLE): + assert supports(generator_name, FeatureKey.PITCH) + assert supports(generator_name, FeatureKey.HI_PITCH) def test_feature_ranges_match_expected_channel_domains() -> None: diff --git a/tests/unit/sampletones_core/generators/test_tonal.py b/tests/unit/sampletones_core/generators/test_tonal.py new file mode 100644 index 000000000..4a505a85b --- /dev/null +++ b/tests/unit/sampletones_core/generators/test_tonal.py @@ -0,0 +1,104 @@ +import math +from typing import Final + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import ( + HI_PITCH_FACTOR, + MAX_PITCH, + MAX_TIMER, + MIN_TIMER, +) +from sampletones_core.generators.implementation.pulse import PulseGenerator +from sampletones_core.generators.implementation.triangle import TriangleGenerator +from sampletones_core.instructions import PulseInstruction, TriangleInstruction + +PITCH: Final[int] = 60 +VOLUME: Final[int] = 15 +CENTS_PER_OCTAVE: Final[float] = 1200.0 + + +@pytest.fixture +def config() -> Config: + return Config() + + +@pytest.fixture +def generator(config: Config) -> PulseGenerator: + return PulseGenerator(config, ChannelName.PULSE1) + + +def _pulse(**bend: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=0, **bend) + + +class TestBendingTheDivider: + def test_an_unbent_frame_sounds_at_the_note_itself(self, generator: PulseGenerator) -> None: + generator.set_timer(_pulse()) + + assert generator.timer.timer == generator.timer_table[PITCH] + + def test_a_bend_moves_the_divider_by_the_steps_it_states(self, generator: PulseGenerator) -> None: + generator.set_timer(_pulse(detune=5, coarse_detune=1)) + + assert generator.timer.timer == generator.timer_table[PITCH] + 5 + HI_PITCH_FACTOR + + def test_a_larger_divider_sounds_lower(self, generator: PulseGenerator) -> None: + """The divider counts a period, so adding to it lowers the note the frame sounds.""" + generator.set_timer(_pulse()) + unbent = generator.timer.frequency + + generator.set_timer(_pulse(detune=1)) + + assert generator.timer.frequency < unbent + + def test_a_bend_of_a_whole_gap_reaches_the_neighboring_note(self, generator: PulseGenerator) -> None: + gap = generator.timer_table[PITCH] - generator.timer_table[PITCH + 1] + generator.set_timer(_pulse(detune=-gap)) + + assert generator.timer.timer == generator.timer_table[PITCH + 1] + + def test_one_step_is_finer_than_a_semitone_in_the_middle_register( + self, + generator: PulseGenerator, + ) -> None: + """A step spans a fraction of the gap to the next note, which is the room a bend has.""" + generator.set_timer(_pulse()) + unbent = generator.timer.frequency + generator.set_timer(_pulse(detune=1)) + stepped = generator.timer.frequency + + cents = abs(CENTS_PER_OCTAVE * math.log2(stepped / unbent)) + semitone = CENTS_PER_OCTAVE / 12 + + assert 0.0 < cents < semitone + + +class TestBendClamping: + def test_a_bend_below_the_register_is_held_at_its_floor(self, generator: PulseGenerator) -> None: + generator.set_timer(PulseInstruction(on=True, pitch=MAX_PITCH, volume=VOLUME, duty_cycle=0, coarse_detune=-8)) + + assert generator.timer.timer == MIN_TIMER + + def test_a_bend_past_the_register_is_held_at_its_ceiling(self, config: Config) -> None: + generator = PulseGenerator(config, ChannelName.PULSE1) + lowest = min(generator.timer_table) + generator.set_timer(PulseInstruction(on=True, pitch=lowest, volume=VOLUME, duty_cycle=0, coarse_detune=127)) + + assert generator.timer.timer == MAX_TIMER + + def test_a_silent_frame_carries_no_bend_into_the_timer(self, generator: PulseGenerator) -> None: + generator.set_timer(PulseInstruction(on=False, pitch=PITCH, volume=0, duty_cycle=0, detune=40)) + + assert generator.timer.frequency == 0.0 or generator.timer.timer == 0 + + +class TestTriangleBend: + def test_the_triangle_bends_through_the_same_table(self, config: Config) -> None: + generator = TriangleGenerator(config, ChannelName.TRIANGLE) + + generator.set_timer(TriangleInstruction(on=True, pitch=PITCH, detune=-3)) + + assert generator.timer.timer == generator.timer_table[PITCH] - 3 diff --git a/tests/unit/sampletones_core/instructions/test_tonal.py b/tests/unit/sampletones_core/instructions/test_tonal.py new file mode 100644 index 000000000..7d94d9adc --- /dev/null +++ b/tests/unit/sampletones_core/instructions/test_tonal.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.general import ( + HI_PITCH_FACTOR, + PITCH_BEND_MAX, + PITCH_BEND_MIN, +) +from sampletones_core.instructions import PulseInstruction, TriangleInstruction +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +PITCH: Final[int] = 60 +VOLUME: Final[int] = 15 + + +def _pulse(**bend: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=0, **bend) + + +class TestTimerOffset(BaseTestSuite): + """The two bend dimensions read as one offset, the coarse one counting sixteen steps.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + detune: int + coarse_detune: int + expected: int + + test_cases: Tuple["TestTimerOffset.TestCase", ...] = ( + TestCase(label="no bend", detune=0, coarse_detune=0, expected=0), + TestCase(label="fine alone", detune=7, coarse_detune=0, expected=7), + TestCase(label="coarse alone", detune=0, coarse_detune=3, expected=3 * HI_PITCH_FACTOR), + TestCase(label="both", detune=-4, coarse_detune=-1, expected=-4 - HI_PITCH_FACTOR), + TestCase(label="opposing", detune=-1, coarse_detune=1, expected=HI_PITCH_FACTOR - 1), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_offset_sums_both_dimensions(self, test_case: "TestTimerOffset.TestCase") -> None: + instruction = _pulse(detune=test_case.detune, coarse_detune=test_case.coarse_detune) + + assert instruction.timer_offset == test_case.expected + assert instruction.bent == (test_case.expected != 0) + + +class TestBendDefaults: + def test_a_frame_states_no_bend_unless_it_names_one(self) -> None: + assert _pulse().timer_offset == 0 + assert TriangleInstruction(on=True, pitch=PITCH).timer_offset == 0 + + def test_a_bend_past_the_range_a_sequence_stores_is_refused(self) -> None: + with pytest.raises(ValidationError): + _pulse(detune=PITCH_BEND_MAX + 1) + + with pytest.raises(ValidationError): + _pulse(coarse_detune=PITCH_BEND_MIN - 1) diff --git a/tests/unit/sampletones_core/project/voices/test_instrument.py b/tests/unit/sampletones_core/project/voices/test_instrument.py index fdd40c8e6..ec452b8c3 100644 --- a/tests/unit/sampletones_core/project/voices/test_instrument.py +++ b/tests/unit/sampletones_core/project/voices/test_instrument.py @@ -176,7 +176,7 @@ def test_a_volume_past_the_range_is_refused(self) -> None: def test_a_dimension_an_instrument_writes_none_of_is_refused(self) -> None: with pytest.raises(KeyError): - InstrumentEnvelopes().with_envelope(FeatureKey.PITCH, Envelope(items=(1,))) + InstrumentEnvelopes().with_envelope(FeatureKey.INITIAL_PITCH, Envelope(items=(1,))) def test_the_frame_count_is_the_longest_dimension(self) -> None: envelopes = InstrumentEnvelopes(volume=Envelope(items=VOLUME), duty_cycle=Envelope(items=DUTY_CYCLE)) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index a857cde42..2491b6f62 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -11,7 +11,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, FeatureKey, HierarchyMode from sampletones_core.data import Metadata -from sampletones_core.features import resting_reference +from sampletones_core.features import resting_held_features, resting_reference from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem @@ -514,10 +514,20 @@ class TestHeldFeatures: wrote from the reconstruction rather than from the frames. """ - def test_a_fresh_reconstruction_writes_every_dimension(self) -> None: + def test_a_fresh_reconstruction_writes_every_dimension_it_chose(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.held_features[ChannelName.PULSE1] == () + assert reconstruction.held_features[ChannelName.PULSE1] == ( + FeatureKey.PITCH, + FeatureKey.HI_PITCH, + ) + + def test_a_conversion_that_bent_a_note_writes_the_bend_it_made(self) -> None: + """A bend is a choice the conversion made, so the dimension carrying it is written.""" + reconstruction = _reconstruction([_pulse(_BASE_PITCH).model_copy(update={"detune": 4})]) + + assert FeatureKey.PITCH not in reconstruction.held_features[ChannelName.PULSE1] + assert FeatureKey.HI_PITCH in reconstruction.held_features[ChannelName.PULSE1] def test_a_held_dimension_exports_an_empty_envelope(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) @@ -573,15 +583,8 @@ def test_the_record_reads_back_off_the_exported_envelopes(self) -> None: def test_a_channel_standing_by_leaves_every_dimension_it_offers(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - assert reconstruction.held_features[ChannelName.TRIANGLE] == ( - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - ) - assert reconstruction.held_features[ChannelName.NOISE] == ( - FeatureKey.VOLUME, - FeatureKey.ARPEGGIO, - FeatureKey.DUTY_CYCLE, - ) + for channel_name in (ChannelName.TRIANGLE, ChannelName.NOISE): + assert reconstruction.held_features[channel_name] == resting_held_features(channel_name) def test_clearing_the_last_frame_records_what_standing_by_records(self) -> None: """A channel edited out of play reads the same as one that never played.""" @@ -592,7 +595,7 @@ def test_clearing_the_last_frame_records_what_standing_by_records(self) -> None: [], np.zeros(0, dtype=np.float32), resting_reference(ChannelName.PULSE1), - (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + resting_held_features(ChannelName.PULSE1), ) assert reconstruction.streams[ChannelName.PULSE1] == InstructionsItem.resting(ChannelName.PULSE1) From a8ebc78e28ae63dbab0a87e44ac6cf221691f9f6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 03:56:38 +0200 Subject: [PATCH 137/142] Added: a hi-pitch palette token and the bend rows the instruments panel draws --- .../layout/general/colors/feature.py | 1 + .../reconstruction/instruments/config.py | 2 +- .../reconstruction/instruments/instruments.py | 23 +++++++ src/sampletones_config/lang/en.yaml | 2 + .../layout/general/colors.yaml | 1 + src/sampletones_config/palettes/dark.yaml | 1 + src/sampletones_config/palettes/light.yaml | 1 + src/sampletones_config/palettes/studio.yaml | 1 + src/sampletones_core/exporters/tonal.py | 4 +- src/sampletones_core/features/__init__.py | 2 + src/sampletones_core/features/spec.py | 6 ++ .../reconstruction/test_instruments_panel.py | 66 +++++++++++++++++++ 12 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/sampletones_application/layout/general/colors/feature.py b/src/sampletones_application/layout/general/colors/feature.py index b9d9ee0f6..32aac5b3a 100644 --- a/src/sampletones_application/layout/general/colors/feature.py +++ b/src/sampletones_application/layout/general/colors/feature.py @@ -14,4 +14,5 @@ class FeatureColors(BaseModel, extra="forbid", frozen=True): volume: WrittenColor arpeggio: WrittenColor pitch: WrittenColor + hi_pitch: WrittenColor duty_cycle: WrittenColor diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py index 9a056a094..b7f0629a0 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py @@ -58,7 +58,7 @@ def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, BaseColor FeatureKey.VOLUME: feature_colors.volume, FeatureKey.ARPEGGIO: feature_colors.arpeggio, FeatureKey.PITCH: feature_colors.pitch, - FeatureKey.HI_PITCH: feature_colors.pitch, + FeatureKey.HI_PITCH: feature_colors.hi_pitch, FeatureKey.DUTY_CYCLE: feature_colors.duty_cycle, } diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 691c89292..ba2f149c7 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -92,6 +92,7 @@ ) from sampletones_core.exporters import Features from sampletones_core.features import ( + BEND_FEATURES, CHANNEL_GENERATOR_KIND, resting_reference, supported_features, @@ -923,6 +924,28 @@ def _add_raw_data_text( raw_data_tag, partial(self._sequence_status_message, channel_name, feature_key), ) + self._explain_bend(raw_data_tag, feature_key) + + def _explain_bend(self, raw_data_tag: str, feature_key: FeatureKey) -> None: + """Says what one item of a bend dimension is worth, since that follows the note it bends. + + A divider step spans well under a cent at the lowest notes and a whole semitone at the + highest, so the axis a bend is drawn on states the range a sequence stores rather than the + distance a value covers. + """ + if feature_key not in BEND_FEATURES: + return + + show_tooltip( + raw_data_tag, + self._language_manager[ + ( + "reconstructions.instruments.tooltip.hi_pitch_bend" + if feature_key is FeatureKey.HI_PITCH + else "reconstructions.instruments.tooltip.pitch_bend" + ) + ], + ) def _sequence_status_message( self, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index b1493b462..376a53b10 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -513,6 +513,8 @@ reconstructions.instruments.title.not_loaded_dialog: "Reconstruction not loaded" reconstructions.instruments.title.export_wav_dialog: "Export WAV" reconstructions.instruments.title.export_instrument_dialog: "Export instrument" reconstructions.instruments.title.export_instruments_dialog: "Export instruments" +reconstructions.instruments.tooltip.pitch_bend: "Each item shifts the note's divider by one step. What a step is worth follows the note: under a cent at the lowest notes, widening to a whole semitone at the highest." +reconstructions.instruments.tooltip.hi_pitch_bend: "Each item shifts the note's divider by sixteen steps, so one item spans about a semitone in the middle register. It reaches further than the pitch dimension and lands more coarsely." reconstructions.instruments.tooltip.audition: "Play this instrument with the note keys: the bottom row starts at the tracker's octave and the top row an octave above it." reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the initial {} by name (e.g. {}) or value ({})." diff --git a/src/sampletones_config/layout/general/colors.yaml b/src/sampletones_config/layout/general/colors.yaml index bc47a016c..fd1995646 100644 --- a/src/sampletones_config/layout/general/colors.yaml +++ b/src/sampletones_config/layout/general/colors.yaml @@ -19,6 +19,7 @@ features: volume: .feature_volume arpeggio: .feature_arpeggio pitch: .feature_pitch + hi_pitch: .feature_hi_pitch duty_cycle: .feature_duty_cycle channels: pulse1: .channel_pulse1 diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 403b17c28..cf951bb7e 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -152,6 +152,7 @@ colors: feature_volume: "#7ee787" feature_arpeggio: "#f0a35e" feature_pitch: "#6fb8ff" + feature_hi_pitch: "#a99cff" feature_duty_cycle: "#e6c26a" caret_fill: "#4fa6ff55" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 71c7b8af6..04d5d7927 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -152,6 +152,7 @@ colors: feature_volume: "#16702e" feature_arpeggio: "#a8410a" feature_pitch: "#0a5aa8" + feature_hi_pitch: "#4a3fa8" feature_duty_cycle: "#7a5c00" caret_fill: "#0b4c8c58" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 1db0dc9ed..3c67dc74b 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -152,6 +152,7 @@ colors: feature_volume: "#64ff64" feature_arpeggio: "#ff9664" feature_pitch: "#64c8ff" + feature_hi_pitch: "#9664ff" feature_duty_cycle: "#ffc864" caret_fill: "#8888ff80" diff --git a/src/sampletones_core/exporters/tonal.py b/src/sampletones_core/exporters/tonal.py index 948bf21aa..90661ebfe 100644 --- a/src/sampletones_core/exporters/tonal.py +++ b/src/sampletones_core/exporters/tonal.py @@ -2,7 +2,7 @@ from typing import Dict, List, Tuple, TypeVar, Union from sampletones_core.constants.enums import FeatureKey -from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.features import BEND_FEATURES, CHANNEL_FEATURE_DEFAULTS from sampletones_core.instructions import TonalInstruction from .exporter import Exporter @@ -85,5 +85,5 @@ def bend_fields(cls, dictionary: Dict[str, Union[bool, int]]) -> Dict[str, int]: CHANNEL_FEATURE_DEFAULTS[feature_key], ) ) - for feature_key in (FeatureKey.PITCH, FeatureKey.HI_PITCH) + for feature_key in BEND_FEATURES } diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index 35e3809e0..5b51dd5ac 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -1,4 +1,5 @@ from .spec import ( + BEND_FEATURES, CHANNEL_FEATURE_DEFAULTS, CHANNEL_GENERATOR_KIND, FEATURE_DIMENSION_ORDER, @@ -19,6 +20,7 @@ ) __all__ = [ + "BEND_FEATURES", "CHANNEL_FEATURE_DEFAULTS", "FEATURE_DIMENSION_ORDER", "GENERATOR_CHANNEL_KINDS", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index c46164d34..f71aa45ba 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -31,6 +31,12 @@ class FeatureRange: ) +BEND_FEATURES: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.PITCH, + FeatureKey.HI_PITCH, +) + + CHANNEL_FEATURE_DEFAULTS: Final[Dict[FeatureKey, int]] = { FeatureKey.VOLUME: MAX_VOLUME, FeatureKey.ARPEGGIO: 0, diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index ff08da7e1..96157ad7f 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -43,6 +43,8 @@ ) from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName +from sampletones_core.constants.general import PITCH_BEND_MAX, PITCH_BEND_MIN +from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.formats.famitracker.specification.sequences import ( @@ -496,6 +498,70 @@ def test_only_a_playing_channel_offers_its_export( } +class TestTheDimensionsAChannelShows: + """A channel plots the dimensions its generator offers, the bend among them where it reads one. + + The panel builds one row per offered dimension, so what a channel shows follows the generator + spec rather than a list of its own. + """ + + @pytest.mark.parametrize( + "channel_name", + ChannelName.items(), + ids=lambda channel_name: str(channel_name), + ) + def test_a_channel_plots_every_dimension_it_offers( + self, + panel: GUIReconstructionInstrumentsPanel, + channel_name: ChannelName, + ) -> None: + kind = CHANNEL_GENERATOR_KIND[channel_name] + + assert list(panel._feature_plot_configs[kind]) == supported_features(kind) + + @pytest.mark.parametrize( + "kind", + (GeneratorName.PULSE, GeneratorName.TRIANGLE), + ids=lambda kind: str(kind), + ) + def test_a_tonal_channel_plots_both_bend_dimensions( + self, + panel: GUIReconstructionInstrumentsPanel, + kind: GeneratorName, + ) -> None: + plotted = panel._feature_plot_configs[kind] + + assert FeatureKey.PITCH in plotted + assert FeatureKey.HI_PITCH in plotted + + def test_the_noise_channel_plots_no_bend(self, panel: GUIReconstructionInstrumentsPanel) -> None: + plotted = panel._feature_plot_configs[GeneratorName.NOISE] + + assert FeatureKey.PITCH not in plotted + assert FeatureKey.HI_PITCH not in plotted + + def test_the_two_bend_dimensions_read_apart(self, panel: GUIReconstructionInstrumentsPanel) -> None: + """One counts a divider step and the other sixteen, so a reader tells them apart at a glance.""" + plotted = panel._feature_plot_configs[GeneratorName.PULSE] + + assert plotted[FeatureKey.PITCH].color != plotted[FeatureKey.HI_PITCH].color + assert plotted[FeatureKey.PITCH].label != plotted[FeatureKey.HI_PITCH].label + + @pytest.mark.parametrize( + "feature_key", + (FeatureKey.PITCH, FeatureKey.HI_PITCH), + ids=lambda feature_key: str(feature_key), + ) + def test_a_bend_plot_spans_the_range_a_sequence_stores( + self, + panel: GUIReconstructionInstrumentsPanel, + feature_key: FeatureKey, + ) -> None: + plotted = panel._feature_plot_configs[GeneratorName.PULSE][feature_key] + + assert plotted.data_range == (PITCH_BEND_MIN, PITCH_BEND_MAX) + + class TestSizeVisibility: def test_a_loaded_reconstruction_shows_the_sample_size( self, From 4e6d2b8710088c0615f8fc7c076d72202c7c9820 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 04:07:37 +0200 Subject: [PATCH 138/142] Carried: pitch and hi-pitch sequences through a FamiTracker file, pinned by the arpeggio beside them --- docs/development/bugs-and-todos.md | 14 +++-- docs/formats/famitracker.md | 31 ++++++++--- docs/glossary.md | 9 +++ .../categories/elements/sequencer.py | 3 +- .../categories/instrument.py | 9 ++- src/sampletones_config/lang/en.yaml | 5 +- .../formats/famitracker/sequences/features.py | 55 ++++++++++++++++++- .../famitracker/specification/sequences.py | 9 ++- .../formats/famitracker/voice.py | 40 +++++++++++--- .../categories/test_instrument.py | 8 +-- .../coordinators/tabs/test_sequencer.py | 4 +- .../logic/sequencer/test_voices.py | 2 +- .../famitracker/sequences/test_features.py | 39 +++++++++++++ .../formats/famitracker/test_voice.py | 40 ++++++++++++-- 14 files changed, 221 insertions(+), 47 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 82b57d029..2ab503e8d 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -14,19 +14,23 @@ ### Tracker -The first four entries are also what an imported `.fti` reports as left to the file +The first two entries are also what an imported `.fti` reports as left to the file (section C of `formats/famitracker.md`), so each one closed is a dimension the import starts carrying. -* Pitch and hi-pitch envelopes: a per-tick period bend, where an instruction's pitch is a whole - semitone. Sounding them needs a sub-semitone offset in the instruction model and raw timer values - in the NSF planes, which reaches the reconstruction search space, the instruction library and the - compression pitch table. The two sequences reach a tracker file today and are written empty. * Release points: `NoteValue.RELEASE` stands in the FamiTracker specification while a note-off cuts the channel. A release segment would need the playback walk, the NSF driver and `NoteOff` to gain one. * Arpeggio modes: a sequence's `setting` byte states absolute. Fixed, relative and scheme need an enum of their own, and scheme needs the item bit-packing FamiTracker gives it. +* The bend in the NSF planes. A channel's value plane names a pitch as a semitone index, which is + what makes `TRANSPOSED_PHRASE` work, so a divider offset needs a plane of its own: `PLANE_COUNT` + 8 → 12, a wider song header, another `PLANE_STATE_SIZE` block per channel, another + `plane_advance` per channel per tick and a sixteen-bit add before the timer registers are + written. Until it lands, an NSF renders a bent note at the note's own divider. +* The bend in a Bitphase export. `formats/bitphase/envelopes.py` states the three dimensions it + writes; `NesInstrumentRow` already carries `tone_add` and `tone_accumulation`, so the mapping is + confined to that module. * A transpose or a volume typed in the sample column of a row holding no sample reaches every channel. The column summarizes the channels its samples cover, and a row covering none falls back to all four so a value typed there lands somewhere; the reference slot keeps the narrower diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 549667748..afc41b613 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -128,8 +128,8 @@ The five sequence kinds, in slot order (`SequenceKind` in `specification/sequenc | --- | --- | --- | | 0 | Volume | output volume per tick, 0–15 | | 1 | Arpeggio | semitone offsets added to the played note (absolute mode) | -| 2 | Pitch | fine per-tick pitch bend, applied cumulatively | -| 3 | Hi-pitch | coarse pitch bend | +| 2 | Pitch | per-tick divider offset, one step per unit | +| 3 | Hi-pitch | per-tick divider offset, sixteen steps per unit | | 4 | Duty / Noise | pulse duty cycle 0–3, or the noise short/long mode | Each sequence carries: @@ -142,6 +142,18 @@ Each sequence carries: - **setting** — the sequence mode; for arpeggio, `0` selects absolute (the offsets are added to the played note). +**The bend and the arpeggio that pins it.** FamiTracker walks an instrument's sequences in +slot order, and an arpeggio in absolute mode reloads the period from the note before the two bend +sequences add to it (`CSeqInstHandler::ProcessSequence`). While the arpeggio runs, a pitch item is +therefore an *offset from the note* for that tick, and a hi-pitch item is the same offset counted +sixteen dividers at a time; once the arpeggio halts, the same items start accumulating on the +running period instead. _SampleToNES_ writes and reads a bend as the per-tick offset, so the writer +guarantees the arpeggio that makes that reading hold: an instrument writing a bend and no arpeggio +gains one holding a single zero at loop point 0, and a shorter arpeggio is brought to the bend's +length holding its final note. What one step is worth follows the note it bends — under a cent at +the lowest notes, widening to a whole semitone at the highest, where the divider grid is already +coarser than the note grid. + **Looping.** Each sequence states the item it repeats from, so a held note sustains from that item on. A dimension written without one leaves its loop point at `-1` and plays its items once. Every envelope carries its own point, so a two-item duty cycle circles on its @@ -184,8 +196,9 @@ place in the instrument table, so the instruments an export writes are the chann that play. The arpeggio sequence carries the reconstruction's pitch contour as signed offsets, and triggering the instrument at `initial_pitch` replays that contour. Volume, duty -(or noise mode) and any pitch sequences carry across directly. The DPCM -key-assignment table is empty by design. +(or noise mode) and the two bend sequences carry across directly; a conversion that bent no note +records both bend dimensions as ones the channel governs, so they reach the file as disabled slots. +The DPCM key-assignment table is empty by design. An [instrument](../glossary.md#instrument) written by hand is one set of envelopes every channel reads, which is the instrument model FamiTracker itself uses, so it @@ -217,8 +230,8 @@ one into the voice pool as a hand-written [instrument](../glossary.md#instrument `instrument.py::read_fti` parses the layout in section A.1, and `voice.py::instrument_to_voice` makes a voice of the 2A03 instrument it holds. -A voice carries three of the five dimensions — volume, arpeggio and duty — each with the -item it repeats from, so those come across as they stand. The voice takes the name the +A voice carries all five dimensions — volume, arpeggio, pitch, hi-pitch and duty — each with +the item it repeats from, so those come across as they stand. The voice takes the name the file states, and a file naming nothing leaves the voice named after the file itself. The arpeggio is read as offsets from the pitch a hand-written voice rests at, since a tracker instrument sounds at whatever note a row names it with. @@ -233,8 +246,7 @@ file carried (`InstrumentOmission` in `voice.py`): | Stated in the file | What the voice holds | | --- | --- | -| a pitch envelope | a note moved in whole semitones, which the arpeggio carries | -| a hi-pitch envelope | the same | +| a bend outrunning its arpeggio | each item as the offset it states, where the tracker would accumulate it past the arpeggio's last tick (section B) | | a release point | a note the pattern cuts with a note-off | | an arpeggio in fixed, relative or scheme mode | absolute offsets | @@ -298,7 +310,8 @@ each instrument's body: a sequence-enable bitmask, then one pointer per populate An instrument with `n` populated sequences carrying `s₁ … sₙ` items therefore occupies `3 + 2n` bytes of the instrument region and `Σ (4 + sᵢ)` of the sequence region. A dimension the channel leaves unused is written as a disabled slot, and the populated sequences alone are -charged: `n` is 3 on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle. +charged: a reconstruction that bent no note charges 3 sequences on the pulse and noise channels +(volume, arpeggio, duty) and 2 on triangle, and each bend an instrument writes adds one more. Each sequence is charged at its own length (section B), so shortening any one dimension shows in the figure, and an instrument tops out at 777 bytes — three sequences at the 252-item limit. diff --git a/docs/glossary.md b/docs/glossary.md index 5fdbd07fb..3c7217875 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -179,6 +179,15 @@ reconstructed samples into a song. In a FamiTracker instrument, a per-tick envelope for one dimension: volume, arpeggio, pitch, hi-pitch, or duty/noise mode. +### Bend + +How far a frame sounds from the note it names, counted in steps of the divider the +channel loads. The **pitch** dimension counts one step per item and the **hi-pitch** +dimension sixteen, and the two add up. What a step is worth follows the note: well under +a cent at the lowest notes, widening to a whole semitone at the highest, where the +divider grid is already coarser than the note grid. Only the pulse and triangle channels +read a bend; the noise channel's sixteen periods have no finer grid. + ### Pattern A block of tracker rows spanning the channels. A song plays its patterns in an diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 1468de641..ac29f18e9 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -88,8 +88,7 @@ class SequencerVoicesElements(AbstractElement): CONTEXT_MOVE_BOTTOM = "context_move_bottom" CONTEXT_INSTRUMENT_FROM = "context_instrument_from" CONTEXT_EXPORT_INSTRUMENT = "context_export_instrument" - OMISSION_PITCH = "omission_pitch" - OMISSION_HI_PITCH = "omission_hi_pitch" + OMISSION_CUMULATIVE_BEND = "omission_cumulative_bend" OMISSION_RELEASE_POINT = "omission_release_point" OMISSION_ARPEGGIO_MODE = "omission_arpeggio_mode" diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py index a125d3253..3a92804ac 100644 --- a/src/sampletones_application/categories/instrument.py +++ b/src/sampletones_application/categories/instrument.py @@ -9,8 +9,7 @@ from sampletones_core.formats.famitracker.voice import InstrumentOmission OMISSION_ELEMENTS: Final[Dict[InstrumentOmission, SequencerVoicesElements]] = { - InstrumentOmission.PITCH: SequencerVoicesElements.OMISSION_PITCH, - InstrumentOmission.HI_PITCH: SequencerVoicesElements.OMISSION_HI_PITCH, + InstrumentOmission.CUMULATIVE_BEND: SequencerVoicesElements.OMISSION_CUMULATIVE_BEND, InstrumentOmission.RELEASE_POINT: SequencerVoicesElements.OMISSION_RELEASE_POINT, InstrumentOmission.ARPEGGIO_MODE: SequencerVoicesElements.OMISSION_ARPEGGIO_MODE, } @@ -35,9 +34,9 @@ def omission_label( class InstrumentImportMessages: """The words a read instrument file is reported in. - A ``.fti`` states a channel's whole instrument, and a voice takes the three envelopes every - channel here plays. Whatever the file states past them stays in the file, so the import names - it in the reader's own words as the voice arrives. + A ``.fti`` states a channel's whole instrument, and a voice takes every envelope it holds. + Whatever the file states past them stays in the file, so the import names it in the reader's + own words as the voice arrives. Attributes: title: Title of the dialog reporting a finished import. diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 376a53b10..19772cb6a 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -624,8 +624,7 @@ sequencer.voices.label.context_move_top: "Move to top" sequencer.voices.label.context_move_bottom: "Move to bottom" sequencer.voices.label.context_instrument_from: "New instrument from" sequencer.voices.label.context_export_instrument: "Export instrument..." -sequencer.voices.label.omission_pitch: "a pitch envelope" -sequencer.voices.label.omission_hi_pitch: "a hi-pitch envelope" +sequencer.voices.label.omission_cumulative_bend: "a bend the tracker piles up, since its arpeggio stops pinning the note" sequencer.voices.label.omission_release_point: "a release point" sequencer.voices.label.omission_arpeggio_mode: "an arpeggio in fixed, relative or scheme mode" sequencer.voices.tooltip.new_instrument: "Add an instrument written by hand, playable on any channel" @@ -639,7 +638,7 @@ sequencer.voices.template.instrument_name: "Instrument {position}" sequencer.voices.template.status_sample: "{name} is a sample playing {channels}. It takes {bytes} as FamiTracker instruments." sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes} as a FamiTracker instrument." sequencer.voices.template.status_channel_separator: ", " -sequencer.voices.template.instrument_omissions: "\"{name}\" plays the volume, arpeggio and duty envelopes the file states.\nThe file also holds, on the instrument's own terms:" +sequencer.voices.template.instrument_omissions: "\"{name}\" plays every envelope the file states.\nThe file also holds, on the instrument's own terms:" sequencer.history.label.history_text: "History" sequencer.history.label.undo: "Undo" diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index 6ed6fdea1..b3711e135 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -6,13 +6,16 @@ from sampletones_core.features.envelope import Envelope, releases from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( + BEND_SEQUENCE_KINDS, FEATURE_KEY_TO_SEQUENCE_KIND, + LOOP_FROM_START, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, SequenceKind, ) ONE_INSTRUMENT: Final[int] = 1 +NO_ARPEGGIO_STEP: Final[int] = 0 def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, InstrumentSequence]: @@ -24,13 +27,16 @@ def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, I advances each sequence on a counter of its own, and each stands within the items the file holds — see :func:`stored_envelope`. + A bend travels with an arpeggio that runs beside it, which is what makes the bend an offset + from the note — see :func:`_pinning_arpeggio`. + Args: features: The per-dimension envelopes describing the slice. Returns: Dict[SequenceKind, InstrumentSequence]: The sequences, one per dimension FamiTracker holds. """ - stored = _stored_envelopes(features) + stored = _pinned(_stored_envelopes(features)) return {kind: _sequence(kind, stored.get(kind, Envelope[int]())) for kind in SequenceKind} @@ -99,6 +105,53 @@ def features_truncation(features: Features) -> Optional[EnvelopeTruncation]: ) +def _pinned(stored: Dict[SequenceKind, Envelope[int]]) -> Dict[SequenceKind, Envelope[int]]: + """These sequences with an arpeggio that runs for as long as the bend beside it does. + + FamiTracker walks an instrument's sequences in slot order, and an arpeggio in absolute mode + reloads the period from the note before the bend sequences add to it. So while the arpeggio + runs, a bend item is the offset from the note that this project writes it as; once the + arpeggio halts, the same items start accumulating on the running period instead. Writing an + arpeggio that covers the bend is what holds the two readings together. + + Args: + stored: The sequences as the file holds them. + + Returns: + Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio reaching the bend's length. + """ + bend_length = max((len(stored.get(kind, Envelope[int]()).items) for kind in BEND_SEQUENCE_KINDS), default=0) + if not bend_length: + return stored + + return {**stored, SequenceKind.ARPEGGIO: _pinning_arpeggio(stored, bend_length)} + + +def _pinning_arpeggio(stored: Dict[SequenceKind, Envelope[int]], bend_length: int) -> Envelope[int]: + """The arpeggio that reloads the note for every tick a bend states an offset for. + + An arpeggio circling from a point runs for as long as the note sounds and needs nothing; one + playing its items once holds its final note over the remaining ticks, which is the note it + would rest on anyway; and an instrument writing no arpeggio at all takes one item at its own + note, repeating. + + Args: + stored: The sequences as the file holds them. + bend_length: The ticks the longest bend dimension states. + + Returns: + Envelope[int]: The arpeggio to write. + """ + arpeggio = stored.get(SequenceKind.ARPEGGIO, Envelope[int]()) + if arpeggio.loops: + return arpeggio + + if not arpeggio.items: + return Envelope[int](items=(NO_ARPEGGIO_STEP,), loop_point=LOOP_FROM_START) + + return arpeggio.resized(max(len(arpeggio.items), bend_length)) + + def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]: """Each dimension the slice offers, as the file holds it.""" return { diff --git a/src/sampletones_core/formats/famitracker/specification/sequences.py b/src/sampletones_core/formats/famitracker/specification/sequences.py index a4df4a93b..43822ccf5 100644 --- a/src/sampletones_core/formats/famitracker/specification/sequences.py +++ b/src/sampletones_core/formats/famitracker/specification/sequences.py @@ -1,5 +1,5 @@ from enum import IntEnum -from typing import Dict, Final +from typing import Dict, Final, Tuple from sampletones_core.constants.enums import FeatureKey @@ -14,6 +14,13 @@ class SequenceKind(IntEnum): DUTY = 4 +BEND_SEQUENCE_KINDS: Final[Tuple[SequenceKind, ...]] = ( + SequenceKind.PITCH, + SequenceKind.HI_PITCH, +) + +HI_PITCH_SEQUENCE_FACTOR: Final[int] = 16 + SEQUENCE_COUNT_2A03: Final[int] = 5 MAX_SEQUENCES_PER_TYPE: Final[int] = 128 MAX_SEQUENCE_ITEMS: Final[int] = 252 diff --git a/src/sampletones_core/formats/famitracker/voice.py b/src/sampletones_core/formats/famitracker/voice.py index 30e9adeb2..131ad5d71 100644 --- a/src/sampletones_core/formats/famitracker/voice.py +++ b/src/sampletones_core/formats/famitracker/voice.py @@ -8,6 +8,7 @@ from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( + BEND_SEQUENCE_KINDS, DEFAULT_SEQUENCE_SETTING, LOOP_FROM_START, NO_RELEASE_POINT, @@ -23,8 +24,7 @@ class InstrumentOmission(StrEnum): """What a tracker instrument states beyond the envelopes and loop points a voice holds.""" - PITCH = "pitch" - HI_PITCH = "hi_pitch" + CUMULATIVE_BEND = "cumulative_bend" RELEASE_POINT = "release_point" ARPEGGIO_MODE = "arpeggio_mode" @@ -45,10 +45,10 @@ class ImportedVoice: def instrument_to_voice(instrument: Instrument2A03) -> ImportedVoice: """Makes a voice from a FamiTracker instrument, and names what the instrument stated past it. - A voice carries a volume, an arpeggio and a duty-cycle envelope, each with the item it - repeats from, so those come across as they stand. A tracker instrument states more than that - — a pitch bend, a release segment, an arpeggio mode — and each of those is reported, so a - reader learns what the file held. + A voice carries a volume, an arpeggio, a pitch, a hi-pitch and a duty-cycle envelope, each + with the item it repeats from, so those come across as they stand. A tracker instrument + states more than that — a release segment, an arpeggio mode, a bend the tracker accumulates — + and each of those is reported, so a reader learns what the file held. The voice measures its arpeggio against the pitch a voice added by hand rests at, since a tracker instrument sounds at whatever note a row names it with. @@ -79,6 +79,8 @@ def _voice( envelopes = InstrumentEnvelopes( volume=_envelope(sequences[SequenceKind.VOLUME]), arpeggio=_envelope(sequences[SequenceKind.ARPEGGIO]), + pitch=_envelope(sequences[SequenceKind.PITCH]), + hi_pitch=_envelope(sequences[SequenceKind.HI_PITCH]), duty_cycle=_envelope(sequences[SequenceKind.DUTY]), ) except ValidationError as exception: @@ -118,8 +120,7 @@ def _loop_point(sequence: InstrumentSequence) -> Optional[int]: def _omissions(sequences: Sequences) -> Tuple[InstrumentOmission, ...]: arpeggio = sequences[SequenceKind.ARPEGGIO] held = { - InstrumentOmission.PITCH: sequences[SequenceKind.PITCH].enabled, - InstrumentOmission.HI_PITCH: sequences[SequenceKind.HI_PITCH].enabled, + InstrumentOmission.CUMULATIVE_BEND: _accumulates_bend(sequences), InstrumentOmission.RELEASE_POINT: _holds_release_point(sequences), InstrumentOmission.ARPEGGIO_MODE: arpeggio.enabled and arpeggio.setting != DEFAULT_SEQUENCE_SETTING, } @@ -127,5 +128,28 @@ def _omissions(sequences: Sequences) -> Tuple[InstrumentOmission, ...]: return tuple(omission for omission, stated in held.items() if stated) +def _accumulates_bend(sequences: Sequences) -> bool: + """Whether the tracker would let this file's bend pile up rather than offset each tick. + + A bend item is an offset from the note wherever an absolute arpeggio reloads the period on the + same tick, and the tracker walks the arpeggio before the bend. A file whose arpeggio runs out + before its bend does therefore sounds the remaining items as a drift there, while a voice here + sounds each one as the offset it states. Reporting that is what tells a reader the two readings + part company. + + Args: + sequences: The sequences the file states. + + Returns: + bool: Whether the tracker accumulates any tick of this file's bend. + """ + arpeggio = sequences[SequenceKind.ARPEGGIO] + if arpeggio.enabled and arpeggio.loop_point >= LOOP_FROM_START: + return False + + covered = len(arpeggio.items) if arpeggio.enabled else 0 + return any(len(sequences[kind].items) > covered for kind in BEND_SEQUENCE_KINDS if sequences[kind].enabled) + + def _holds_release_point(sequences: Sequences) -> bool: return any(sequence.enabled and sequence.release_point != NO_RELEASE_POINT for sequence in sequences.values()) diff --git a/tests/unit/sampletones_application/categories/test_instrument.py b/tests/unit/sampletones_application/categories/test_instrument.py index 60ceb652f..ef6f74caf 100644 --- a/tests/unit/sampletones_application/categories/test_instrument.py +++ b/tests/unit/sampletones_application/categories/test_instrument.py @@ -38,13 +38,13 @@ def test_a_file_stating_the_voice_alone_is_reported_nowhere( assert messages.notice("Lead", ()) is None def test_the_voice_is_named_in_the_opening(self, messages: InstrumentImportMessages) -> None: - notice = messages.notice("Lead", (InstrumentOmission.PITCH,)) + notice = messages.notice("Lead", (InstrumentOmission.CUMULATIVE_BEND,)) assert notice is not None assert "Lead" in notice.splitlines()[0] def test_each_dimension_is_listed_on_its_own_line(self, messages: InstrumentImportMessages) -> None: - stated = (InstrumentOmission.PITCH, InstrumentOmission.RELEASE_POINT) + stated = (InstrumentOmission.CUMULATIVE_BEND, InstrumentOmission.RELEASE_POINT) notice = messages.notice("Lead", stated) assert notice is not None @@ -56,7 +56,7 @@ def test_a_dimension_the_file_left_out_is_named_nowhere( self, messages: InstrumentImportMessages, ) -> None: - notice = messages.notice("Lead", (InstrumentOmission.PITCH,)) + notice = messages.notice("Lead", (InstrumentOmission.CUMULATIVE_BEND,)) assert notice is not None assert messages.omissions[InstrumentOmission.RELEASE_POINT] not in notice @@ -68,7 +68,7 @@ def test_a_file_stating_everything_names_everything(self, messages: InstrumentIm assert all(words in notice for words in messages.omissions.values()) def test_the_wording_holds_no_placeholder_open(self, messages: InstrumentImportMessages) -> None: - notice = messages.notice("Lead", (InstrumentOmission.PITCH,)) + notice = messages.notice("Lead", (InstrumentOmission.CUMULATIVE_BEND,)) assert notice is not None assert "{" not in notice diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 7ac4373e7..41b9c095e 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -279,12 +279,12 @@ def test_what_the_file_held_past_the_voice_is_reported( located_file: List[Dict[str, object]], ) -> None: logic = instrument_voices._voices_logic - logic.read_instrument.return_value = _imported(InstrumentOmission.PITCH) + logic.read_instrument.return_value = _imported(InstrumentOmission.CUMULATIVE_BEND) instrument_voices.import_instrument() notice = instrument_voices._dialogs.show_info.call_args.args[1] - assert instrument_voices._import_messages.omissions[InstrumentOmission.PITCH] in notice + assert instrument_voices._import_messages.omissions[InstrumentOmission.CUMULATIVE_BEND] in notice def test_a_file_holding_the_voice_alone_is_reported_nowhere( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index db0b956fb..1d325244d 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -574,7 +574,7 @@ def test_what_the_file_states_past_the_voice_comes_back_with_it(self, tmp_path: ) _, logic = _logic() - assert InstrumentOmission.PITCH in logic.read_instrument(filepath).omissions + assert InstrumentOmission.CUMULATIVE_BEND in logic.read_instrument(filepath).omissions def test_reading_leaves_the_pool_as_it_stands(self, tmp_path: Path) -> None: """The pool is edited by the gesture that adds, so a read alone records no history entry.""" diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 785e7ff09..7298b0551 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -53,6 +53,45 @@ def build( ) +class TestTheArpeggioThatPinsABend: + """A bend travels with an arpeggio, because that is what makes each item an offset. + + FamiTracker walks an instrument's sequences in slot order, and an absolute arpeggio reloads + the period from the note before a bend adds to it. A bend the arpeggio covers therefore sounds + in the tracker as the per-tick offset this project writes it as. + """ + + def test_a_bend_without_an_arpeggio_gains_a_repeating_one(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [], pitch=[3, -3]))[SequenceKind.ARPEGGIO] + + assert arpeggio.enabled is True + assert arpeggio.items == (0,) + assert arpeggio.loop_point == LOOP_FROM_START + + def test_an_arpeggio_shorter_than_the_bend_reaches_its_length(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7], pitch=[1, 2, 3, 4]))[SequenceKind.ARPEGGIO] + + assert arpeggio.items == (4, 7, 7, 7) + + def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: + sequences = features_to_instrument_sequences( + build([15, 0], [0, 5], pitch=[1, 2, 3, 4], loop_point=LOOP_FROM_START) + ) + + assert sequences[SequenceKind.ARPEGGIO].items == (0, 5) + assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START + + def test_an_arpeggio_already_covering_the_bend_is_left_as_it_stands(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9], pitch=[1, 2]))[SequenceKind.ARPEGGIO] + + assert arpeggio.items == (4, 7, 9) + + def test_an_instrument_writing_no_bend_gains_no_arpeggio(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], []))[SequenceKind.ARPEGGIO] + + assert arpeggio.enabled is False + + class TestFeaturesToInstrumentSequences: def test_all_five_kinds_present(self) -> None: sequences = features_to_instrument_sequences(build([15, 0], [0])) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_voice.py b/tests/unit/sampletones_core/formats/famitracker/test_voice.py index e849050e4..c3c2d2383 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_voice.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_voice.py @@ -122,11 +122,40 @@ def omissions(*sequences: InstrumentSequence) -> Mapping[InstrumentOmission, boo def test_a_plain_instrument_leaves_nothing_behind(self) -> None: assert imported(written(SequenceKind.VOLUME, (15, 8, 0))).omissions == () - def test_a_pitch_bend_is_reported(self) -> None: - assert self.omissions(written(SequenceKind.PITCH, (1, -1)))[InstrumentOmission.PITCH] + def test_a_bend_the_tracker_accumulates_is_reported(self) -> None: + """The tracker pins the period only while an arpeggio runs, and this file writes none.""" + assert self.omissions(written(SequenceKind.PITCH, (1, -1)))[InstrumentOmission.CUMULATIVE_BEND] + + def test_a_hi_pitch_bend_the_tracker_accumulates_is_reported(self) -> None: + assert self.omissions(written(SequenceKind.HI_PITCH, (1,)))[InstrumentOmission.CUMULATIVE_BEND] + + def test_a_bend_an_arpeggio_covers_is_carried_rather_than_reported(self) -> None: + """An arpeggio reloading the note every tick is what makes each bend item an offset.""" + assert not self.omissions( + written(SequenceKind.ARPEGGIO, (0, 0)), + written(SequenceKind.PITCH, (1, -1)), + )[InstrumentOmission.CUMULATIVE_BEND] + + def test_a_bend_beside_a_looping_arpeggio_is_carried(self) -> None: + assert not self.omissions( + written(SequenceKind.ARPEGGIO, (0,), loop_point=0), + written(SequenceKind.PITCH, (1, -1, 2, 3)), + )[InstrumentOmission.CUMULATIVE_BEND] + + def test_a_bend_outrunning_its_arpeggio_is_reported(self) -> None: + assert self.omissions( + written(SequenceKind.ARPEGGIO, (0, 0)), + written(SequenceKind.PITCH, (1, -1, 2)), + )[InstrumentOmission.CUMULATIVE_BEND] + + def test_the_bend_a_file_states_reaches_the_voice(self) -> None: + envelopes = imported( + written(SequenceKind.PITCH, (1, -1)), + written(SequenceKind.HI_PITCH, (2,)), + ).voice.envelopes - def test_a_hi_pitch_bend_is_reported(self) -> None: - assert self.omissions(written(SequenceKind.HI_PITCH, (1,)))[InstrumentOmission.HI_PITCH] + assert envelopes.pitch.items == (1, -1) + assert envelopes.hi_pitch.items == (2,) def test_a_release_point_is_reported(self) -> None: volume = written(SequenceKind.VOLUME, (15, 8), release_point=1) @@ -153,9 +182,8 @@ def test_a_point_per_sequence_is_carried_rather_than_reported(self) -> None: def test_every_dimension_past_the_voice_is_named_at_once(self) -> None: reported = imported( written(SequenceKind.VOLUME, (15, 8), loop_point=1), - written(SequenceKind.ARPEGGIO, (0, 3), setting=ARPEGGIO_SCHEME_SETTING), + written(SequenceKind.ARPEGGIO, (0,), setting=ARPEGGIO_SCHEME_SETTING), written(SequenceKind.PITCH, (1, -1), release_point=1), - written(SequenceKind.HI_PITCH, (0,)), ).omissions assert set(reported) == set(InstrumentOmission) From 9c838dbe1927c4c3cf44d4585477e8466fbbc2df Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 04:08:03 +0200 Subject: [PATCH 139/142] Held: the bend a voice writes through the song walk and a row's modifiers --- .../sampletones_core/performance/test_bend.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/unit/sampletones_core/performance/test_bend.py diff --git a/tests/unit/sampletones_core/performance/test_bend.py b/tests/unit/sampletones_core/performance/test_bend.py new file mode 100644 index 000000000..467eec68e --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_bend.py @@ -0,0 +1,117 @@ +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.performance import ( + ChannelPerformance, + VoiceReading, + apply_modifiers, + sound_tick, +) +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument + +REFERENCE: Final[int] = 60 +SEMITONE: Final[int] = 1 +BEND: Final[int] = 5 +COARSE_BEND: Final[int] = 2 + + +def _instrument(**envelopes: Envelope[int]) -> Instrument: + return Instrument( + name="bent", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME, MAX_VOLUME)), **envelopes), + initial_pitch=REFERENCE, + ) + + +def _played(instrument: Instrument, ticks: int) -> List[InstructionUnion]: + """The frames one channel sounds, tick by tick, with the channel's own values filled in.""" + reading = VoiceReading.read(instrument, ChannelName.PULSE1) + assert reading is not None + + performance = ChannelPerformance(voice_id=instrument.id) + played: List[InstructionUnion] = [] + for _ in range(ticks): + sounded = sound_tick(performance, reading) + assert sounded is not None + played.append(sounded) + + return played + + +class TestABendThroughTheSongWalk: + def test_the_bend_an_envelope_writes_reaches_the_frame_a_tick_sounds(self) -> None: + played = _played(_instrument(pitch=Envelope(items=(BEND, -BEND))), 2) + + assert [frame.detune for frame in played] == [BEND, -BEND] + + def test_both_dimensions_reach_the_frame_together(self) -> None: + played = _played( + _instrument( + pitch=Envelope(items=(BEND,)), + hi_pitch=Envelope(items=(COARSE_BEND,)), + ), + 1, + ) + + assert played[0].detune == BEND + assert played[0].coarse_detune == COARSE_BEND + + def test_a_channel_holds_the_bend_a_voice_left_it(self) -> None: + """A voice writing no bend sounds at what the channel holds, which is what the last one set.""" + reading = VoiceReading.read(_instrument(), ChannelName.PULSE1) + assert reading is not None + + performance = ChannelPerformance(voice_id="held") + performance.feature_values[FeatureKey.PITCH] = BEND + sounded = sound_tick(performance, reading) + + assert sounded is not None + assert sounded.detune == BEND + + def test_a_bend_a_voice_writes_becomes_what_the_channel_holds(self) -> None: + reading = VoiceReading.read(_instrument(pitch=Envelope(items=(BEND,))), ChannelName.PULSE1) + assert reading is not None + + performance = ChannelPerformance(voice_id="written") + sound_tick(performance, reading) + + assert performance.feature_values[FeatureKey.PITCH] == BEND + + +class TestABendUnderARowsModifiers: + @pytest.mark.parametrize("transpose", (-SEMITONE, 0, SEMITONE), ids=("down", "none", "up")) + def test_a_transpose_moves_the_note_and_leaves_the_bend_where_it_is(self, transpose: int) -> None: + """A row states the note; how far off that note the frame sounds is the frame's own.""" + instruction = PulseInstruction( + on=True, + pitch=REFERENCE, + volume=MAX_VOLUME, + duty_cycle=0, + detune=BEND, + coarse_detune=COARSE_BEND, + ) + + sounded = apply_modifiers(instruction, transpose, MAX_VOLUME) + + assert sounded.pitch == REFERENCE + transpose + assert sounded.detune == BEND + assert sounded.coarse_detune == COARSE_BEND + + def test_a_row_volume_leaves_the_bend_where_it_is(self) -> None: + instruction = PulseInstruction( + on=True, + pitch=REFERENCE, + volume=MAX_VOLUME, + duty_cycle=0, + detune=BEND, + ) + + sounded = apply_modifiers(instruction, 0, MAX_VOLUME // 2) + + assert sounded.detune == BEND From 080c66eb3ddccf63f407426671b7098b2baf97f8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 04:41:03 +0200 Subject: [PATCH 140/142] Added: a refinement pass bending each chosen note onto the divider its source sounds --- docs/concepts/reconstruction.md | 89 +++++++- docs/development/bugs-and-todos.md | 12 ++ docs/formats/nsf.md | 8 + src/sampletones_core/configs/generation.py | 28 +++ src/sampletones_core/constants/algorithm.py | 7 + src/sampletones_core/fft/instantaneous.py | 156 ++++++++++++++ src/sampletones_core/generators/__init__.py | 2 + src/sampletones_core/generators/tonal.py | 63 +++++- src/sampletones_core/generators/types.py | 1 + .../reconstructor/reconstructor.py | 56 +++-- .../reconstructor/refinement/__init__.py | 9 + .../reconstructor/refinement/refiner.py | 164 ++++++++++++++ .../reconstructor/refinement/smoothing.py | 87 ++++++++ src/sampletones_player/registers/channel.py | 8 +- src/sampletones_player/registers/playable.py | 36 ++++ tests/benchmarks/test_pitch_bend.py | 59 +++++ tests/integration/nsf/console/instructions.py | 27 +++ tests/integration/nsf/test_backend.py | 15 +- tests/integration/nsf/test_driver_audio.py | 22 +- .../reconstruction/test_pitch_refinement.py | 201 ++++++++++++++++++ .../fft/test_instantaneous.py | 97 +++++++++ .../reconstructor/refinement/__init__.py | 0 .../refinement/test_smoothing.py | 109 ++++++++++ 23 files changed, 1224 insertions(+), 32 deletions(-) create mode 100644 src/sampletones_core/fft/instantaneous.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py create mode 100644 src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py create mode 100644 src/sampletones_player/registers/playable.py create mode 100644 tests/integration/reconstruction/test_pitch_refinement.py create mode 100644 tests/unit/sampletones_core/fft/test_instantaneous.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/refinement/__init__.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index e1293c93e..d954e7e48 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -62,12 +62,13 @@ input through a fixed sequence of stages: criterion (§5 and §4). 6. **Decode** each channel's stream, reading its candidates across the whole recording (§5). -7. **Render** the chosen instructions back into audio through the generators, +7. **Refine** each chosen note onto the divider the recording's own fundamental stands at (§6). +8. **Render** the chosen instructions back into audio through the generators, keeping each oscillator continuous across frames. -8. **Reassemble** the channels into the final approximation and package it, with the +9. **Reassemble** the channels into the final approximation and package it, with the instruction streams, as a `Reconstruction`. -Stages 3–6 are where the algorithms described below live; the rest is preparation +Stages 3–7 are where the algorithms described below live; the rest is preparation and playback. A run says which of these it is in as it passes through them, so a reader watching a @@ -263,7 +264,79 @@ A resting frame reaches the decoder as a column of one, so a channel that no sou took sits in the path as the off state it is, and coming back on costs what any other on/off change costs. -## 6. Rendering and reassembly +## 6. Refining the pitch + +The catalog is built on the equal-tempered grid, so the matching can place a frame no closer than +the nearest semitone. The hardware is finer than that: a note reaches a channel as an 11-bit +divider, and one step of that divider spans **0.85 cents at A-0, 4 cents at C-3, 16 cents at C-5**, +reaching a whole semitone only around C-7, where the divider grid and the note grid meet. Everything +below that is room the matching leaves unused, and material that was never in A=440 equal +temperament — most recordings of most instruments — sits somewhere inside it. + +`sampletones_core.reconstructions.reconstructor.refinement` spends that room, after the decoder has +settled which note each frame plays and before the frames are rendered. + +### 6.1 Reading rather than searching + +The refinement does not search. Two measurements settle why: + +- Against a **matched** candidate the criterion answers a detune smoothly and monotonically — a + 25-cent error costs about 0.09 where a 50-cent error costs about 0.40. Against a **realistic** + target, where the candidate cannot match the timbre, that response is a small ripple on a + timbre-dominated floor with many local minima, and taking the lowest-cost divider over a sweep + lands 15–30 cents from the truth. +- Searching also costs what the library exists to avoid. Scoring one extra candidate per frame + means rendering it and extracting its feature, which measures around **2.1 s per second of + audio** — more than a whole conversion of the same audio. + +So the answer is read out of the transform instead. `sampletones_core.fft.instantaneous` takes the +**phase** the constant-Q transform already computes and `calculate_cqt_spectrum_columns` discards. +A partial standing between two bin centers still advances its phase at its own rate, so comparing +that advance across two columns against the rate the bin itself turns at states the partial's +frequency far more finely than the bins are spaced. Reading the first few harmonics of the note the +decoder chose, each weighted by the energy behind it and each settled against the fundamental the +harmonics below it agreed on, places the note **within a tenth of a cent** across the whole range. + +The reading also states how much of the frame stands behind it — the share of the column's energy +its harmonics hold. A pitched frame reads around 0.5, a frame sharing the channel with another tone +around 0.3, and noise around 0.04, so one threshold separates the frames worth bending from the +frames with no pitch to read. + +### 6.2 Landing the note, and holding it + +A reading becomes a bend through the generator, which owns the divider geometry: `bend_towards` +answers with the divider steps that land the note nearest the frequency read, bounded by +`bend_range` — **half the gap to each neighboring note**. That bound is what leaves the refined +pitches gapless: note *n* covers `[(tₙ + tₙ₊₁) / 2, (tₙ + tₙ₋₁) / 2]`, and those windows tile the +divider range exactly, so every divider the notes span is reachable and none is claimed twice. + +A bend that followed every reading exactly would jitter, and jitter is more audible than the tuning +it chases. So the per-frame proposals are settled by a change-penalised walk, the same shape the +Viterbi decoder settles a note contour with: the cost of a bend is how far it stands from that +frame's reading, plus a toll on changing at all. The states a frame may take are the bends its +neighborhood proposed together with no bend, which keeps the walk to a handful of states even where +a note owns tens of dividers. + +### 6.3 What it costs, and what it leaves alone + +The refinement enumerates no candidate, rescores nothing, and leaves the library, the per-frame +matching and the decoder's lattice exactly as they were. What it adds is one transform per +recording and a small walk per channel: a conversion measures **around 2 % longer** with it than +without. + +A frame makes no proposal where it rests, where its channel is not pitched — the noise channel's +sixteen periods have no finer grid — or where its reading falls below the confidence threshold. A +conversion that bent no note records both bend dimensions as ones the channel governs, so it writes +the same instrument it wrote before the feature existed. + +| parameter | default | notes | +|---|---|---| +| `generation.refinement.enabled` | on | acts only where the run renders the chosen instructions | +| `generation.refinement.confidence` | 0.15 | the share of a frame's energy its harmonics must hold | +| `generation.refinement.change_weight` | 2.0 | divider steps of reading error worth avoiding one change | +| `generation.refinement.window` | 4 | the frames on either side whose readings a frame may settle on | + +## 7. Rendering and reassembly Once instructions are chosen, each one is rendered back through its generator (`sampletones_core.generators`), which carries oscillator phase across frames so @@ -274,7 +347,7 @@ per-channel instruction streams (which can be exported to a tracker format via `sampletones_core.exporters`). The coefficient from §3.4 is stored so the reconstruction and the original can be shown and played on a common scale. -## 7. Limitations +## 8. Limitations - **Dynamic range.** A single NES tonal channel spans roughly 25 dB from its quietest to its loudest note, and the coefficient is one global scalar. Material @@ -287,6 +360,10 @@ reconstruction and the original can be shown and played on a common scale. - **Per-channel independence in Viterbi.** Channels are decoded independently once the assignment has settled their columns, which is fast but not jointly optimal across channels. +- **Refinement needs a fundamental to read.** A frame carrying several pitches at once, or one + whose sound is unpitched, states no fundamental for its channel and keeps the note the matching + chose. The room a bend has also closes with pitch: a divider step is a whole semitone from around + C-7 up, so notes there sound where the grid puts them. ## Appendix — key parameters and where things live @@ -301,6 +378,7 @@ noise): | spectral / temporal weight | 0.8 / 0.2 | criterion blend | | spectral distance | β-divergence | also `squared`, `absolute` | | selector | Viterbi | `greedy` / `viterbi` | +| pitch refinement | on | bends each note onto the divider the source sounds | | normalize / quantize | on / off | input preprocessing | Package map: @@ -315,5 +393,6 @@ Package map: | selection + assembly | `sampletones_core.reconstructions.reconstructor` | | audio I/O and level | `sampletones_core.audio` | | tracker export | `sampletones_core.exporters` | +| pitch refinement | `sampletones_core.reconstructions.reconstructor.refinement` | | criterion calibration | `sampletones_core.calibration` | | analytic waveform synthesis | `sampletones_synthesis` | diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 2ab503e8d..498868b40 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -46,6 +46,18 @@ starts carrying. * In-application guide/tutorial * Language selector +* Verifying a bend against the criterion. The plan for the refinement carried a guard: render the + bent candidate, score it, and keep the bend only where the cost improves. It was measured and + left out. The criterion agreed with the reading on **every** bent frame of both a matched and a + mismatched target, so the guard rejects nothing; and one extra render-and-score per bent frame + measures around **2.1 s per second of audio**, against a whole conversion's ~1.2 s, so it would + nearly triple a run to change no decision. It is worth revisiting only against material where the + reading is shown to misfire. +* Calibrating the pitch refinement. `generation.refinement`'s confidence threshold, change weight + and window are chosen by hand; `docs/concepts/calibration.md`'s experiment measures the criterion + blend and could measure these beside it. The change weight is the one with an audible trade-off: + it decides how large a one-frame excursion the walk follows rather than absorbs, which is + vibrato against jitter. ### Technical diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md index 6d16077a3..6919ebf8b 100644 --- a/docs/formats/nsf.md +++ b/docs/formats/nsf.md @@ -162,6 +162,14 @@ written.** Storing it restarts a pulse waveform and reloads the triangle's count channel holding one pitch across a rest keeps its phase running the way a rendered channel does. +**A value plane names a pitch, not a divider.** Stating a note as its distance above the lowest +one the song reaches is what lets `TRANSPOSED_PHRASE` move a whole phrase by adding to it, and a +divider offset added to an index means nothing. So a frame carrying a **bend** — the pitch and +hi-pitch dimensions an instrument writes — reaches the driver at its note's own divider: +`registers/playable.py::playable` states that once, and every encoder below reads frames that +carry no bend. Carrying one would mean a third plane per channel; `docs/development/bugs-and-todos.md` +under **Tracker** owns that work. + ## D. Limits | Limit | Value | diff --git a/src/sampletones_core/configs/generation.py b/src/sampletones_core/configs/generation.py index 2aaa8c74c..1c9a7ba88 100644 --- a/src/sampletones_core/configs/generation.py +++ b/src/sampletones_core/configs/generation.py @@ -12,6 +12,10 @@ MAX_DRIVE, PERCEPTUAL_EXPONENT, PHASE_ALIGNER, + REFINE_PITCH, + REFINEMENT_CHANGE_WEIGHT, + REFINEMENT_CONFIDENCE, + REFINEMENT_WINDOW, RESET_PHASE, SELECTOR, SPECTRAL_DISTANCE, @@ -68,6 +72,29 @@ class DecoderConfig(DataModel): on_off_weight: float = Field(default=TRANSITION_ON_OFF_WEIGHT, ge=0.0) +class RefinementConfig(DataModel): + """How far off the equal-tempered grid a conversion is allowed to place its notes. + + A note reaches the hardware as a divider, and the divider grid is finer than the note grid + everywhere below the top of the range. The refinement reads where each frame's fundamental + actually stands and bends the note it landed on towards it, so material recorded off the grid + comes back in tune with itself. + + Attributes: + enabled: Whether a conversion bends the notes it chose. + confidence: The share of a frame's energy its harmonics must hold for its reading to count. + change_weight: The divider steps of reading error worth avoiding one change of bend. + window: The frames on either side whose readings a frame may settle on. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + enabled: bool = Field(default=REFINE_PITCH) + confidence: float = Field(default=REFINEMENT_CONFIDENCE, ge=0.0, le=1.0) + change_weight: float = Field(default=REFINEMENT_CHANGE_WEIGHT, ge=0.0) + window: int = Field(default=REFINEMENT_WINDOW, ge=0) + + class GenerationConfig(DataModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -95,3 +122,4 @@ class GenerationConfig(DataModel): weights: WeightsConfig = Field(default_factory=WeightsConfig) metric: MetricConfig = Field(default_factory=MetricConfig) decoder: DecoderConfig = Field(default_factory=DecoderConfig) + refinement: RefinementConfig = Field(default_factory=RefinementConfig) diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index ab0e5b502..405401fd1 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -65,6 +65,13 @@ TRANSITION_TIMBRE_WEIGHT: Final[float] = 0.10 TRANSITION_ON_OFF_WEIGHT: Final[float] = 0.20 +# Pitch refinement + +REFINE_PITCH: Final[bool] = True +REFINEMENT_CONFIDENCE: Final[float] = 0.15 +REFINEMENT_CHANGE_WEIGHT: Final[float] = 2.0 +REFINEMENT_WINDOW: Final[int] = 4 + # Mixer drive DRIVE: Final[float] = 1.0 diff --git a/src/sampletones_core/fft/instantaneous.py b/src/sampletones_core/fft/instantaneous.py new file mode 100644 index 000000000..f8ce8c703 --- /dev/null +++ b/src/sampletones_core/fft/instantaneous.py @@ -0,0 +1,156 @@ +from dataclasses import dataclass +from typing import Final, Optional + +import numpy as np + +from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY + +from .cqt.frequencies import calculate_cqt_frequencies +from .cqt.transform import calculate_cqt_frames + +HARMONIC_COUNT: Final[int] = 5 +MINIMUM_COLUMN_ENERGY: Final[float] = 1e-20 + + +@dataclass(frozen=True) +class FundamentalReading: + """What a frame's partials place its fundamental at, and how much of the frame stands behind it. + + Attributes: + frequency: The fundamental in Hz the frame's harmonics agree on. + confidence: The share of the frame's energy those harmonics hold, in ``[0, 1]``. + """ + + frequency: float + confidence: float + + +class InstantaneousPitch: + """Where a recording's partials actually sit, read frame by frame from the transform's phase. + + A constant-Q column carries a phase as well as a magnitude, and a partial standing between two + bin centers still advances that phase at its own rate. Comparing the phase two columns apart + against the rate the bin itself would turn at therefore states the partial's frequency far more + finely than the bins are spaced — finely enough to place a note within a fraction of a cent, + where the bins alone place it within a semitone. + + Reading around a stated reference is what makes the answer usable: the reference names which + bins carry the note's harmonics, and each harmonic's estimate is divided back down and weighted + by the energy standing behind it. The harmonics are read in order, each one settled against the + fundamental the ones below it agreed on, which is what keeps an upper harmonic on the right side + of the whole turn its phase states the reading to within. + """ + + def __init__( + self, + audio: np.ndarray, + sample_rate: int, + hop_length: int, + *, + cutoff: float = CQT_CUTOFF_FREQUENCY, + bins_per_octave: int = BINS_PER_OCTAVE, + ) -> None: + coefficients = calculate_cqt_frames(audio, sample_rate, hop_length, cutoff, None, bins_per_octave) + self._magnitudes: np.ndarray = np.abs(coefficients) + self._phases: np.ndarray = np.angle(coefficients) + self._frequencies: np.ndarray = calculate_cqt_frequencies( + coefficients.shape[0], + cutoff, + bins_per_octave, + ) + self._energies: np.ndarray = np.sum(self._magnitudes**2, axis=0) + self._turn_per_column: np.ndarray = 2.0 * np.pi * self._frequencies * hop_length / sample_rate + self._resolution: float = sample_rate / (2.0 * np.pi * hop_length) + self._ambiguity: float = sample_rate / hop_length + + @property + def columns(self) -> int: + """How many frames the transform read.""" + return int(self._magnitudes.shape[1]) + + def at(self, frame: int, reference: float) -> Optional[FundamentalReading]: + """Where the fundamental sits in one frame, read around a reference it is known to be near. + + Args: + frame: The frame to read. + reference: The frequency in Hz the note is expected at. + + Returns: + Optional[FundamentalReading]: The reading, or ``None`` where the frame lies outside + the transform or the reference names no bin the transform covers. + """ + opening, closing = self._pair(frame) + if opening is None or closing is None: + return None + + weighted = 0.0 + weight = 0.0 + running = reference + for harmonic in range(1, HARMONIC_COUNT + 1): + bin_index = self._bin_for(reference * harmonic) + if bin_index is None: + continue + + partial = self._partial_frequency(bin_index, opening, closing, running * harmonic) + energy = float(self._magnitudes[bin_index, opening]) ** 2 + weighted += energy * partial / harmonic + weight += energy + running = weighted / weight + + if weight <= 0.0: + return None + + return FundamentalReading( + frequency=weighted / weight, + confidence=weight / max(float(self._energies[opening]), MINIMUM_COLUMN_ENERGY), + ) + + def _pair(self, frame: int) -> tuple[Optional[int], Optional[int]]: + """The two columns a phase advance is read across, stepping back at the final frame.""" + if frame < 0 or frame >= self.columns: + return None, None + + if frame + 1 < self.columns: + return frame, frame + 1 + + if frame > 0: + return frame - 1, frame + + return None, None + + def _bin_for(self, frequency: float) -> Optional[int]: + """The bin whose center stands nearest a frequency, where the transform reaches it.""" + if frequency < self._frequencies[0] or frequency > self._frequencies[-1]: + return None + + return int(np.argmin(np.abs(self._frequencies - frequency))) + + def _partial_frequency( + self, + bin_index: int, + opening: int, + closing: int, + expected: float, + ) -> float: + """What the partial in one bin sounds at, from how far its phase turned between two columns. + + A phase states its turn to within a whole turn, so the reading repeats every + ``sample_rate / hop`` hertz and the branch to take is the one standing nearest where the + partial is expected. That spacing is far wider than any error the reading itself carries, + so choosing by the expectation settles the branch without moving the answer inside it — + which is what keeps the upper harmonics of a note usable, since a whole turn there spans + less than the semitone their bin covers. + + Args: + bin_index: The bin the partial stands in. + opening: The column the turn is measured from. + closing: The column the turn is measured to. + expected: The frequency in Hz the partial is expected near. + + Returns: + float: The partial's frequency in Hz. + """ + turned = self._phases[bin_index, closing] - self._phases[bin_index, opening] + deviation = np.angle(np.exp(1j * (turned - self._turn_per_column[bin_index]))) + partial = float(self._frequencies[bin_index] + deviation * self._resolution) + return partial + self._ambiguity * round((expected - partial) / self._ambiguity) diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py index e37a76346..92183c2f6 100644 --- a/src/sampletones_core/generators/__init__.py +++ b/src/sampletones_core/generators/__init__.py @@ -19,6 +19,7 @@ GeneratorT, GeneratorTypeUnion, GeneratorUnion, + TonalGeneratorUnion, ) from .utils import ( get_generator_by_instruction, @@ -44,6 +45,7 @@ "NoiseGenerator", "PulseGenerator", "TonalGenerator", + "TonalGeneratorUnion", "TriangleGenerator", "get_generator_by_instruction", "get_generators_by_channels", diff --git a/src/sampletones_core/generators/tonal.py b/src/sampletones_core/generators/tonal.py index 86dc5b067..a44096580 100644 --- a/src/sampletones_core/generators/tonal.py +++ b/src/sampletones_core/generators/tonal.py @@ -1,10 +1,10 @@ from abc import ABC -from typing import Dict, TypeVar +from typing import Dict, Tuple, TypeVar from sampletones_core.configs import Config from sampletones_core.constants.general import MAX_TIMER, MIN_TIMER from sampletones_core.instructions import TonalInstruction -from sampletones_core.timers import PhaseTimer, frequency_to_timer +from sampletones_core.timers import PhaseTimer, frequency_to_timer, timer_to_frequency from sampletones_shared.utils.arrays import clamp from .generator import Generator @@ -47,3 +47,62 @@ def get_timer(self, pitch: int, offset: int) -> int: KeyError: If the pitch is absent from the generator's tables. """ return int(clamp(self.timer_table[pitch] + offset, MIN_TIMER, MAX_TIMER)) + + def sounds_at(self, pitch: int, offset: int) -> float: + """The frequency in Hz this channel sounds a note at once a bend has moved it. + + A channel's waveform completes once per divider period times whatever the timer's own + stride is, so this is where a divider becomes the pitch a listener hears — the triangle + striding half as fast as the pulse and sounding an octave below the same divider. + + Args: + pitch: The note the frame names. + offset: The divider steps the frame is bent by. + + Returns: + float: The frequency the channel sounds. + """ + return timer_to_frequency(self.get_timer(pitch, offset)) * self.timer.phase_increment + + def bend_towards(self, pitch: int, frequency: float) -> int: + """The bend that lands this note nearest a frequency, held inside the note's own room. + + A note owns half the dividers between itself and each neighbor, which is what tiles the + whole divider range across the notes with none of it out of reach and none of it claimed + twice. A frequency past that room takes the nearest bend the note offers, and the note + beside it is the one that reaches further. + + Args: + pitch: The note the frame names. + frequency: The frequency in Hz the frame should sound at. + + Returns: + int: The divider steps to bend by. + """ + if frequency <= 0.0: + return 0 + + lowest, highest = self.bend_range(pitch) + wanted = frequency_to_timer(frequency / self.timer.phase_increment) - self.timer_table[pitch] + return int(clamp(wanted, lowest, highest)) + + def bend_range(self, pitch: int) -> Tuple[int, int]: + """The divider steps a bend may move a note, half the gap to each neighboring note. + + Args: + pitch: The note the frame names. + + Returns: + Tuple[int, int]: The lowest and highest bend the note offers. + """ + divider = self.timer_table[pitch] + below = self.timer_table.get(pitch - 1, divider + self._gap(pitch, pitch + 1)) + above = self.timer_table.get(pitch + 1, divider - self._gap(pitch - 1, pitch)) + return -((divider - above) // 2), (below - divider) // 2 + + def _gap(self, lower: int, higher: int) -> int: + """The dividers between two neighboring notes, where the table reaches both.""" + if lower not in self.timer_table or higher not in self.timer_table: + return 0 + + return self.timer_table[lower] - self.timer_table[higher] diff --git a/src/sampletones_core/generators/types.py b/src/sampletones_core/generators/types.py index 671c5034a..6a294bced 100644 --- a/src/sampletones_core/generators/types.py +++ b/src/sampletones_core/generators/types.py @@ -9,5 +9,6 @@ GeneratorT = TypeVar("GeneratorT", PulseGenerator, TriangleGenerator, NoiseGenerator) GeneratorClass = Type[GeneratorT] GeneratorUnion = Union[PulseGenerator, TriangleGenerator, NoiseGenerator] +TonalGeneratorUnion = Union[PulseGenerator, TriangleGenerator] GeneratorTypeUnion = Union[Type[PulseGenerator], Type[TriangleGenerator], Type[NoiseGenerator]] GeneratorClassNames = Union[GeneratorClassName, Tuple[GeneratorClassName, ...]] diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index c20c2d7ad..444d2c142 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Sequence, Tuple @@ -28,6 +29,7 @@ from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstructor.decoder.base import Streams +from sampletones_core.reconstructions.reconstructor.refinement import PitchRefiner from sampletones_core.reconstructions.reconstructor.state import ReconstructionState from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame from sampletones_core.reconstructions.reconstructor.stems.assignment.track import TrackAssignment @@ -40,6 +42,24 @@ from sampletones_shared.utils.system.paths import to_path +@dataclass(frozen=True) +class PreparedStems: + """Each stem's recording at the level the matching is made on, and the frames it was cut into. + + The matching reads the frames and the refinement reads the recording they came from, so both + travel together from the one place that scales them. + + Attributes: + recordings: Each stem's scaled recording, keyed by stem id. + frames: Each stem's frames, keyed by stem id. + coefficient: The factor the whole set was scaled by. + """ + + recordings: Dict[int, np.ndarray] + frames: Dict[int, FragmentedAudio] + coefficient: float + + class Reconstructor: """ Turns an audio file into a :class:`Reconstruction` of NES instructions. @@ -133,21 +153,27 @@ def reconstruct( announce(report, ReconstructionStage.LOADING, STAGE_BEGUN, PREPARATIONS) recordings = self._load_stem_recordings(checked_paths) announce(report, ReconstructionStage.LOADING, RECORDINGS_LOADED, PREPARATIONS) - stem_frames, coefficient = self._prepare_stem_frames(recordings, stems_config) + prepared = self._prepare_stem_frames(recordings, stems_config) announce(report, ReconstructionStage.LOADING, FRAMES_PREPARED, PREPARATIONS) worker = self._build_worker(common_length(recordings)) - assignment = self._assign_stem_frames(stem_frames, stems_config, worker, report) + assignment = self._assign_stem_frames(prepared.frames, stems_config, worker, report) self._drop_resting_channels(assignment) announce(report, ReconstructionStage.DECODING, STAGE_BEGUN, WHOLE_STAGE) - self._record_streams(worker.decoder.decode(assignment.lattices), report) + streams = worker.decoder.decode(assignment.lattices) + streams = self._refiner().refine(streams, assignment.stem_ids, prepared.recordings) + self._record_streams(streams, report) return Reconstruction.from_state( self.state, self.config, - coefficient, + prepared.coefficient, tuple(checked_paths), stems_data=self._build_stems_data(stems_config, assignment.stem_ids), ) + def _refiner(self) -> PitchRefiner: + """The pass that carries each chosen note towards the fundamental the recording sounds.""" + return PitchRefiner(config=self.config, channels=self.channels) + @staticmethod def _check_stem_paths( paths: Sequence[Pathlike], @@ -189,7 +215,7 @@ def _prepare_stem_frames( self, recordings: Sequence[np.ndarray], stems_config: StemsConfig, - ) -> Tuple[Dict[int, FragmentedAudio], float]: + ) -> PreparedStems: """Scales the recordings to the working level and frames each of them. The level is measured on their mix, so one factor scales the whole set and a @@ -197,18 +223,24 @@ def _prepare_stem_frames( Framing every recording on its own is what lets a stem's picks be scored against the sound that stem contributes. - Returns the framed recordings keyed by stem id, together with the coefficient they - were scaled by, so the assembled reconstruction records the level it was matched at. + Args: + recordings: The loaded stem recordings, in entry order. + stems_config: The stems setup the run is made under. + + Returns: + PreparedStems: The scaled recordings and their frames, keyed by stem id, together + with the coefficient they were scaled by. """ coefficient = self.get_coefficient(mix(list(recordings)), stems_config) self.reset_generators() covered = stems_config.covered_channels self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered]) - stem_frames = { - entry.id: self.get_fragments(recording / coefficient) - for entry, recording in zip(stems_config.entries, recordings) - } - return stem_frames, coefficient + scaled = {entry.id: recording / coefficient for entry, recording in zip(stems_config.entries, recordings)} + return PreparedStems( + recordings=scaled, + frames={stem_id: self.get_fragments(recording) for stem_id, recording in scaled.items()}, + coefficient=coefficient, + ) def _build_worker(self, signal_length: int) -> ReconstructorWorker: """Builds the matching machinery and the decoder this recording runs through.""" diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py b/src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py new file mode 100644 index 000000000..9ac177acd --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py @@ -0,0 +1,9 @@ +from .refiner import PitchRefiner, StemIds, StemRecordings +from .smoothing import smoothed + +__all__ = [ + "PitchRefiner", + "StemIds", + "StemRecordings", + "smoothed", +] diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py new file mode 100644 index 000000000..49e2f2120 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py @@ -0,0 +1,164 @@ +from dataclasses import dataclass, replace +from typing import Dict, List, Optional + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft.instantaneous import InstantaneousPitch +from sampletones_core.generators import ( + GeneratorUnion, + PulseGenerator, + TonalGeneratorUnion, + TriangleGenerator, +) +from sampletones_core.instructions import PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions.reconstructor.decoder.base import Streams +from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate + +from .smoothing import smoothed + +StemRecordings = Dict[int, np.ndarray] +StemIds = Dict[ChannelName, List[int]] + + +@dataclass(frozen=True) +class PitchRefiner: + """Bends each chosen note towards where the recording's own fundamental stands. + + The matching stage places every frame on the nearest note of the equal-tempered grid, which is + as fine as its candidate catalog goes. The hardware is finer than that everywhere below the top + of its range — a divider step spans well under a cent at the lowest notes — so a note the + matching chose can be carried closer to the sound it stands for. + + Where it lands is read rather than searched. The transform already carries a phase beside every + magnitude, and how far that phase turns between two frames states a partial's frequency far + more finely than the bins are spaced; the note the decoder chose says which bins to read. So a + frame's bend costs a phase difference over a handful of harmonics, and the candidate catalog, + the per-frame scoring and the decoder's lattice all stay exactly as they were. + + A frame whose sound is not pitched enough for that reading to mean anything makes no proposal, + and the run of bends is then settled against a toll on changing, so a stream holds a tuning + rather than chasing one. + + Attributes: + config: The settings the refinement and the render are run under. + channels: The generator each channel sounds through, which owns the divider geometry. + """ + + config: Config + channels: Dict[ChannelName, GeneratorUnion] + + @property + def active(self) -> bool: + """Whether this run bends its notes. + + A run keeping the audio each frame was matched on would write a bend it never sounds, so + the refinement acts where the chosen instructions are rendered afresh. + """ + return self.config.generation.refinement.enabled and self.config.generation.final_regeneration + + def refine( + self, + streams: Streams, + stem_ids: StemIds, + recordings: StemRecordings, + ) -> Streams: + """The decoded streams with every frame's note bent towards what the recording sounds. + + Args: + streams: What each channel plays, one candidate per frame. + stem_ids: The stem each channel took, frame by frame. + recordings: Each stem's recording, at the level the matching was made on. + + Returns: + Streams: The streams, each frame carrying the bend its reading settled on. + """ + if not self.active: + return streams + + readers = {stem_id: self._reader(recording) for stem_id, recording in recordings.items()} + return { + channel_name: self._refined(channel_name, stream, stem_ids.get(channel_name, []), readers) + for channel_name, stream in streams.items() + } + + def _reader(self, recording: np.ndarray) -> InstantaneousPitch: + """The instantaneous-pitch reading of one stem, taken once for every channel that took it.""" + return InstantaneousPitch( + recording, + self.config.library.sample_rate, + self.config.library.frame_length, + ) + + def _refined( + self, + channel_name: ChannelName, + stream: List[ScoredCandidate], + stem_ids: List[int], + readers: Dict[int, InstantaneousPitch], + ) -> List[ScoredCandidate]: + """One channel's stream, each frame bent to the reading its neighborhood settled on.""" + generator = self.channels[channel_name] + if not isinstance(generator, (PulseGenerator, TriangleGenerator)): + return stream + + settings = self.config.generation.refinement + proposals = [ + self._proposal(generator, candidate, frame, stem_ids, readers) for frame, candidate in enumerate(stream) + ] + bends = smoothed( + proposals, + window=settings.window, + change_weight=settings.change_weight, + ) + return [self._bent(candidate, bend) for candidate, bend in zip(stream, bends)] + + def _proposal( + self, + generator: TonalGeneratorUnion, + candidate: ScoredCandidate, + frame: int, + stem_ids: List[int], + readers: Dict[int, InstantaneousPitch], + ) -> Optional[int]: + """The bend one frame's reading asks for, and nothing where it has no reading to give.""" + instruction = candidate.instruction + if not isinstance(instruction, (PulseInstruction, TriangleInstruction)) or not instruction.on: + return None + + reader = self._reader_at(frame, stem_ids, readers) + if reader is None: + return None + + reading = reader.at(frame, generator.sounds_at(instruction.pitch, 0)) + if reading is None or reading.confidence < self.config.generation.refinement.confidence: + return None + + return generator.bend_towards(instruction.pitch, reading.frequency) + + @staticmethod + def _reader_at( + frame: int, + stem_ids: List[int], + readers: Dict[int, InstantaneousPitch], + ) -> Optional[InstantaneousPitch]: + """The reading of the recording this channel took at one frame, where it took one.""" + if frame >= len(stem_ids): + return None + + stem_id = stem_ids[frame] + if stem_id == RESTING_STEM_ID: + return None + + return readers.get(stem_id) + + @staticmethod + def _bent(candidate: ScoredCandidate, bend: int) -> ScoredCandidate: + """One frame carrying a bend, leaving a frame that stands at its note as it was.""" + instruction = candidate.instruction + if not isinstance(instruction, (PulseInstruction, TriangleInstruction)) or bend == instruction.detune: + return candidate + + return replace(candidate, instruction=instruction.model_copy(update={"detune": bend})) diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py b/src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py new file mode 100644 index 000000000..d447b891d --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py @@ -0,0 +1,87 @@ +from typing import Dict, List, Optional, Sequence, Set + +NO_BEND: int = 0 + + +def smoothed( + proposals: Sequence[Optional[int]], + *, + window: int, + change_weight: float, +) -> List[int]: + """One bend per frame, following the readings while paying for every change it makes. + + A reading is made frame by frame and a frame's own reading is the best statement of where its + note sits, but a bend that follows every reading exactly jitters, and jitter is more audible + than the tuning error it chases. So the walk weighs the distance from each frame's reading + against a toll on changing at all, and settles on the run of bends that costs least over the + whole stream — the same shape the decoder settles a note contour with. + + The states a frame may take are the readings its neighborhood proposed, together with no bend + at all. Drawing them from the readings is what keeps the walk small: the divider range a note + owns spans tens of steps at the bottom of the range, while the readings around any one frame + are a handful. + + Args: + proposals: The bend each frame's reading asks for, ``None`` where it made none. + window: The frames on either side whose readings a frame may settle on. + change_weight: The divider steps of reading error worth avoiding one change. + + Returns: + List[int]: The bend each frame writes. + """ + if not proposals: + return [] + + states = [_states(proposals, frame, window) for frame in range(len(proposals))] + costs: Dict[int, float] = {bend: _reading_cost(bend, proposals[0]) for bend in states[0]} + origins: List[Dict[int, int]] = [] + + for frame in range(1, len(proposals)): + step: Dict[int, float] = {} + origin: Dict[int, int] = {} + for bend in states[frame]: + previous = _cheapest_predecessor(costs, bend, change_weight) + step[bend] = ( + costs[previous] + (0.0 if previous == bend else change_weight) + _reading_cost(bend, proposals[frame]) + ) + origin[bend] = previous + + costs = step + origins.append(origin) + + return _walked_back(costs, origins) + + +def _cheapest_predecessor(costs: Dict[int, float], bend: int, change_weight: float) -> int: + """The bend a frame is cheapest to arrive at ``bend`` from, holding costing nothing.""" + reached = {held: cost + (0.0 if held == bend else change_weight) for held, cost in costs.items()} + return min(reached, key=lambda held: reached[held]) + + +def _states(proposals: Sequence[Optional[int]], frame: int, window: int) -> List[int]: + """The bends a frame may settle on: what its neighborhood read, and no bend at all.""" + opening = max(0, frame - window) + closing = min(len(proposals), frame + window + 1) + nearby: Set[int] = {proposal for proposal in proposals[opening:closing] if proposal is not None} + nearby.add(NO_BEND) + return sorted(nearby) + + +def _reading_cost(bend: int, proposal: Optional[int]) -> float: + """How far a bend stands from what the frame read, and nothing where it read nothing.""" + if proposal is None: + return 0.0 + + return float(abs(bend - proposal)) + + +def _walked_back(costs: Dict[int, float], origins: List[Dict[int, int]]) -> List[int]: + """The least-cost run of bends, read back from the frame it ended on.""" + bend = min(costs, key=lambda held: costs[held]) + walked = [bend] + for origin in reversed(origins): + bend = origin[bend] + walked.append(bend) + + return list(reversed(walked)) diff --git a/src/sampletones_player/registers/channel.py b/src/sampletones_player/registers/channel.py index bf62431ff..cc9bb2586 100644 --- a/src/sampletones_player/registers/channel.py +++ b/src/sampletones_player/registers/channel.py @@ -10,6 +10,7 @@ ) from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.playable import playable from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.triangle import TriangleRegisters @@ -22,7 +23,8 @@ def channel_instructions( A reconstruction holds a stream for every channel, and a channel standing by holds one describing no frame. Such a channel reaches the player resting for a single tick, which is - the shortest stream a song lays its records out from. + the shortest stream a song lays its records out from. Each frame arrives as the driver can + sound it — see :func:`playable`. Args: instructions: The channel's stream, as the reconstruction holds it. @@ -42,7 +44,9 @@ def channel_instructions( f"{instruction.name}, which another channel sounds" ) - typed.append(instruction) + sounded = playable(instruction) + assert isinstance(sounded, instruction_type) + typed.append(sounded) if typed: return typed diff --git a/src/sampletones_player/registers/playable.py b/src/sampletones_player/registers/playable.py new file mode 100644 index 000000000..3a1632f9f --- /dev/null +++ b/src/sampletones_player/registers/playable.py @@ -0,0 +1,36 @@ +from sampletones_core.instructions import ( + InstructionUnion, + PulseInstruction, + TriangleInstruction, +) + +NO_BEND: int = 0 + + +def playable(instruction: InstructionUnion) -> InstructionUnion: + """One frame as the driver sounds it, which is at the note it names. + + A channel's plane states a pitch as its distance above the lowest note the song reaches, and + that is what lets a phrase be transposed by adding to it — a divider offset added to an index + means nothing there. A frame carrying a bend therefore sounds at its note's own divider until + the planes gain a place to put one; ``docs/development/bugs-and-todos.md`` under **Tracker** + owns that work. + + Stating it here is what makes the loss deliberate and single-placed: the encoders below read + frames that carry no bend, and whoever holds the console's output against a reconstruction + reads the same answer. + + Args: + instruction: The frame as the reconstruction holds it. + + Returns: + InstructionUnion: The frame the driver can sound. + """ + match instruction: + case PulseInstruction() | TriangleInstruction(): + if not instruction.bent: + return instruction + + return instruction.model_copy(update={"detune": NO_BEND, "coarse_detune": NO_BEND}) + case _: + return instruction diff --git a/tests/benchmarks/test_pitch_bend.py b/tests/benchmarks/test_pitch_bend.py index e53e73a15..8c1385b7d 100644 --- a/tests/benchmarks/test_pitch_bend.py +++ b/tests/benchmarks/test_pitch_bend.py @@ -1,18 +1,30 @@ +from pathlib import Path from time import process_time from typing import Final, List +import numpy as np import pytest +from sampletones_core.audio import write_wave from sampletones_core.configs import Config +from sampletones_core.configs.generation import GenerationConfig, RefinementConfig from sampletones_core.constants.enums import ChannelName from sampletones_core.generators.render import render_instructions from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstructor +from tests.integration.assets.reconstruction import build_mini_library FRAMES: Final[int] = 6000 PITCH: Final[int] = 60 VOLUME: Final[int] = 12 REPEATS: Final[int] = 3 BEND_OVERHEAD_LIMIT: Final[float] = 1.25 +REFINEMENT_OVERHEAD_LIMIT: Final[float] = 1.20 +CONVERSION_SECONDS: Final[float] = 2.0 +LOWER_TONE: Final[float] = 261.0 +UPPER_TONE: Final[float] = 393.0 +NOISE_LEVEL: Final[float] = 0.05 +NOISE_SEED: Final[int] = 23 def _stream(bent: bool) -> List[PulseInstruction]: @@ -58,3 +70,50 @@ def test_a_bent_stream_renders_in_what_an_unbent_one_takes(self, config: Config) bent = _render_seconds(config, _stream(bent=True)) assert bent < unbent * BEND_OVERHEAD_LIMIT, f"unbent {unbent:.4f}s, bent {bent:.4f}s" + + +def _conversion_config(*, refining: bool) -> Config: + return Config(generation=GenerationConfig(refinement=RefinementConfig(enabled=refining))) + + +def _target(path: Path, config: Config) -> Path: + """A two-tone target under light noise, which is the shape a conversion works hardest on.""" + sample_rate = config.library.sample_rate + count = int(sample_rate * CONVERSION_SECONDS) + time = np.arange(count) / sample_rate + audio = 0.5 * np.sin(2 * np.pi * LOWER_TONE * time) + 0.3 * np.sin(2 * np.pi * UPPER_TONE * time) + audio += np.random.default_rng(NOISE_SEED).normal(0.0, NOISE_LEVEL, count) + + write_wave(path, sample_rate, audio) + return path + + +def _conversion_seconds(config: Config, audio_path: Path) -> float: + """The best of several conversions of the same target, library build excluded.""" + library = build_mini_library(config) + readings: List[float] = [] + for _ in range(REPEATS): + started = process_time() + Reconstructor(config, library=library)(audio_path) + readings.append(process_time() - started) + + return min(readings) + + +class TestRefiningCostsLittleOnTopOfAConversion: + """The refinement reads a phase the transform already carries, over a handful of harmonics. + + It enumerates no candidate and rescores nothing, so what it adds to a conversion is one more + transform per recording and a small walk per channel. The reading is a ratio against the same + conversion with the refinement off, since what a machine converts a second of audio in is its + own; what the bound catches is a refinement that started doing the matching's kind of work. + """ + + def test_a_refined_conversion_costs_about_what_a_plain_one_costs(self, tmp_path: Path) -> None: + plain = _conversion_config(refining=False) + audio_path = _target(tmp_path / "target.wav", plain) + + unrefined = _conversion_seconds(plain, audio_path) + refined = _conversion_seconds(_conversion_config(refining=True), audio_path) + + assert refined < unrefined * REFINEMENT_OVERHEAD_LIMIT, f"unrefined {unrefined:.3f}s, refined {refined:.3f}s" diff --git a/tests/integration/nsf/console/instructions.py b/tests/integration/nsf/console/instructions.py index 2a2654660..c82f8bbf1 100644 --- a/tests/integration/nsf/console/instructions.py +++ b/tests/integration/nsf/console/instructions.py @@ -1,13 +1,19 @@ from typing import Dict, Final, List, Mapping, Tuple +import numpy as np + +from sampletones_core.audio.mixing import mix from sampletones_core.constants.enums import ChannelName from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME +from sampletones_core.generators.render import render_channels from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction, ) +from sampletones_core.reconstructions import Reconstruction +from sampletones_player.registers.playable import playable from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES from sampletones_player.specification.registers import ( DUTY_CYCLE_SHIFT, @@ -153,3 +159,24 @@ def instructions_from_trace( streams[channel].append(instruction) return streams + + +def sounded_approximation(reconstruction: Reconstruction) -> np.ndarray: + """A reconstruction's own waveform, rendered from the frames the console can sound. + + A bend has nowhere to travel in a channel's planes, so ``playable`` states each frame as the + driver loads it and the reconstruction is rendered from those. Holding the console's output + against this waveform is what makes the comparison one about the driver rather than about the + divider offset it has yet to gain. + + Args: + reconstruction: The reconstruction the console is playing. + + Returns: + np.ndarray: The waveform its playable frames sound as. + """ + streams = { + channel_name: [playable(instruction) for instruction in instructions] + for channel_name, instructions in reconstruction.instructions.items() + } + return mix(list(render_channels(streams, reconstruction.config).values())) diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 674491476..1a7aafb06 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -26,10 +26,11 @@ song_from_sample, ) from sampletones_player.export import NSFBackend +from sampletones_player.registers.playable import playable from sampletones_player.song import Song from sampletones_player.specification.nsf import NSF_MAGIC from sampletones_shared.paths.extensions import EXT_FILE_NSF -from tests.integration.nsf.console.instructions import instructions_from_trace +from tests.integration.nsf.console.instructions import instructions_from_trace, sounded_approximation from tests.integration.nsf.console.session import ( captured_file_trace, captured_run, @@ -147,7 +148,11 @@ def test_the_catalog_sounds_all_four_channels(self, played: Dict[str, ChannelIns class TestTheConsoleSoundsTheRequest: - """The envelopes an export request carries, read back off the APU the file drives.""" + """The envelopes an export request carries, read back off the APU the file drives. + + A frame reaches the console through ``playable``, which states what its planes can carry of + one, so both sides are read in the terms the driver actually sounds. + """ def test_every_slice_sounds_the_instructions_its_envelopes_describe( self, @@ -158,7 +163,7 @@ def test_every_slice_sounds_the_instructions_its_envelopes_describe( for channel, instructions in instructions_from_instruments(request.instruments).items(): sounded = played[name][channel][: len(instructions)] assert [resting(instruction) for instruction in sounded] == [ - resting(instruction) for instruction in instructions + resting(playable(instruction)) for instruction in instructions ] def test_a_channel_the_request_leaves_out_rests_throughout( @@ -181,7 +186,7 @@ def test_the_console_sounds_the_reconstructions_own_waveform( """ for name, sample in instrument_catalog.items(): rendered = mix(list(render_channels(played[name], sample.reconstruction.config).values())) - approximation = sample.reconstruction.approximation + approximation = sounded_approximation(sample.reconstruction) audible = min(len(rendered), len(approximation)) assert np.array_equal(rendered[:audible], approximation[:audible]) @@ -240,7 +245,7 @@ def test_the_console_sounds_the_arrangement_the_project_states( for channel, instructions in song_instructions(integration_project).items(): sounded = played[channel][: len(instructions)] assert [resting(instruction) for instruction in sounded] == [ - resting(instruction) for instruction in instructions + resting(playable(instruction)) for instruction in instructions ] def test_the_song_comes_round_rather_than_falling_silent( diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py index efb8bd062..02018b35c 100644 --- a/tests/integration/nsf/test_driver_audio.py +++ b/tests/integration/nsf/test_driver_audio.py @@ -10,7 +10,8 @@ from sampletones_core.project.voices.sample import Sample from sampletones_core.timers.utils import get_timer_table from sampletones_player.builder import song_from_reconstruction -from tests.integration.nsf.console.instructions import instructions_from_trace +from sampletones_player.registers.playable import playable +from tests.integration.nsf.console.instructions import instructions_from_trace, sounded_approximation from tests.integration.nsf.console.session import captured_trace from tests.integration.nsf.exports import exported_information @@ -56,7 +57,12 @@ def rendered( class TestTheConsoleSoundsTheReconstruction: - """What the driver puts on the APU, decoded back into the terms the reconstruction speaks.""" + """What the driver puts on the APU, decoded back into the terms the reconstruction speaks. + + A frame reaches the console through :func:`playable`, which states what the planes can carry + of it, so the console is held against the frames it can sound rather than against ones naming + a divider offset it has nowhere to put. + """ def test_every_played_channel_sounds_its_own_instructions( self, @@ -67,7 +73,7 @@ def test_every_played_channel_sounds_its_own_instructions( for channel, instructions in sample.reconstruction.instructions.items(): sounded = played[name][channel][: len(instructions)] assert [resting(instruction) for instruction in sounded] == [ - resting(instruction) for instruction in instructions + resting(playable(instruction)) for instruction in instructions ] def test_a_channel_the_reconstruction_leaves_out_rests_throughout( @@ -95,7 +101,11 @@ def test_every_run_ends_with_every_channel_silent(self, played: Dict[str, Channe class TestTheConsoleRendersTheReconstructionsAudio: - """The captured trace, sounded through the very generators the reconstruction was built on.""" + """The captured trace, sounded through the very generators the reconstruction was built on. + + The reconstruction is rendered from the frames the console can sound, so the two sides are + held against one waveform — see :func:`sounded_approximation`. + """ def test_the_console_reproduces_the_reconstructions_waveform( self, @@ -103,7 +113,7 @@ def test_the_console_reproduces_the_reconstructions_waveform( instrument_catalog: Dict[str, Sample], ) -> None: for name, sample in instrument_catalog.items(): - approximation = sample.reconstruction.approximation + approximation = sounded_approximation(sample.reconstruction) assert np.array_equal(rendered[name][: len(approximation)], approximation) def test_the_audio_past_the_reconstruction_is_silent( @@ -112,5 +122,5 @@ def test_the_audio_past_the_reconstruction_is_silent( instrument_catalog: Dict[str, Sample], ) -> None: for name, sample in instrument_catalog.items(): - approximation = sample.reconstruction.approximation + approximation = sounded_approximation(sample.reconstruction) assert not np.any(rendered[name][len(approximation) :]) diff --git a/tests/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py new file mode 100644 index 000000000..06198e7ae --- /dev/null +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -0,0 +1,201 @@ +import math +from pathlib import Path +from typing import Any, Dict, Final, List + +import numpy as np +import pytest + +from sampletones_core.audio import write_wave +from sampletones_core.configs import Config +from sampletones_core.configs.generation import GenerationConfig, RefinementConfig +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import PulseGenerator, get_generators_by_channels +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.library import ( + InstructionLibrary, + InstructionLibraryData, + InstructionLibraryFragment, +) +from sampletones_core.reconstructions import Reconstruction, Reconstructor + +PITCH: Final[int] = 60 +NEIGHBORHOOD: Final[range] = range(PITCH - 2, PITCH + 3) +DUTY_CYCLE: Final[int] = 2 +SECONDS: Final[float] = 1.0 +CENTS_PER_OCTAVE: Final[float] = 1200.0 +DETUNE_CENTS: Final[float] = 30.0 +CENTS_TOLERANCE: Final[float] = 8.0 +NOISE_SEED: Final[int] = 11 +EDGE_FRAMES: Final[int] = 1 +PULSE_ONLY: Final[List[ChannelName]] = [ChannelName.PULSE1] + + +def _config(*, refinement: RefinementConfig) -> Config: + return Config(generation=GenerationConfig(channels=PULSE_ONLY, refinement=refinement)) + + +def _library(config: Config) -> InstructionLibrary: + """A catalog of the notes around the target, at every volume the pulse channel offers. + + Nothing in it is bent: the catalog is the equal-tempered grid the matching chooses from, and + the bend is what the refinement adds on top of that choice. + """ + window = Window.from_config(config) + extractor = get_feature_extractor(config, window) + generator = get_generators_by_channels(config, PULSE_ONLY)[ChannelName.PULSE1] + + data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {} + for instruction in generator.get_possible_instructions(): + if instruction.on and instruction.pitch not in NEIGHBORHOOD: + continue + + if instruction.on and instruction.duty_cycle != DUTY_CYCLE: + continue + + data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor) + + library = InstructionLibrary() + library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) + return library + + +def _square_path(path: Path, config: Config, cents: float) -> Path: + """A steady square tone standing ``cents`` off the note the catalog holds.""" + sample_rate = config.library.sample_rate + generator = PulseGenerator(config, ChannelName.PULSE1) + frequency = generator.sounds_at(PITCH, 0) * 2 ** (cents / CENTS_PER_OCTAVE) + + count = int(sample_rate * SECONDS) + phase = (np.arange(count) * frequency / sample_rate) % 1.0 + audio = np.where(phase < 0.5, 0.4, -0.4) + + write_wave(path, sample_rate, audio) + return path + + +def _noise_path(path: Path, config: Config) -> Path: + sample_rate = config.library.sample_rate + count = int(sample_rate * SECONDS) + audio = np.random.default_rng(NOISE_SEED).normal(0.0, 0.3, count) + + write_wave(path, sample_rate, audio) + return path + + +def _reconstruct(config: Config, audio_path: Path) -> Reconstruction: + reconstruction = Reconstructor(config, library=_library(config))(audio_path) + assert reconstruction is not None + return reconstruction + + +def _sounding(reconstruction: Reconstruction) -> List[PulseInstruction]: + return [ + instruction + for instruction in reconstruction.instructions[ChannelName.PULSE1] + if isinstance(instruction, PulseInstruction) and instruction.on + ] + + +def _cents_off(config: Config, instruction: PulseInstruction) -> float: + """How far the frame sounds from the note it names, in cents.""" + generator = PulseGenerator(config, ChannelName.PULSE1) + bent = generator.sounds_at(instruction.pitch, instruction.timer_offset) + named = generator.sounds_at(instruction.pitch, 0) + return CENTS_PER_OCTAVE * math.log2(bent / named) + + +@pytest.fixture(scope="module") +def refining() -> Config: + return _config(refinement=RefinementConfig(enabled=True)) + + +@pytest.fixture(scope="module") +def plain() -> Config: + return _config(refinement=RefinementConfig(enabled=False)) + + +class TestAConversionLandsOnTheNoteTheSourceSounds: + """A source recorded off the equal-tempered grid comes back bent onto its own tuning. + + The catalog holds whole semitones, so the matching can only reach the nearest note; the + hardware's divider grid is finer than that, and the refinement is what spends the difference. + """ + + def test_a_detuned_tone_comes_back_bent_towards_its_own_pitch( + self, + refining: Config, + tmp_path: Path, + ) -> None: + reconstruction = _reconstruct(refining, _square_path(tmp_path / "sharp.wav", refining, DETUNE_CENTS)) + + sounding = _sounding(reconstruction) + assert sounding + measured = float(np.median([_cents_off(refining, frame) for frame in sounding])) + + assert abs(measured - DETUNE_CENTS) < CENTS_TOLERANCE + + def test_a_tone_already_on_the_grid_is_left_where_it_is( + self, + refining: Config, + tmp_path: Path, + ) -> None: + reconstruction = _reconstruct(refining, _square_path(tmp_path / "flat.wav", refining, 0.0)) + + sounding = _sounding(reconstruction) + assert sounding + measured = float(np.median([_cents_off(refining, frame) for frame in sounding])) + + assert abs(measured) < CENTS_TOLERANCE + + def test_the_bend_holds_rather_than_wandering(self, refining: Config, tmp_path: Path) -> None: + """A steady tone is one tuning, so the stream settles on one bend and keeps it. + + The opening and closing frames are read across the edge of the recording, where the + constant-Q window reaches past what was recorded, so the interior is what a held tuning + shows in. + """ + reconstruction = _reconstruct(refining, _square_path(tmp_path / "steady.wav", refining, DETUNE_CENTS)) + + bends = [frame.timer_offset for frame in _sounding(reconstruction)] + interior = bends[EDGE_FRAMES:-EDGE_FRAMES] + + assert interior + assert len(set(interior)) == 1 + + +class TestWhatTheRefinementLeavesAlone: + def test_a_conversion_with_the_refinement_off_bends_nothing( + self, + plain: Config, + tmp_path: Path, + ) -> None: + reconstruction = _reconstruct(plain, _square_path(tmp_path / "unrefined.wav", plain, DETUNE_CENTS)) + + assert all(not frame.bent for frame in _sounding(reconstruction)) + + def test_a_conversion_with_the_refinement_off_records_the_bend_as_the_channels( + self, + plain: Config, + tmp_path: Path, + ) -> None: + """Nothing bent means nothing chosen, so both dimensions stay the channel's own.""" + from sampletones_core.constants.enums import FeatureKey + + reconstruction = _reconstruct(plain, _square_path(tmp_path / "held.wav", plain, DETUNE_CENTS)) + held = reconstruction.held_features[ChannelName.PULSE1] + + assert FeatureKey.PITCH in held + assert FeatureKey.HI_PITCH in held + + def test_a_source_with_no_pitch_to_read_is_left_unbent( + self, + refining: Config, + tmp_path: Path, + ) -> None: + """Noise states no fundamental, so no frame of it earns a bend.""" + reconstruction = _reconstruct(refining, _noise_path(tmp_path / "noise.wav", refining)) + + assert all(not frame.bent for frame in _sounding(reconstruction)) diff --git a/tests/unit/sampletones_core/fft/test_instantaneous.py b/tests/unit/sampletones_core/fft/test_instantaneous.py new file mode 100644 index 000000000..0d9d73b3f --- /dev/null +++ b/tests/unit/sampletones_core/fft/test_instantaneous.py @@ -0,0 +1,97 @@ +import math +from typing import Final, List, Optional + +import numpy as np +import pytest + +from sampletones_core.fft.instantaneous import FundamentalReading, InstantaneousPitch + +SAMPLE_RATE: Final[int] = 44100 +HOP: Final[int] = 735 +SECONDS: Final[float] = 0.5 +A4_FREQUENCY: Final[float] = 440.0 +CENTS_PER_OCTAVE: Final[float] = 1200.0 +CENT_TOLERANCE: Final[float] = 1.0 +PITCHED_CONFIDENCE: Final[float] = 0.2 +UNPITCHED_CONFIDENCE: Final[float] = 0.1 +NOISE_SEED: Final[int] = 4 + + +def _harmonic(frequency: float, seed: int = 0) -> np.ndarray: + """A steady tone with a full harmonic series, which is what a pitched frame looks like.""" + generator = np.random.default_rng(seed) + count = int(SAMPLE_RATE * SECONDS) + time = np.arange(count) / SAMPLE_RATE + audio = np.zeros(count) + for harmonic in range(1, 20): + if frequency * harmonic > SAMPLE_RATE / 2: + break + + audio += np.sin(2 * np.pi * frequency * harmonic * time + generator.uniform(0, 2 * np.pi)) / harmonic + + return (audio / np.abs(audio).max() * 0.3).astype(np.float32) + + +def _readings(audio: np.ndarray, reference: float) -> List[FundamentalReading]: + reader = InstantaneousPitch(audio, SAMPLE_RATE, HOP) + read: List[Optional[FundamentalReading]] = [reader.at(frame, reference) for frame in range(reader.columns)] + return [reading for reading in read if reading is not None] + + +def _median_cents(readings: List[FundamentalReading], reference: float) -> float: + return CENTS_PER_OCTAVE * math.log2(float(np.median([reading.frequency for reading in readings])) / reference) + + +class TestReadingAFundamental: + @pytest.mark.parametrize("cents", (-45.0, -18.0, 0.0, 25.0, 40.0), ids=lambda cents: f"{cents:+.0f}c") + def test_a_tone_between_two_notes_is_read_where_it_stands(self, cents: float) -> None: + """The bins are a semitone apart, and the phase places the tone far inside one of them.""" + truth = A4_FREQUENCY * 2 ** (cents / CENTS_PER_OCTAVE) + + readings = _readings(_harmonic(truth), A4_FREQUENCY) + + assert readings + assert abs(_median_cents(readings, A4_FREQUENCY) - cents) < CENT_TOLERANCE + + def test_a_reading_is_taken_for_every_frame_of_the_recording(self) -> None: + reader = InstantaneousPitch(_harmonic(A4_FREQUENCY), SAMPLE_RATE, HOP) + + assert all(reader.at(frame, A4_FREQUENCY) is not None for frame in range(reader.columns)) + + def test_a_frame_outside_the_recording_is_read_as_nothing(self) -> None: + reader = InstantaneousPitch(_harmonic(A4_FREQUENCY), SAMPLE_RATE, HOP) + + assert reader.at(-1, A4_FREQUENCY) is None + assert reader.at(reader.columns, A4_FREQUENCY) is None + + def test_a_reference_the_transform_never_reaches_is_read_as_nothing(self) -> None: + reader = InstantaneousPitch(_harmonic(A4_FREQUENCY), SAMPLE_RATE, HOP) + + assert reader.at(1, SAMPLE_RATE) is None + + +class TestHowMuchOfAFrameStandsBehindItsReading: + def test_a_pitched_frame_reads_with_confidence(self) -> None: + readings = _readings(_harmonic(A4_FREQUENCY), A4_FREQUENCY) + + assert float(np.median([reading.confidence for reading in readings])) > PITCHED_CONFIDENCE + + def test_noise_reads_with_none(self) -> None: + """Noise spreads its energy everywhere, so the harmonics of any note hold little of it.""" + count = int(SAMPLE_RATE * SECONDS) + noise = np.random.default_rng(NOISE_SEED).normal(0.0, 0.3, count).astype(np.float32) + + readings = _readings(noise, A4_FREQUENCY) + + assert float(np.median([reading.confidence for reading in readings])) < UNPITCHED_CONFIDENCE + + def test_a_tone_sharing_the_frame_lowers_the_share_without_moving_the_reading(self) -> None: + alone = _harmonic(A4_FREQUENCY) + crowded = alone + _harmonic(A4_FREQUENCY * 1.5, seed=7) + + readings = _readings(crowded, A4_FREQUENCY) + + assert abs(_median_cents(readings, A4_FREQUENCY)) < CENT_TOLERANCE + assert float(np.median([reading.confidence for reading in readings])) < float( + np.median([reading.confidence for reading in _readings(alone, A4_FREQUENCY)]) + ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/__init__.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py new file mode 100644 index 000000000..53ca141ae --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py @@ -0,0 +1,109 @@ +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_core.reconstructions.reconstructor.refinement.smoothing import smoothed +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +WINDOW: Final[int] = 4 +CHANGE_WEIGHT: Final[float] = 2.0 + + +def _smoothed(proposals: List[Optional[int]]) -> List[int]: + return smoothed(proposals, window=WINDOW, change_weight=CHANGE_WEIGHT) + + +class TestWhatTheWalkSettlesOn(BaseTestSuite): + """The walk follows the readings while paying a toll on every change it makes.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + proposals: Tuple[Optional[int], ...] + expected: Tuple[int, ...] + + test_cases: Tuple["TestWhatTheWalkSettlesOn.TestCase", ...] = ( + TestCase(label="nothing read", proposals=(), expected=()), + TestCase(label="one reading", proposals=(5,), expected=(5,)), + TestCase( + label="a steady reading is kept", + proposals=(3, 3, 3, 3), + expected=(3, 3, 3, 3), + ), + TestCase( + label="a single stray reading is absorbed", + proposals=(3, 3, 6, 3, 3), + expected=(3, 3, 3, 3, 3), + ), + TestCase( + label="a stray reading worth two tolls is followed", + proposals=(3, 3, 12, 3, 3), + expected=(3, 3, 12, 3, 3), + ), + TestCase( + label="a reading that holds is followed", + proposals=(3, 3, 3, -8, -8, -8, -8), + expected=(3, 3, 3, -8, -8, -8, -8), + ), + TestCase( + label="frames that read nothing rest at no bend", + proposals=(None, None, None), + expected=(0, 0, 0), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_walk_settles_where_the_readings_point( + self, + test_case: "TestWhatTheWalkSettlesOn.TestCase", + ) -> None: + assert tuple(_smoothed(list(test_case.proposals))) == test_case.expected + + +class TestTheTollOnChanging: + def test_a_walk_paying_nothing_to_change_follows_every_reading(self) -> None: + proposals: List[Optional[int]] = [3, 9, 3, 9] + + assert smoothed(proposals, window=WINDOW, change_weight=0.0) == [3, 9, 3, 9] + + def test_a_walk_that_cannot_afford_a_change_holds_one_bend(self) -> None: + proposals: List[Optional[int]] = [3, 9, 3, 9] + + assert len(set(smoothed(proposals, window=WINDOW, change_weight=1000.0))) == 1 + + def test_an_excursion_is_absorbed_while_it_stays_within_two_tolls(self) -> None: + """Leaving a bend and returning costs two changes, which is what a blip has to beat.""" + toll = int(2 * CHANGE_WEIGHT) + held: List[Optional[int]] = [3, 3, 3 + toll, 3, 3] + beyond: List[Optional[int]] = [3, 3, 3 + toll * 2, 3, 3] + + assert _smoothed(held) == [3, 3, 3, 3, 3] + assert _smoothed(beyond)[2] != 3 + + def test_a_frame_that_read_nothing_costs_the_walk_nothing(self) -> None: + """A gap in the readings neither pulls the bend nor pays for holding it.""" + proposals: List[Optional[int]] = [4, 4, None, None, 4, 4] + + assert _smoothed(proposals) == [4, 4, 4, 4, 4, 4] + + def test_every_frame_answers(self) -> None: + proposals: List[Optional[int]] = [1, None, 2, None, 3] + + assert len(_smoothed(proposals)) == len(proposals) + + +class TestTheStatesTheWalkConsiders: + def test_the_walk_settles_only_on_bends_its_neighborhood_read(self) -> None: + """The states are the readings, so a divider range spanning tens of steps stays small.""" + proposals: List[Optional[int]] = [10, 20, 30] + + assert set(_smoothed(proposals)) <= {0, 10, 20, 30} + + def test_a_reading_beyond_the_window_is_out_of_reach(self) -> None: + """A frame settles on what stands near it, so a distant reading pulls it no further.""" + proposals: List[Optional[int]] = [7] + [None] * (WINDOW * 3) + [7] + + settled = smoothed(proposals, window=1, change_weight=CHANGE_WEIGHT) + + assert settled[len(proposals) // 2] == 0 From dd2fbc3ca3f23362b8ec8b0364ee4e2897e5afd2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 13:25:14 +0200 Subject: [PATCH 141/142] Added: a bend plane per tone channel --- docs/concepts/compression.md | 40 +++--- docs/development/bugs-and-todos.md | 11 +- docs/development/player.md | 17 ++- docs/formats/nsf.md | 43 +++--- .../compression/compressed.py | 4 +- src/sampletones_player/compression/decode.py | 6 +- .../compression/dictionary/phrase.py | 2 +- src/sampletones_player/compression/encode.py | 6 +- .../compression/matches/index.py | 2 +- .../compression/matches/matcher.py | 2 +- .../compression/matches/shift.py | 2 +- .../compression/parse/song.py | 2 +- .../compression/planes/channel.py | 36 +++-- .../compression/planes/order.py | 11 +- .../compression/planes/rebuild.py | 45 ++++-- .../compression/planes/separate.py | 17 +-- .../compression/planes/song.py | 28 ++-- .../compression/progress/monitor.py | 2 +- .../compression/progress/report.py | 2 +- src/sampletones_player/compression/seeds.py | 16 ++- src/sampletones_player/compression/song.py | 4 +- .../driver/assembly/include/song.inc | 17 ++- .../driver/assembly/source/channels.s | 50 +++++-- .../driver/binary/driver.bin | Bin 584 -> 615 bytes src/sampletones_player/nsf/song.py | 4 +- src/sampletones_player/song.py | 4 +- .../specification/binary.py | 36 +++++ .../specification/compression.py | 15 +- tests/integration/nsf/corpus.py | 2 +- tests/integration/nsf/test_backend.py | 2 +- .../nsf/test_compression_report.py | 13 +- tests/integration/nsf/test_driver_bend.py | 133 ++++++++++++++++++ tests/integration/nsf/test_driver_trace.py | 2 +- tests/suite/player.py | 46 +++++- .../compression/matches/test_shift.py | 2 +- .../compression/planes/test_channel.py | 2 +- .../compression/planes/test_order.py | 2 +- .../compression/planes/test_song.py | 36 +++-- .../compression/test_admit.py | 3 +- .../compression/test_encode.py | 10 +- .../compression/test_seeds.py | 71 +++++++--- .../compression/tokens/test_phrase.py | 2 +- .../driver/test_song_include.py | 13 +- .../unit/sampletones_player/nsf/test_song.py | 21 ++- 44 files changed, 582 insertions(+), 202 deletions(-) create mode 100644 tests/integration/nsf/test_driver_bend.py diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md index 46d506202..9fab475a1 100644 --- a/docs/concepts/compression.md +++ b/docs/concepts/compression.md @@ -59,13 +59,15 @@ arrangement falls from 11 bytes a tick to about 1.7. A tone channel names a pitch by the **divider** the hardware counts down from, which takes two bytes and runs the opposite way to the note: higher notes have smaller dividers, and the steps between them are uneven. The encoder replaces the two -divider planes with one **pitch index** — how far the note sits above the lowest -pitch the table covers — and the song block carries a table the driver resolves it -through. Every channel is then two planes, and a tick is eight bytes before any -coding at all. - -Saving a byte a tick is the smaller half of why this matters. The larger half is -that **a pitch index can be transposed and a divider cannot.** The same figure played +divider planes with a **pitch index** — how far the note sits above the lowest +pitch the table covers — and a **bend**, the divider steps the tick stands away from +that note. The song block carries a table the driver resolves the index through and +adds the bend to. A tone channel is therefore three planes, the same count as the +registers it writes. + +Trading two dividers for an index and a bend is worth about a twentieth once the +planes are coded, since a bend a song never uses is a plane of one value. The larger +half is that **a pitch index can be transposed and a divider cannot.** The same figure played at five pitches is five unrelated byte sequences in divider space; in index space it is one sequence and five offsets. That is what turns a repeated sample into a single dictionary entry in §5. @@ -241,14 +243,14 @@ above it: | what is stored | bytes per tick | ratio | ticks that fit | |---|---|---|---| -| a record per tick per channel | 11.000 | 1.00 | 2925 | -| planes, coded | 1.735 | 6.34 | 18544 | -| planes with a pitch index | 1.598 | 6.88 | 20255 | -| phrases from the instruments | 1.126 | 9.77 | 28994 | -| phrases played transposed | 0.976 | 11.27 | 33567 | -| phrases from the search as well | **0.811** | **13.56** | **40673** | - -The whole song is 8761 bytes of the roughly 32000 available, and **40673 ticks is 11.3 +| a record per tick per channel | 11.000 | 1.00 | 2923 | +| planes, coded | 1.735 | 6.34 | 18527 | +| planes with a pitch index and a bend | 1.645 | 6.69 | 19644 | +| phrases from the instruments | 1.191 | 9.23 | 27302 | +| phrases played transposed | 1.046 | 10.52 | 31194 | +| phrases from the search as well | **0.880** | **12.49** | **37238** | + +The whole song is 9509 bytes of the roughly 32000 available, and **37238 ticks is 10.3 minutes at 60 Hz**, against the 49 seconds a record per tick reaches. Encoding it costs about two seconds; decoding it costs the console around twenty instructions per plane per tick, comfortably inside a video frame. @@ -257,7 +259,7 @@ per tick, comfortably inside a video frame. constants are settled from it. Two of them were settled against expectation: splitting the duty cycle out of the control byte into a plane of its own **costs** 14 %, because volume and duty turn over together and a split pays two opcodes for what one covers; -and the pitch index earns its place twice, 8 % directly and a further 13 % through the +and the pitch index earns its place twice, 5 % directly and a further 12 % through the transposition it makes possible. ## 7. Limitations @@ -280,8 +282,8 @@ transposition it makes possible. | quantity | value | |---|---| -| planes | 8 — control and value, for each of four channels | -| bytes per tick before coding | 8 | +| planes | control and value for every channel, and a bend for each tone channel | +| bytes per tick before coding | 11 | | ticks one hold covers | 1 to 64 | | values one literal carries | 1 to 64 | | ticks one phrase token covers | 1 to 256 | @@ -289,7 +291,7 @@ transposition it makes possible. | phrases in the dictionary | up to 255 | | values in a phrase | up to 255 | | candidate lengths the search gathers | 3 to 48 | -| decoder state on the console | 64 bytes of zero page, 8 per plane | +| decoder state on the console | 88 bytes of zero page, 8 per plane | Where things live: diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 498868b40..6a986d304 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -23,11 +23,12 @@ starts carrying. one. * Arpeggio modes: a sequence's `setting` byte states absolute. Fixed, relative and scheme need an enum of their own, and scheme needs the item bit-packing FamiTracker gives it. -* The bend in the NSF planes. A channel's value plane names a pitch as a semitone index, which is - what makes `TRANSPOSED_PHRASE` work, so a divider offset needs a plane of its own: `PLANE_COUNT` - 8 → 12, a wider song header, another `PLANE_STATE_SIZE` block per channel, another - `plane_advance` per channel per tick and a sixteen-bit add before the timer registers are - written. Until it lands, an NSF renders a bent note at the note's own divider. +* The bend reaching the NSF planes. Each tone channel carries a bend plane and the driver adds + what it holds to the divider, but the encoders still leave the dimension to the note: + `registers/playable.py::playable` states that once, and every encoder below reads frames + carrying no bend. Filling the plane means the register encoders holding the bent divider and + `PitchTable` naming a divider as the nearest pitch beside a signed residual. Until it lands, an + NSF renders a bent note at the note's own divider. * The bend in a Bitphase export. `formats/bitphase/envelopes.py` states the three dimensions it writes; `NesInstrumentRow` already carries `tone_add` and `tone_accumulation`, so the mapping is confined to that module. diff --git a/docs/development/player.md b/docs/development/player.md index a035c071e..b3fcf59db 100644 --- a/docs/development/player.md +++ b/docs/development/player.md @@ -43,7 +43,7 @@ songs. The format's constants are settled from that report rather than from argu ## The song a file carries -`Song` is the compressed song: the dictionary, the eight token streams, the timer table, +`Song` is the compressed song: the dictionary, one token stream per plane, the timer table, the clock and the loop point. The register values every channel writes are read back out of the streams on demand, so a trace, a writer and a test all speak to the compressed song without knowing it is one. @@ -60,7 +60,7 @@ than sounded half in tune. ## The codec -The codec turns the four channels' per-tick register values into the eight token streams the +The codec turns the four channels' per-tick register values into the token streams the driver reads, and back again. `compression/decode.py` is the golden model: every encoding is held against it, so what the console plays and what the encoder meant are the same values. The scheme itself, with the measurements each layer is settled on, is @@ -105,7 +105,7 @@ true fraction of the song. ## The driver The driver is three sources: the entry points and the play call in `driver.s`, the clock in -`clock.s`, and the eight plane decoders in `channels.s`. +`clock.s`, and the plane decoders in `channels.s`. **The clock steps a tick at a time.** A play call adds the header's step to an accumulator and reads the whole ticks off the top; the driver then moves the clock on by one tick at a @@ -122,10 +122,16 @@ phrase whose bytes lie inline behind its opcode — so playing a tick is the sam instructions whichever token is standing. **The plane's own state block is the whole of the dispatch.** Which plane is being advanced -is a base offset held in `X`, the way a channel's register base is, so eight decoders are -one routine called eight times. The state lives in zero page, well inside what the driver +is a base offset held in `X`, the way a channel's register base is, so every plane's decoder +is one routine called again. The state lives in zero page, well inside what the driver leaves free, and the linker configuration keeps the two-segment memory model an NSF loads. +**The one sum the driver performs is the bend.** A tone channel's value plane resolves to a +divider through the timer table, and its bend plane states the steps the tick stands away +from it — sign-extended and added across both halves of the timer, with the high half +reaching the register only where it changed. Everything that keeps the sum in range is +settled in Python, so what crosses into assembly stays a byte moved and a carry followed. + ## How it is verified The chain runs from the register values upward, and each link is held on its own: @@ -138,6 +144,7 @@ The chain runs from the register values upward, and each link is held on its own | The byte layout | a hand-built song serializes to expected bytes | | The assembly agrees with the specification | the include's equates are read and compared field by field | | The driver behaves | the assembled image on a 6502 emulator against `RegisterTrace.from_song`, over several rates and over songs that repeat | +| The driver's arithmetic | a song stating a bend outright, held to the divider each tick is meant to sound at | | The audio | a captured trace re-rendered against the reconstruction's own approximation | | The whole export | a project exported, played on the emulator, and read back as the instructions the sequencer sounds | | Listening | `make nsf-samples` then `make nsf-render`, or any NSF player | diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md index 6919ebf8b..bc9ae9733 100644 --- a/docs/formats/nsf.md +++ b/docs/formats/nsf.md @@ -47,17 +47,18 @@ rather than written short. ## B. The song block -A song reaches the console as eight **token streams** — one per plane, two planes per -channel — decoded a tick at a time against a **dictionary** of phrases and a **timer -table** of pitches. Every offset below is a `uint16` counted from the block's own first -byte, so the whole block plays from wherever the file loads it. +A song reaches the console as one **token stream** per plane, decoded a tick at a time +against a **dictionary** of phrases and a **timer table** of pitches. Every channel writes +a control plane and a value plane, and a tone channel writes a bend plane besides. Every +offset below is a `uint16` counted from the block's own first byte, so the whole block +plays from wherever the file loads it. ``` +0 header -+43 timer table ++55 timer table phrase table: count, then one offset per phrase phrase bodies: each a length byte, then its values - eight token streams, in plane order + one token stream per plane, in plane order ``` ### B.1 The header @@ -70,8 +71,8 @@ byte, so the whole block plays from wherever the file loads it. | +5 | 2 | the tick the song returns to, or `$FFFF` where it stops there | | +7 | 2 | where the timer table begins | | +9 | 2 | where the phrase table begins | -| +11 | 8×2 | where each plane's stream begins | -| +27 | 8×2 | where each plane's stream is re-entered once the song comes round | +| +11 | `PLANE_COUNT`×2 | where each plane's stream begins | +| +33 | `PLANE_COUNT`×2 | where each plane's stream is re-entered once the song comes round | All fields are little-endian, and the header runs to `SONG_HEADER_SIZE` bytes. @@ -141,34 +142,44 @@ plane at the byte the header states and clearing what it was playing. ## C. What the planes hold -The planes are written in this order, and each pair belongs to one channel: +The planes are written in this order, and each group belongs to one channel: | Plane | Carries | Reaches | |---|---|---| | pulse 1 control | duty cycle and volume | `$4000` | | pulse 1 value | pitch index | `$4002`, `$4003` | +| pulse 1 bend | divider offset | `$4002`, `$4003` | | pulse 2 control | duty cycle and volume | `$4004` | | pulse 2 value | pitch index | `$4006`, `$4007` | +| pulse 2 bend | divider offset | `$4006`, `$4007` | | triangle control | linear counter | `$4008` | | triangle value | pitch index | `$400A`, `$400B` | +| triangle bend | divider offset | `$400A`, `$400B` | | noise control | volume | `$400C` | | noise value | period and mode | `$400E` | Splitting a channel's registers apart is what gives each plane something to repeat: a volume envelope and a pitch line are separate series that turn over at their own rates. +**The noise channel reads no bend.** It selects one of sixteen fixed periods, so there is +no finer grid for a bend to reach, and the plane it would hold is left out of the block. + **A timer's high half reaches the register only where it differs from the last one written.** Storing it restarts a pulse waveform and reloads the triangle's counter, so a channel holding one pitch across a rest keeps its phase running the way a rendered channel does. -**A value plane names a pitch, not a divider.** Stating a note as its distance above the lowest -one the song reaches is what lets `TRANSPOSED_PHRASE` move a whole phrase by adding to it, and a -divider offset added to an index means nothing. So a frame carrying a **bend** — the pitch and -hi-pitch dimensions an instrument writes — reaches the driver at its note's own divider: -`registers/playable.py::playable` states that once, and every encoder below reads frames that -carry no bend. Carrying one would mean a third plane per channel; `docs/development/bugs-and-todos.md` -under **Tracker** owns that work. +**A value plane names a pitch, not a divider.** Stating a note as its distance above the +lowest one the song reaches is what lets `TRANSPOSED_PHRASE` move a whole phrase by adding +to it, and a divider offset added to an index means nothing. A tone channel's **bend** +plane is where the offset goes: one signed byte a tick, in two's complement, added to the +divider the value plane's note resolves to. + +The driver sign-extends that byte and adds it across both halves of the timer, which is the +only arithmetic it performs on a song's behalf. Everything that makes the sum land in +range is settled in Python: the divider stays within `[MIN_TIMER, MAX_TIMER]`, so the +timer's high half never exceeds three bits, never reaches the length-counter field beside +them, and never collides with the `$FF` the driver marks an unwritten shadow by. ## D. Limits diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py index 796cc84fb..adecebf3f 100644 --- a/src/sampletones_player/compression/compressed.py +++ b/src/sampletones_player/compression/compressed.py @@ -10,7 +10,7 @@ class CompressedPlanes(BaseModel): - """A song's planes as the driver reads them: one dictionary and eight token streams. + """A song's planes as the driver reads them: one dictionary and a token stream per plane. Attributes: phrases: The dictionary every stream's tokens name. @@ -33,7 +33,7 @@ def _validate_the_song_lasts(self) -> CompressedPlanes: @property def size(self) -> int: - """The bytes the dictionary and the eight streams take together.""" + """The bytes the dictionary and every plane's stream take together.""" return self.phrases.size + sum(len(stream) for stream in self.streams) def entries(self, tick: int) -> Tuple[int, ...]: diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py index 75783dc6f..3e7bd79d9 100644 --- a/src/sampletones_player/compression/decode.py +++ b/src/sampletones_player/compression/decode.py @@ -4,8 +4,8 @@ from sampletones_player.compression.dictionary.table import PhraseTable from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.binary import BYTE_VALUES from sampletones_player.specification.compression import ( - BYTE_VALUES, INITIAL_PLANE_VALUE, PHRASE_ID_ESCAPE, TOKEN_OPERAND_MASK, @@ -91,13 +91,13 @@ def decode_plane(data: bytes, table: PhraseTable, ticks: int) -> bytes: def decode_planes(compressed: CompressedPlanes) -> SongPlanes: - """Plays a song's eight token streams back into the planes they were written from. + """Plays a song's token streams back into the planes they were written from. Args: compressed: The dictionary, the streams and the ticks the song lasts. Returns: - SongPlanes: The eight planes, two per channel. + SongPlanes: The planes under the channel each belongs to. """ played = PlaneOrder.across( decode_plane( diff --git a/src/sampletones_player/compression/dictionary/phrase.py b/src/sampletones_player/compression/dictionary/phrase.py index c12a9b480..ea8fabe6d 100644 --- a/src/sampletones_player/compression/dictionary/phrase.py +++ b/src/sampletones_player/compression/dictionary/phrase.py @@ -4,8 +4,8 @@ from pydantic import BaseModel, ConfigDict, model_validator +from sampletones_player.specification.binary import BYTE_VALUES from sampletones_player.specification.compression import ( - BYTE_VALUES, MAX_PHRASE_LENGTH, PHRASE_LENGTH_SIZE, PHRASE_TABLE_ENTRY_SIZE, diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py index 17a9b5383..1cf52ccfe 100644 --- a/src/sampletones_player/compression/encode.py +++ b/src/sampletones_player/compression/encode.py @@ -121,7 +121,7 @@ def encode_planes( boundaries: FrozenSet[int], report: CodecReporter = silent_reporter, ) -> CompressedPlanes: - """Compresses a song's eight planes into the dictionary and streams the driver reads. + """Compresses a song's planes into the dictionary and streams the driver reads. Every layer is weighed against one reading of the song naming no phrase at all: the seeds a dictionary crowded past its ids keeps, and the bytes each phrase spares once the table @@ -131,14 +131,14 @@ def encode_planes( phrases inside the opcodes that name them. Args: - planes: The eight planes, two per channel. + planes: The planes under the channel each belongs to. seeds: The phrases the song's instruments offer. options: Which of the codec's layers the encoding is built from. boundaries: The ticks a token starts on, beyond the first tick of the song. report: Hears what the run holds each time it looks up, and answers whether it goes on. Returns: - CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. + CompressedPlanes: The dictionary, every plane's token stream and the ticks the song lasts. Raises: OperationCanceled: If ``report`` withdraws the run. diff --git a/src/sampletones_player/compression/matches/index.py b/src/sampletones_player/compression/matches/index.py index 08559d1cc..cbe5a4687 100644 --- a/src/sampletones_player/compression/matches/index.py +++ b/src/sampletones_player/compression/matches/index.py @@ -4,7 +4,7 @@ from itertools import pairwise from typing import List, Tuple -from sampletones_player.specification.compression import BYTE_VALUES +from sampletones_player.specification.binary import BYTE_VALUES @dataclass(frozen=True) diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py index 693680797..89e772271 100644 --- a/src/sampletones_player/compression/matches/matcher.py +++ b/src/sampletones_player/compression/matches/matcher.py @@ -5,7 +5,7 @@ from sampletones_player.compression.matches.cache import KEY_LENGTH, MIN_PHRASE_TICKS, MatchCache from sampletones_player.compression.matches.index import PlaneIndex from sampletones_player.compression.matches.match import PhraseMatch -from sampletones_player.specification.compression import BYTE_VALUES +from sampletones_player.specification.binary import BYTE_VALUES class PhraseMatcher: diff --git a/src/sampletones_player/compression/matches/shift.py b/src/sampletones_player/compression/matches/shift.py index fda242cda..26ff801c7 100644 --- a/src/sampletones_player/compression/matches/shift.py +++ b/src/sampletones_player/compression/matches/shift.py @@ -1,7 +1,7 @@ from functools import lru_cache from typing import Final -from sampletones_player.specification.compression import BYTE_VALUES +from sampletones_player.specification.binary import BYTE_VALUES NO_SHIFT: Final[int] = 0 diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py index dded5b55f..c1914b965 100644 --- a/src/sampletones_player/compression/parse/song.py +++ b/src/sampletones_player/compression/parse/song.py @@ -20,7 +20,7 @@ def parse_planes( """Reads every plane of a song against one dictionary. A plane is where the run looks up: reading one is the longest stretch the codec spends - without a natural pause, so the monitor hears from it eight times over. + without a natural pause, so the monitor hears from it once per plane. Args: cache: The planes the song covers, alongside what each phrase plays against them. diff --git a/src/sampletones_player/compression/planes/channel.py b/src/sampletones_player/compression/planes/channel.py index 7a836d17d..cbee94f83 100644 --- a/src/sampletones_player/compression/planes/channel.py +++ b/src/sampletones_player/compression/planes/channel.py @@ -6,7 +6,7 @@ class ChannelPlanes(BaseModel): - """One channel's ticks separated into the two byte series it writes. + """One channel's ticks separated into the byte series it writes. A channel writes two things each tick: how it sounds and what it sounds. Read tick by tick those two braid together, and each turns over at its own pace — a volume envelope decays @@ -25,12 +25,10 @@ class ChannelPlanes(BaseModel): value: bytes @model_validator(mode="after") - def _validate_both_planes_cover_the_same_ticks(self) -> ChannelPlanes: - if len(self.control) != len(self.value): - raise ValueError( - f"a channel's planes cover the same ticks, and these cover " - f"{len(self.control)} and {len(self.value)}" - ) + def _validate_every_plane_covers_the_same_ticks(self) -> ChannelPlanes: + lengths = {len(plane) for plane in self.ordered} + if len(lengths) > 1: + raise ValueError(f"a channel's planes cover the same ticks, and these cover {sorted(lengths)}") if not self.control: raise ValueError("a channel's planes cover at least one tick") @@ -39,10 +37,30 @@ def _validate_both_planes_cover_the_same_ticks(self) -> ChannelPlanes: @property def ticks(self) -> int: - """The ticks both planes cover.""" + """The ticks the channel's planes cover.""" return len(self.control) @property def ordered(self) -> Tuple[bytes, ...]: - """Both planes, in the order the song block writes them.""" + """The channel's planes, in the order the song block writes them.""" return (self.control, self.value) + + +class TonePlanes(ChannelPlanes): + """A tone channel's ticks, the divider each one sounds at named in two parts. + + A tone channel reaches its divider through the pitch table, and a frame may stand away from + the note it names. The value plane holds the note, which is what lets a phrase be transposed + by adding to it, and the bend plane holds how far the frame stands from it — so the divider + the hardware takes is the sum, and each half repeats on its own terms. + + Attributes: + bend: The divider steps each tick stands away from its note, held as a signed byte. + """ + + bend: bytes + + @property + def ordered(self) -> Tuple[bytes, ...]: + """The channel's planes, in the order the song block writes them.""" + return (self.control, self.value, self.bend) diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py index 9a019ad46..2291484af 100644 --- a/src/sampletones_player/compression/planes/order.py +++ b/src/sampletones_player/compression/planes/order.py @@ -8,28 +8,33 @@ class PlaneOrder(NamedTuple): """One byte series per plane, in the order the song block writes them. - The song block states its planes as a run of eight, and both readings of a song take that + The song block states its planes as a single run, and both readings of a song take that shape: the values each plane plays tick by tick, and the tokens those values are written as. - Naming the eight is what lets either be carried whole and read back by the channel it - belongs to. + Naming them is what lets either be carried whole and read back by the channel it belongs to. Attributes: pulse1_control: The first pulse channel's timbre and volume. pulse1_value: The first pulse channel's pitch. + pulse1_bend: The first pulse channel's divider offset. pulse2_control: The second pulse channel's timbre and volume. pulse2_value: The second pulse channel's pitch. + pulse2_bend: The second pulse channel's divider offset. triangle_control: The triangle channel's linear counter. triangle_value: The triangle channel's pitch. + triangle_bend: The triangle channel's divider offset. noise_control: The noise channel's timbre and volume. noise_value: The noise channel's period. """ pulse1_control: bytes pulse1_value: bytes + pulse1_bend: bytes pulse2_control: bytes pulse2_value: bytes + pulse2_bend: bytes triangle_control: bytes triangle_value: bytes + triangle_bend: bytes noise_control: bytes noise_value: bytes diff --git a/src/sampletones_player/compression/planes/rebuild.py b/src/sampletones_player/compression/planes/rebuild.py index 235151146..94c0bac54 100644 --- a/src/sampletones_player/compression/planes/rebuild.py +++ b/src/sampletones_player/compression/planes/rebuild.py @@ -1,43 +1,64 @@ -from typing import Tuple +from typing import Iterator, Tuple from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.streams import ChannelStreams from sampletones_player.registers.triangle import TriangleRegisters +from sampletones_player.specification.binary import signed_byte from sampletones_player.specification.registers import ( MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT, ) +def _sounded( + planes: TonePlanes, + timers: Tuple[int, ...], +) -> Iterator[Tuple[int, int]]: + """Each tick's control byte beside the divider its note and its bend reach together. + + This is the reading the driver performs between the pitch table and the timer registers, + stated where it is testable. + + Args: + planes: The channel's planes. + timers: The divider each pitch sounds at, in pitch order. + + Yields: + Tuple[int, int]: The tick's control byte and the divider its registers carry. + """ + for control, index, bend in zip(planes.control, planes.value, planes.bend): + yield control, timers[index] + signed_byte(bend) + + def _pulse_registers( - planes: ChannelPlanes, + planes: TonePlanes, timers: Tuple[int, ...], ) -> Tuple[PulseRegisters, ...]: return tuple( PulseRegisters( control=control, - timer_low=timers[index] & MAX_REGISTER_VALUE, - timer_high=timers[index] >> TIMER_HIGH_SHIFT, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, ) - for control, index in zip(planes.control, planes.value) + for control, timer in _sounded(planes, timers) ) def _triangle_registers( - planes: ChannelPlanes, + planes: TonePlanes, timers: Tuple[int, ...], ) -> Tuple[TriangleRegisters, ...]: return tuple( TriangleRegisters( linear_counter=control, - timer_low=timers[index] & MAX_REGISTER_VALUE, - timer_high=timers[index] >> TIMER_HIGH_SHIFT, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, ) - for control, index in zip(planes.control, planes.value) + for control, timer in _sounded(planes, timers) ) @@ -55,10 +76,10 @@ def streams_from_planes( planes: SongPlanes, pitches: PitchTable, ) -> ChannelStreams: - """Rebuilds a song's four streams from the eight planes they were separated into. + """Rebuilds a song's four streams from the planes they were separated into. Args: - planes: The eight planes, two per channel. + planes: The planes under the channel each belongs to. pitches: The timer each pitch sounds at. Returns: diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py index d79cc21c6..cd666d7d7 100644 --- a/src/sampletones_player/compression/planes/separate.py +++ b/src/sampletones_player/compression/planes/separate.py @@ -2,7 +2,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_player.compression.pitch import PitchTable -from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.streams import ChannelStreams @@ -30,11 +30,12 @@ def _pitch_indices( def _tone_planes( registers: Sequence[ChannelRegisters], indices: Dict[int, int], -) -> ChannelPlanes: +) -> TonePlanes: control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers) - return ChannelPlanes( + return TonePlanes( control=control, value=_pitch_indices(registers, indices), + bend=bytes(len(registers)), ) @@ -49,7 +50,7 @@ def channel_planes( registers: Sequence[ChannelRegisters], pitches: PitchTable, ) -> ChannelPlanes: - """Separates one channel's ticks into the two planes the codec reads. + """Separates one channel's ticks into the planes the codec reads. Args: channel: The channel the registers belong to. @@ -57,7 +58,7 @@ def channel_planes( pitches: The timer each pitch sounds at. Returns: - ChannelPlanes: The channel's control and value planes. + ChannelPlanes: The channel's own planes. Raises: ValueError: If a tone channel sounds a timer the pitch table states no index for. @@ -72,9 +73,9 @@ def planes_from_streams( streams: ChannelStreams, pitches: PitchTable, ) -> SongPlanes: - """Separates a song's four streams into the eight planes the codec compresses. + """Separates a song's four streams into the planes the codec compresses. - Every channel is carried to the song's full length first, so the eight planes cover the same + Every channel is carried to the song's full length first, so every plane covers the same ticks and the decoder advances them together. Args: @@ -82,7 +83,7 @@ def planes_from_streams( pitches: The timer each pitch sounds at. Returns: - SongPlanes: The eight planes, two per channel. + SongPlanes: The planes under the channel each belongs to. Raises: ValueError: If a tone channel sounds a timer the pitch table states no index for. diff --git a/src/sampletones_player/compression/planes/song.py b/src/sampletones_player/compression/planes/song.py index 3a62034fc..05122d2ce 100644 --- a/src/sampletones_player/compression/planes/song.py +++ b/src/sampletones_player/compression/planes/song.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, model_validator -from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.order import PlaneOrder @@ -20,33 +20,36 @@ class SongPlanes(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - pulse1: ChannelPlanes - pulse2: ChannelPlanes - triangle: ChannelPlanes + pulse1: TonePlanes + pulse2: TonePlanes + triangle: TonePlanes noise: ChannelPlanes @classmethod def from_order(cls, planes: PlaneOrder) -> SongPlanes: - """Gathers eight planes back into the four channels that write them. + """Gathers a song block's planes back into the four channels that write them. Args: - planes: The eight planes, in the order the song block writes them. + planes: The planes, in the order the song block writes them. Returns: - SongPlanes: The planes under the channel each pair belongs to. + SongPlanes: The planes under the channel each belongs to. """ return cls( - pulse1=ChannelPlanes( + pulse1=TonePlanes( control=planes.pulse1_control, value=planes.pulse1_value, + bend=planes.pulse1_bend, ), - pulse2=ChannelPlanes( + pulse2=TonePlanes( control=planes.pulse2_control, value=planes.pulse2_value, + bend=planes.pulse2_bend, ), - triangle=ChannelPlanes( + triangle=TonePlanes( control=planes.triangle_control, value=planes.triangle_value, + bend=planes.triangle_bend, ), noise=ChannelPlanes( control=planes.noise_control, @@ -69,14 +72,17 @@ def ordered(self) -> Tuple[ChannelPlanes, ...]: @property def planes(self) -> PlaneOrder: - """The eight planes in the order the song block writes them.""" + """Every plane in the order the song block writes them.""" return PlaneOrder( pulse1_control=self.pulse1.control, pulse1_value=self.pulse1.value, + pulse1_bend=self.pulse1.bend, pulse2_control=self.pulse2.control, pulse2_value=self.pulse2.value, + pulse2_bend=self.pulse2.bend, triangle_control=self.triangle.control, triangle_value=self.triangle.value, + triangle_bend=self.triangle.bend, noise_control=self.noise.control, noise_value=self.noise.value, ) diff --git a/src/sampletones_player/compression/progress/monitor.py b/src/sampletones_player/compression/progress/monitor.py index 58670943b..c38c31864 100644 --- a/src/sampletones_player/compression/progress/monitor.py +++ b/src/sampletones_player/compression/progress/monitor.py @@ -32,7 +32,7 @@ def reached(self, phrases: int, size: int) -> None: Args: phrases: The entries the dictionary now holds. - size: The bytes the dictionary and the eight streams now take together. + size: The bytes the dictionary and every plane's stream now take together. Raises: OperationCanceled: If the run is no longer wanted. diff --git a/src/sampletones_player/compression/progress/report.py b/src/sampletones_player/compression/progress/report.py index 092e7bf27..4ad840526 100644 --- a/src/sampletones_player/compression/progress/report.py +++ b/src/sampletones_player/compression/progress/report.py @@ -8,7 +8,7 @@ class CodecProgress: Attributes: phrases: The entries the dictionary has gathered. - size: The bytes the dictionary and the eight token streams take together, as of the last + size: The bytes the dictionary and every plane's token stream take together, as of the last reading of the whole song; a run that has yet to read one reports nothing laid down. """ diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py index e088cb141..dbfe7bc55 100644 --- a/src/sampletones_player/compression/seeds.py +++ b/src/sampletones_player/compression/seeds.py @@ -19,9 +19,12 @@ def phrases_from_project( """The phrases a project's own instruments offer the dictionary. A song is built by playing sample slices at rows, so the shapes its planes repeat are the - slices themselves: each one reaches the dictionary as the two planes it writes, at the pitch - and level it was reconstructed at, and every row playing it names those entries at the shift - the row asks for. + slices themselves: each one reaches the dictionary as the planes it writes, at the pitch and + level it was reconstructed at, and every row playing it names those entries at the shift the + row asks for. + + A plane holding one value throughout offers the dictionary nothing a hold covers more + cheaply, so the slices seed the planes that turn over. Args: project: The project whose samples the song plays. @@ -41,6 +44,11 @@ def phrases_from_project( channel_registers(channel, played, timer_table), pitches, ) - phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes.ordered) + phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes.ordered if _turns_over(plane)) return tuple(phrases) + + +def _turns_over(plane: bytes) -> bool: + """Whether a plane reaches more than one value over the ticks it covers.""" + return len(set(plane)) > 1 diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py index 039364b2a..7a38582a7 100644 --- a/src/sampletones_player/compression/song.py +++ b/src/sampletones_player/compression/song.py @@ -42,7 +42,7 @@ def compress_song( report: Hears what the codec holds each time it looks up, and answers whether it goes on. Returns: - CompressedPlanes: The dictionary, the eight token streams and the ticks the song lasts. + CompressedPlanes: The dictionary, every plane's token stream and the ticks the song lasts. Raises: OperationCanceled: If ``report`` withdraws the run. @@ -68,7 +68,7 @@ def decompress_song( same values. Args: - planes: The dictionary, the eight token streams and the ticks the song lasts. + planes: The dictionary, every plane's token stream and the ticks the song lasts. pitches: The timer each pitch sounds at, which is what turns an index back into a timer. Returns: diff --git a/src/sampletones_player/driver/assembly/include/song.inc b/src/sampletones_player/driver/assembly/include/song.inc index 781b61fa1..e1b66d884 100644 --- a/src/sampletones_player/driver/assembly/include/song.inc +++ b/src/sampletones_player/driver/assembly/include/song.inc @@ -1,5 +1,5 @@ WORD_SIZE = 2 -PLANE_COUNT = 8 +PLANE_COUNT = 11 STEP_WHOLE_OFFSET = 0 STEP_FRACTION_OFFSET = STEP_WHOLE_OFFSET + 1 @@ -43,9 +43,12 @@ PLANE_STATE_BYTES = PLANE_COUNT * PLANE_STATE_SIZE PULSE1_CONTROL_PLANE = 0 * PLANE_STATE_SIZE PULSE1_VALUE_PLANE = 1 * PLANE_STATE_SIZE -PULSE2_CONTROL_PLANE = 2 * PLANE_STATE_SIZE -PULSE2_VALUE_PLANE = 3 * PLANE_STATE_SIZE -TRIANGLE_CONTROL_PLANE = 4 * PLANE_STATE_SIZE -TRIANGLE_VALUE_PLANE = 5 * PLANE_STATE_SIZE -NOISE_CONTROL_PLANE = 6 * PLANE_STATE_SIZE -NOISE_VALUE_PLANE = 7 * PLANE_STATE_SIZE +PULSE1_BEND_PLANE = 2 * PLANE_STATE_SIZE +PULSE2_CONTROL_PLANE = 3 * PLANE_STATE_SIZE +PULSE2_VALUE_PLANE = 4 * PLANE_STATE_SIZE +PULSE2_BEND_PLANE = 5 * PLANE_STATE_SIZE +TRIANGLE_CONTROL_PLANE = 6 * PLANE_STATE_SIZE +TRIANGLE_VALUE_PLANE = 7 * PLANE_STATE_SIZE +TRIANGLE_BEND_PLANE = 8 * PLANE_STATE_SIZE +NOISE_CONTROL_PLANE = 9 * PLANE_STATE_SIZE +NOISE_VALUE_PLANE = 10 * PLANE_STATE_SIZE diff --git a/src/sampletones_player/driver/assembly/source/channels.s b/src/sampletones_player/driver/assembly/source/channels.s index dcee633ca..4dd833e6c 100644 --- a/src/sampletones_player/driver/assembly/source/channels.s +++ b/src/sampletones_player/driver/assembly/source/channels.s @@ -18,7 +18,10 @@ pointer: .res 2 entry: .res 2 opcode: .res 1 phrase_table: .res 2 -timer_table: .res 2 +timer_low_table: .res 2 +timer_high_table: .res 2 +bend: .res 1 +bend_sign: .res 1 plane_state: .res PLANE_STATE_BYTES timer_high_shadows: .res TRIANGLE_REGISTERS + 1 @@ -28,7 +31,7 @@ timer_high_shadows: .res TRIANGLE_REGISTERS + 1 .assert PHRASE_TABLE_ENTRY_SIZE = 2, error, "a table entry is reached by one doubling" .assert PHRASE_LENGTH_SIZE = 1, error, "a phrase body follows its length by one byte" -; Readies the tables the planes read through and points all eight at their own first token. +; Readies the tables the planes read through and points every plane at its own first token. channels_reset: lda #SHADOW_UNWRITTEN sta timer_high_shadows + PULSE1_REGISTERS @@ -38,10 +41,18 @@ channels_reset: clc lda song_data + TIMER_TABLE_OFFSET adc #song_data - sta timer_table + 1 + sta timer_low_table + 1 + + clc + lda timer_low_table + adc #PITCH_COUNT + sta timer_high_table + lda timer_low_table + 1 + adc #$00 + sta timer_high_table + 1 clc lda song_data + PHRASE_TABLE_OFFSET @@ -270,18 +281,21 @@ channels_write: sta CHANNEL_CONTROL + PULSE1_REGISTERS ldx #PULSE1_REGISTERS ldy plane_state + PULSE1_VALUE_PLANE + PLANE_VALUE + lda plane_state + PULSE1_BEND_PLANE + PLANE_VALUE jsr write_timer lda plane_state + PULSE2_CONTROL_PLANE + PLANE_VALUE sta CHANNEL_CONTROL + PULSE2_REGISTERS ldx #PULSE2_REGISTERS ldy plane_state + PULSE2_VALUE_PLANE + PLANE_VALUE + lda plane_state + PULSE2_BEND_PLANE + PLANE_VALUE jsr write_timer lda plane_state + TRIANGLE_CONTROL_PLANE + PLANE_VALUE sta CHANNEL_CONTROL + TRIANGLE_REGISTERS ldx #TRIANGLE_REGISTERS ldy plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + lda plane_state + TRIANGLE_BEND_PLANE + PLANE_VALUE jsr write_timer lda plane_state + NOISE_CONTROL_PLANE + PLANE_VALUE @@ -290,18 +304,26 @@ channels_write: sta CHANNEL_TIMER_LOW + NOISE_REGISTERS rts -; Writes the timer the pitch at Y sounds at to the channel whose register base lies in X. The -; table holds every low byte and then every high byte, so one pointer reaches both halves. A high -; half reaches the register only where it differs from the last one written, since storing it -; restarts a pulse waveform and reloads the triangle's counter. +; Writes the timer the pitch at Y sounds at, moved by the bend in A, to the channel whose +; register base lies in X. The bend states divider steps in two's complement, so it reaches the +; timer's high half as $00 or $FF beside the carry the low half raised. A high half reaches the +; register only where it differs from the last one written, since storing it restarts a pulse +; waveform and reloads the triangle's counter. write_timer: - lda (timer_table),y - sta CHANNEL_TIMER_LOW,x - tya + sta bend + lda #$00 + bit bend + bpl @extended + lda #$FF +@extended: + sta bend_sign + + lda (timer_low_table),y clc - adc #PITCH_COUNT - tay - lda (timer_table),y + adc bend + sta CHANNEL_TIMER_LOW,x + lda (timer_high_table),y + adc bend_sign cmp timer_high_shadows,x beq @held sta timer_high_shadows,x diff --git a/src/sampletones_player/driver/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin index f48183e6ccf36e4540ba13dea85778ecb0892393..3a1b2060bfb4d02090c8d99022fa63f40c7af7ac 100644 GIT binary patch literal 615 zcmX|;J%|%Q6vt;jvf1SFdC6ucsaClro{gn8Hx`P@7Fwxbv4SC4V>~~&es|!32n%Tj zt_hqWgEzt!DN@;3X;K_4f>+8_hmEj>99o^TdfLo;zxV&YnfDmi2;7T-b1M+l$RdJ7 zHW6l|kSIq4imJr98#wO+XEStb)Oi%77?2YLX?>+8n)53YKBDBi6g`2aXoASMqh|6` z0w|z@20G+Co=YsumC=BL!qt&aL5=&=N7CgTYl(WSbNygQ$D2u>YX^_%_Y;e`Kb@jIgy)9eXN{!{lr@(EF`Hr5W}VmAH{TuIOTKzjy8w>ArSo_dUjT0+TPg>rh0mh;U`8_l@pt$UQNb@U_*4(!tFLt*W z&K_|;odNb0X3I(kb3>*U&-;kA@a~Sif96?jCEDCrI-f&%{^{;)6mRD;=ty6KHOA^m z(<_20&;=-9ZuZu|?_s;Q}V5S$(;(hQEBn>_dyA#frO zicqNG9$bVB8L~LJD7m2!2Rn*WT@*6NIo6kIm+$w7-^cg+eyF87y>WFCc?z|khcyOd zhhc3l(O?rUXOA1X zlJ0<(Qqa9W`(gU*f`c(2z bytes: The header states the clock, the length and where each of the song's parts begins, so the whole block plays from wherever the file loads it: the timer every pitch sounds at, the - dictionary the tokens name, and the eight token streams the channels decode a tick at a + dictionary the tokens name, and one token stream per plane, which the channels decode a tick at a time. A song that repeats also states the byte each stream is re-entered at, which is the whole of what a loop restores. @@ -77,7 +77,7 @@ def song_to_bytes(song: Song, available_bytes: int) -> bytes: available_bytes: The space the song has to fit in. Returns: - bytes: The song header, the timer table, the dictionary and the eight token streams. + bytes: The song header, the timer table, the dictionary and every plane's token stream. Raises: SongTooLargeError: If the song takes more than ``available_bytes``, or reaches further diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py index 246dcd874..705872bcb 100644 --- a/src/sampletones_player/song.py +++ b/src/sampletones_player/song.py @@ -18,13 +18,13 @@ class Song(BaseModel): """A song as the console holds it: compressed channel planes, a timer table and a clock. - A file carries a song as eight token streams over one dictionary, and this is that song, so + A file carries a song as one token stream per plane over one dictionary, and this is that song, so what the player holds and what the console reads are the same value. The register values each channel writes are read back out of the streams, the timer table turning a plane's pitch index into the divider the hardware takes. Attributes: - planes: The dictionary and the eight token streams the channels play. + planes: The dictionary and the token stream every plane plays. pitches: The timer each pitch sounds at. schedule: The engine ticks each play call advances the streams by. loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. diff --git a/src/sampletones_player/specification/binary.py b/src/sampletones_player/specification/binary.py index 19a0c4961..917fe89ff 100644 --- a/src/sampletones_player/specification/binary.py +++ b/src/sampletones_player/specification/binary.py @@ -1,3 +1,39 @@ from typing import Final WORD_SIZE: Final[int] = 2 +BYTE_VALUES: Final[int] = 256 +MAX_BYTE_VALUE: Final[int] = BYTE_VALUES - 1 +SIGNED_BYTE_LIMIT: Final[int] = BYTE_VALUES // 2 + + +def signed_byte(value: int) -> int: + """The number a byte states in two's complement, which is how a plane holds a signed value. + + Args: + value: The byte as the stream holds it. + + Returns: + int: The number it states. + """ + if value < SIGNED_BYTE_LIMIT: + return value + + return value - BYTE_VALUES + + +def unsigned_byte(value: int) -> int: + """The byte a signed number reaches a stream as, which is the form a phrase shifts within. + + Args: + value: The number to hold. + + Returns: + int: The byte stating it. + + Raises: + ValueError: If the number lies outside the range one byte states. + """ + if not -SIGNED_BYTE_LIMIT <= value < SIGNED_BYTE_LIMIT: + raise ValueError(f"a byte states {-SIGNED_BYTE_LIMIT} through {SIGNED_BYTE_LIMIT - 1}, and this is {value}") + + return value % BYTE_VALUES diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py index d26d70b18..ea582df37 100644 --- a/src/sampletones_player/specification/compression.py +++ b/src/sampletones_player/specification/compression.py @@ -3,7 +3,12 @@ from typing import Final from sampletones_core.constants.enums import ChannelName -from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.binary import ( + BYTE_VALUES, + MAX_BYTE_VALUE, + WORD_SIZE, +) +from sampletones_player.specification.channels import TONE_CHANNELS from sampletones_shared.constants.general import BITS_PER_BYTE @@ -23,9 +28,6 @@ class TokenTag(IntEnum): TRANSPOSED_PHRASE = 0xC0 -BYTE_VALUES: Final[int] = 256 -MAX_BYTE_VALUE: Final[int] = BYTE_VALUES - 1 - TOKEN_TAG_MASK: Final[int] = 0xC0 TOKEN_OPERAND_MASK: Final[int] = 0x3F @@ -50,5 +52,8 @@ class TokenTag(IntEnum): INITIAL_PLANE_VALUE: Final[int] = 0 PLANES_PER_CHANNEL: Final[int] = 2 -PLANE_COUNT: Final[int] = len(ChannelName.items()) * PLANES_PER_CHANNEL +TONE_PLANES_PER_CHANNEL: Final[int] = PLANES_PER_CHANNEL + 1 +PLANE_COUNT: Final[int] = sum( + TONE_PLANES_PER_CHANNEL if channel in TONE_CHANNELS else PLANES_PER_CHANNEL for channel in ChannelName.items() +) PLANE_STATE_SIZE: Final[int] = 8 diff --git a/tests/integration/nsf/corpus.py b/tests/integration/nsf/corpus.py index 767aacaa9..db7d107d7 100644 --- a/tests/integration/nsf/corpus.py +++ b/tests/integration/nsf/corpus.py @@ -62,7 +62,7 @@ def pitches(self) -> PitchTable: @property def planes(self) -> SongPlanes: - """The eight planes the song separates into.""" + """The planes the song separates into.""" return planes_from_streams(self.song.streams, self.pitches) @property diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py index 1a7aafb06..a080aa36a 100644 --- a/tests/integration/nsf/test_backend.py +++ b/tests/integration/nsf/test_backend.py @@ -234,7 +234,7 @@ def test_the_console_sounds_the_arrangement_the_project_states( integration_project: Project, ) -> None: """This closes the loop a project export opens: the arrangement was played out row by - row, compressed to eight token streams, decoded by the 6502 and written to the APU, and + row, compressed to a token stream per plane, decoded by the 6502 and written to the APU, and what stood in those registers is the very song the sequencer sounds. """ trace = captured_run( diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py index 085b778c6..a7863419d 100644 --- a/tests/integration/nsf/test_compression_report.py +++ b/tests/integration/nsf/test_compression_report.py @@ -276,12 +276,21 @@ def test_a_three_minute_arrangement_fits_the_program_area( assert searched assert SONG_HEADER_SIZE + searched[0].size <= available_bytes(driver_image) - def test_every_variant_of_every_song_undercuts_a_record_per_tick( + def test_every_layer_undercuts_a_record_per_tick( self, encodings: Tuple[Encoding, ...], ) -> None: - """The pitch table is paid once, so what a song's own data is held against is the records.""" + """The pitch table is paid once, so what a song's own data is held against is the records. + + A tone channel writes three planes and three registers, and the noise channel two of + each, so spelling every plane out reaches a record per tick and the opcodes counting the + runs. The layers are what buy a song its room, and each of them undercuts the record by + several times over. + """ for encoding in encodings: + if encoding.variant == LITERALS: + continue + assert encoding.compressed.size < encoding.entry.records diff --git a/tests/integration/nsf/test_driver_bend.py b/tests/integration/nsf/test_driver_bend.py new file mode 100644 index 000000000..1db9a4803 --- /dev/null +++ b/tests/integration/nsf/test_driver_bend.py @@ -0,0 +1,133 @@ +from dataclasses import dataclass +from typing import Final, List, Self, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.song import Song +from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_player.specification.registers import PULSE1_TIMER_HIGH, TIMER_HIGH_SHIFT +from sampletones_player.trace.trace import RegisterTrace +from tests.integration.nsf.console.instructions import channel_values, timer_value +from tests.integration.nsf.console.machine import register_file +from tests.integration.nsf.console.session import captured_trace, play_calls_covering +from tests.integration.nsf.exports import exported_information +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.player import PLAYER_PITCHES, bent_song + +NTSC_RATE: Final[int] = 60 +SONG_NAME: Final[str] = "bend" + +MIDRANGE_INDEX: Final[int] = 33 +BOUNDARY_INDEX: Final[int] = 17 +WIDEST_RESIDUAL: Final[int] = 57 +TIMER_LOW_INDEX: Final[int] = 1 +TIMER_HIGH_INDEX: Final[int] = 2 + + +def sounded_dividers(song: Song) -> List[int]: + """The divider the console leaves on the first pulse channel after each tick it sounds. + + Args: + song: The song to export and run. + + Returns: + List[int]: One divider per tick the song covers, in order. + """ + trace = captured_trace(song, exported_information(SONG_NAME)) + dividers = [] + for registers in register_file(trace): + values = channel_values(registers, ChannelName.PULSE1) + dividers.append(timer_value(values[TIMER_LOW_INDEX], values[TIMER_HIGH_INDEX])) + + return dividers[: song.planes.ticks] + + +class TestTheDriverSoundsTheDividerABendPlaneNames(BaseTestSuite): + """The assembled 6502 driver run on py65, its timer registers read back tick by tick. + + A plane byte reaches the timer through a sign extension and a sixteen-bit add, which is the + one piece of arithmetic the driver performs on a song's behalf. These runs state a bend + outright — the encoders leave the dimension to the note for now — and hold the console to the + divider each tick is meant to sound at. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + """One run of a bent note, and the divider each of its ticks reaches. + + Attributes: + pitch_index: The pitch the value plane names throughout. + bends: The divider steps each tick stands away from that pitch. + expected: The divider each tick sounds at. + """ + + pitch_index: int + bends: Tuple[int, ...] + expected: Tuple[int, ...] + + @classmethod + def bending(cls, pitch_index: int, bends: Tuple[int, ...]) -> Self: + """A case whose expectation is the note's own divider moved by each bend.""" + return cls( + pitch_index=pitch_index, + bends=bends, + expected=tuple(PLAYER_PITCHES.timers[pitch_index] + bend for bend in bends), + ) + + @property + def label(self) -> str: + return f"pitch {self.pitch_index} bent {' '.join(f'{bend:+d}' for bend in self.bends)}" + + test_cases = ( + TestCase.bending(MIDRANGE_INDEX, (0, 0, 0, 0)), + TestCase.bending(MIDRANGE_INDEX, (0, 7, 3, 0)), + TestCase.bending(MIDRANGE_INDEX, (0, -7, -3, 0)), + TestCase.bending(MIDRANGE_INDEX, (WIDEST_RESIDUAL, -WIDEST_RESIDUAL, 0, 1)), + TestCase.bending(BOUNDARY_INDEX, (0, -1, 3, -2)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_console_sounds_the_divider_the_bend_states(self, test_case: TestCase) -> None: + song = bent_song(test_case.pitch_index, test_case.bends, NTSC_RATE) + assert sounded_dividers(song) == list(test_case.expected) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_model_states_the_writes_the_driver_makes(self, test_case: TestCase) -> None: + song = bent_song(test_case.pitch_index, test_case.bends, NTSC_RATE) + trace = captured_trace(song, exported_information(SONG_NAME)) + assert trace == RegisterTrace.from_song(song, play_calls_covering(song)) + + +class TestABendCrossingTheTimersHighHalf: + """A divider whose high half moves is where the driver's carry and its shadow both show. + + The high half reaches the register only where it differs from the last one written, so a bend + that carries into it is the one that proves the carry survives the two halves of the add and + that the shadow follows what was actually written. + """ + + BENDS: Final[Tuple[int, ...]] = (0, -1, 3, -2) + + @pytest.fixture + def crossing(self) -> Song: + """A note standing on a high-byte boundary, bent to either side of it.""" + return bent_song(BOUNDARY_INDEX, self.BENDS, NTSC_RATE) + + def test_the_note_stands_on_a_boundary(self) -> None: + assert PLAYER_PITCHES.timers[BOUNDARY_INDEX] % BYTE_VALUES == 0 + + def test_the_carry_reaches_the_timers_high_half(self, crossing: Song) -> None: + divider = PLAYER_PITCHES.timers[BOUNDARY_INDEX] + assert sounded_dividers(crossing) == [divider + bend for bend in self.BENDS] + + def test_the_high_half_is_written_wherever_the_bend_moves_it(self, crossing: Song) -> None: + divider = PLAYER_PITCHES.timers[BOUNDARY_INDEX] + halves = [(divider + bend) >> TIMER_HIGH_SHIFT for bend in self.BENDS] + crossings = sum(1 for earlier, later in zip(halves, halves[1:]) if earlier != later) + + trace = captured_trace(crossing, exported_information(SONG_NAME)) + written = [write for writes in trace.play_calls for write in writes if write.address == PULSE1_TIMER_HIGH] + assert crossings + assert len(written) == crossings diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py index 2f9fb7cad..8640103d5 100644 --- a/tests/integration/nsf/test_driver_trace.py +++ b/tests/integration/nsf/test_driver_trace.py @@ -125,7 +125,7 @@ def test_the_rate_reaches_the_console_as_the_step_alone( class TestARepeatingSongComesRoundWhereTheModelSaysItDoes(BaseTestSuite): """A song that repeats re-enters its streams partway through. - What the driver restores at the loop is the byte each of the eight planes resumes at, which + What the driver restores at the loop is the byte each plane resumes at, which the header states, so a plane comes back holding nothing of the run that led up to it. The tick the loop returns to therefore starts a token of its own on every plane. """ diff --git a/tests/suite/player.py b/tests/suite/player.py index 3605d2ab9..b0fb0df5a 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -16,15 +16,20 @@ from sampletones_player.clock.schedule import PlaySchedule from sampletones_player.compression.compressed import CompressedPlanes from sampletones_player.compression.dictionary.table import PhraseTable -from sampletones_player.compression.encode import emit +from sampletones_player.compression.encode import emit, encode_planes +from sampletones_player.compression.options import EVERY_LAYER from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable +from sampletones_player.compression.planes.channel import TonePlanes from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.tokens.literal import LiteralToken from sampletones_player.registers.noise import NoiseRegisters from sampletones_player.registers.pulse import PulseRegisters from sampletones_player.registers.streams import ChannelStreams from sampletones_player.registers.triangle import TriangleRegisters from sampletones_player.song import Song +from sampletones_player.specification.binary import unsigned_byte from sampletones_player.specification.compression import ( MAX_LITERAL_BYTES, PLANE_COUNT, @@ -130,6 +135,45 @@ def player_song( ) +def bent_song( + pitch_index: int, + bends: Sequence[int], + nes_frequency: int, +) -> Song: + """A song holding one note on a pulse channel while its bend plane moves the divider. + + A bend reaches the console on a plane of its own, and only the plane can put one there while + the encoders still leave the dimension to the note. Stating one outright is therefore what + holds the driver's own arithmetic to the divider each tick is meant to sound at. + + Args: + pitch_index: The pitch the value plane names, counted from the lowest the table holds. + bends: The divider steps each tick stands away from that pitch. + nes_frequency: The rate the streams were written at. + + Returns: + Song: The song, its other channels resting throughout. + """ + sounding = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_PITCHES.timers[pitch_index]) + planes = planes_from_streams(resting_streams((sounding,) * len(bends)), PLAYER_PITCHES) + bent = SongPlanes( + pulse1=TonePlanes( + control=planes.pulse1.control, + value=planes.pulse1.value, + bend=bytes(unsigned_byte(bend) for bend in bends), + ), + pulse2=planes.pulse2, + triangle=planes.triangle, + noise=planes.noise, + ) + return Song( + planes=encode_planes(bent, (), options=EVERY_LAYER, boundaries=frozenset()), + pitches=PLAYER_PITCHES, + schedule=PlaySchedule.from_parameters(nes_frequency), + loop_tick=None, + ) + + PLAYER_PULSE_TIMER_MUTE_FLOOR: Final[int] = 8 diff --git a/tests/unit/sampletones_player/compression/matches/test_shift.py b/tests/unit/sampletones_player/compression/matches/test_shift.py index 624b17350..9b4d3afea 100644 --- a/tests/unit/sampletones_player/compression/matches/test_shift.py +++ b/tests/unit/sampletones_player/compression/matches/test_shift.py @@ -1,5 +1,5 @@ from sampletones_player.compression.matches.shift import translation -from sampletones_player.specification.compression import BYTE_VALUES +from sampletones_player.specification.binary import BYTE_VALUES MOTIF: bytes = bytes((40, 44, 47)) diff --git a/tests/unit/sampletones_player/compression/planes/test_channel.py b/tests/unit/sampletones_player/compression/planes/test_channel.py index 91a50dec3..7db19099d 100644 --- a/tests/unit/sampletones_player/compression/planes/test_channel.py +++ b/tests/unit/sampletones_player/compression/planes/test_channel.py @@ -5,7 +5,7 @@ class TestAChannelWritesTwoPlanesOfEqualLength: - """The two planes are read tick for tick, so a channel states both across the same ticks.""" + """The planes are read tick for tick, so a channel states them all across the same ticks.""" def test_both_planes_reach_the_ticks_the_channel_covers(self) -> None: planes = ChannelPlanes(control=bytes(4), value=bytes(4)) diff --git a/tests/unit/sampletones_player/compression/planes/test_order.py b/tests/unit/sampletones_player/compression/planes/test_order.py index 0fd6f0d46..571c9331c 100644 --- a/tests/unit/sampletones_player/compression/planes/test_order.py +++ b/tests/unit/sampletones_player/compression/planes/test_order.py @@ -9,7 +9,7 @@ def numbered(count: int) -> PlaneOrder: class TestThePlanesAreNamedRatherThanNumbered: - """The song block writes eight planes in one order, and each is reached by its own name.""" + """The song block writes its planes in one order, and each is reached by its own name.""" def test_the_planes_take_the_names_the_song_block_writes_them_by(self) -> None: planes = numbered(PLANE_COUNT) diff --git a/tests/unit/sampletones_player/compression/planes/test_song.py b/tests/unit/sampletones_player/compression/planes/test_song.py index 235eeabc5..bbf8b3cb7 100644 --- a/tests/unit/sampletones_player/compression/planes/test_song.py +++ b/tests/unit/sampletones_player/compression/planes/test_song.py @@ -1,32 +1,50 @@ +from typing import Final + import pytest from pydantic import ValidationError -from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.specification.compression import PLANE_COUNT +BEND: Final[bytes] = bytes((0x00, 0xFD)) + def song(control: bytes, value: bytes) -> SongPlanes: - channel = ChannelPlanes(control=control, value=value) - resting = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value))) - return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=resting) + channel = TonePlanes(control=control, value=value, bend=BEND) + resting = TonePlanes( + control=bytes(len(control)), + value=bytes(len(value)), + bend=bytes(len(control)), + ) + silent = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value))) + return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=silent) class TestASongGathersItsChannelsPlanes: - """The eight planes advance together, so the song states them under one length.""" + """Every plane advances beside the rest, so the song states them all under one length.""" - def test_a_song_carries_two_planes_for_every_channel(self) -> None: + def test_a_song_carries_the_planes_a_block_writes(self) -> None: assert len(song(bytes((1, 2)), bytes((3, 4))).planes) == PLANE_COUNT def test_the_planes_read_back_under_the_channels_that_write_them(self) -> None: planes = song(bytes((1, 2)), bytes((3, 4))) assert SongPlanes.from_order(planes.planes) == planes + def test_a_tone_channels_bend_reaches_the_block_beside_its_pitch(self) -> None: + planes = song(bytes((1, 2)), bytes((3, 4))).planes + assert planes.pulse1_bend == BEND + def test_a_song_lasts_the_ticks_its_channels_cover(self) -> None: assert song(bytes((1, 2)), bytes((3, 4))).ticks == 2 def test_channels_covering_different_ticks_are_refused(self) -> None: - short = ChannelPlanes(control=bytes(1), value=bytes(1)) - long = ChannelPlanes(control=bytes(2), value=bytes(2)) + short = TonePlanes(control=bytes(1), value=bytes(1), bend=bytes(1)) + long = TonePlanes(control=bytes(2), value=bytes(2), bend=bytes(2)) with pytest.raises(ValidationError): - SongPlanes(pulse1=short, pulse2=long, triangle=short, noise=short) + SongPlanes( + pulse1=short, + pulse2=long, + triangle=short, + noise=ChannelPlanes(control=bytes(1), value=bytes(1)), + ) diff --git a/tests/unit/sampletones_player/compression/test_admit.py b/tests/unit/sampletones_player/compression/test_admit.py index d3bd68e8b..55f9d374c 100644 --- a/tests/unit/sampletones_player/compression/test_admit.py +++ b/tests/unit/sampletones_player/compression/test_admit.py @@ -13,7 +13,8 @@ from sampletones_player.compression.parse.result import Parse from sampletones_player.compression.parse.song import parse_planes from sampletones_player.compression.progress.monitor import CodecMonitor -from sampletones_player.specification.compression import BYTE_VALUES, MAX_PHRASE_IDS +from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_player.specification.compression import MAX_PHRASE_IDS from sampletones_shared.utils.progress import silent_reporter STREAM_START: Final[frozenset] = frozenset({0}) diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py index 2595c8a00..9c01f6416 100644 --- a/tests/unit/sampletones_player/compression/test_encode.py +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -6,7 +6,7 @@ from sampletones_player.compression.dictionary.phrase import Phrase from sampletones_player.compression.encode import emit, encode_planes from sampletones_player.compression.options import CodecOptions -from sampletones_player.compression.planes.channel import ChannelPlanes +from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.compression.progress.report import CodecProgress from sampletones_player.compression.tokens.hold import HoldToken @@ -39,9 +39,11 @@ def song_planes(control: bytes, value: bytes) -> SongPlanes: - channel = ChannelPlanes(control=control, value=value) - resting = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value))) - return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=resting) + unbent = bytes(len(control)) + channel = TonePlanes(control=control, value=value, bend=unbent) + resting = TonePlanes(control=unbent, value=bytes(len(value)), bend=unbent) + silent = ChannelPlanes(control=unbent, value=bytes(len(value))) + return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=silent) class TestWhatATokenLooksLikeOnTheBus: diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py index 5eef3099c..75ed651a2 100644 --- a/tests/unit/sampletones_player/compression/test_seeds.py +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -1,8 +1,12 @@ -from typing import Final +from typing import Final, Tuple + +import pytest from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.project import Project from sampletones_core.timers.utils import get_timer_table from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.channel import ChannelPlanes from sampletones_player.compression.planes.separate import channel_planes from sampletones_player.compression.seeds import phrases_from_project from sampletones_player.registers.channel import channel_registers @@ -12,32 +16,53 @@ TUNING: Final[Tuning] = Tuning() ROWS_PER_PATTERN: Final[int] = 8 SOUNDING_TICKS: Final[int] = 5 -PLANES_PER_SLICE: Final[int] = 2 + + +@pytest.fixture +def project() -> Project: + """A project playing one pulse slice at a row.""" + reconstruction = make_pulse_reconstruction(count=SOUNDING_TICKS) + built, _ = project_with_sample(reconstruction, rows_per_pattern=ROWS_PER_PATTERN) + return built + + +@pytest.fixture +def slice_planes(project: Project) -> ChannelPlanes: + """The planes the project's own slice writes on the channel it plays.""" + instructions = project.voices[0].reconstruction.get_channel_instructions(ChannelName.PULSE1) + registers = channel_registers( + ChannelName.PULSE1, + {ChannelName.PULSE1: instructions}, + get_timer_table(TUNING), + ) + return channel_planes(ChannelName.PULSE1, registers, PitchTable.from_tuning(TUNING)) + + +def _offered(project: Project) -> Tuple[bytes, ...]: + return tuple(phrase.body for phrase in phrases_from_project(project, TUNING)) class TestTheInstrumentsSeedTheDictionary: """A song plays sample slices at rows, so the shapes its planes repeat are the slices.""" - def test_a_sample_offers_both_planes_of_the_channel_it_plays(self) -> None: - reconstruction = make_pulse_reconstruction(count=SOUNDING_TICKS) - project, _ = project_with_sample(reconstruction, rows_per_pattern=ROWS_PER_PATTERN) - assert len(phrases_from_project(project, TUNING)) == PLANES_PER_SLICE - - def test_the_phrases_are_the_planes_the_slice_writes(self) -> None: - reconstruction = make_pulse_reconstruction(count=SOUNDING_TICKS) - project, sample = project_with_sample(reconstruction, rows_per_pattern=ROWS_PER_PATTERN) - registers = channel_registers( - ChannelName.PULSE1, - {ChannelName.PULSE1: sample.reconstruction.get_channel_instructions(ChannelName.PULSE1)}, - get_timer_table(TUNING), - ) - planes = channel_planes(ChannelName.PULSE1, registers, PitchTable.from_tuning(TUNING)) - assert tuple(phrase.body for phrase in phrases_from_project(project, TUNING)) == planes.ordered - - def test_a_project_holding_no_sample_offers_nothing(self) -> None: - project, _ = project_with_sample( - make_pulse_reconstruction(count=SOUNDING_TICKS), - rows_per_pattern=ROWS_PER_PATTERN, - ) + def test_the_phrases_are_the_planes_the_slice_turns_over( + self, + project: Project, + slice_planes: ChannelPlanes, + ) -> None: + turning = tuple(plane for plane in slice_planes.ordered if len(set(plane)) > 1) + assert _offered(project) == turning + + def test_a_plane_holding_one_value_offers_the_dictionary_nothing( + self, + project: Project, + slice_planes: ChannelPlanes, + ) -> None: + """A hold covers such a plane more cheaply than any phrase naming it could.""" + held = tuple(plane for plane in slice_planes.ordered if len(set(plane)) == 1) + assert held + assert not set(held) & set(_offered(project)) + + def test_a_project_holding_no_sample_offers_nothing(self, project: Project) -> None: project.voices.clear() assert phrases_from_project(project, TUNING) == () diff --git a/tests/unit/sampletones_player/compression/tokens/test_phrase.py b/tests/unit/sampletones_player/compression/tokens/test_phrase.py index f0a8e87bd..b60c280d8 100644 --- a/tests/unit/sampletones_player/compression/tokens/test_phrase.py +++ b/tests/unit/sampletones_player/compression/tokens/test_phrase.py @@ -4,9 +4,9 @@ from sampletones_player.compression.tokens.phrase import PhraseToken from sampletones_player.compression.tokens.sizes import phrase_size +from sampletones_player.specification.binary import MAX_BYTE_VALUE from sampletones_player.specification.compression import ( CHEAP_PHRASE_IDS, - MAX_BYTE_VALUE, OPCODE_SIZE, PHRASE_COUNT_SIZE, PHRASE_ESCAPE_SIZE, diff --git a/tests/unit/sampletones_player/driver/test_song_include.py b/tests/unit/sampletones_player/driver/test_song_include.py index f5d5c9a04..042e4b300 100644 --- a/tests/unit/sampletones_player/driver/test_song_include.py +++ b/tests/unit/sampletones_player/driver/test_song_include.py @@ -5,6 +5,7 @@ import pytest from sampletones_player.compression.pitch import PITCH_COUNT +from sampletones_player.compression.planes.order import PlaneOrder from sampletones_player.driver.assembler.layout import INCLUDE_DIRECTORY from sampletones_player.specification.binary import WORD_SIZE from sampletones_player.specification.compression import ( @@ -133,15 +134,7 @@ def test_the_plane_state_fields_fill_the_block_each_plane_holds( assert max(offsets) < equates["PLANE_STATE_SIZE"] def test_every_plane_is_named_at_its_own_state_block(self, equates: Dict[str, int]) -> None: - planes = ( - "PULSE1_CONTROL_PLANE", - "PULSE1_VALUE_PLANE", - "PULSE2_CONTROL_PLANE", - "PULSE2_VALUE_PLANE", - "TRIANGLE_CONTROL_PLANE", - "TRIANGLE_VALUE_PLANE", - "NOISE_CONTROL_PLANE", - "NOISE_VALUE_PLANE", - ) + """The assembly holds a state block per plane, named and ordered as the song block is.""" + planes = [f"{name.upper()}_PLANE" for name in PlaneOrder.names()] expected = [plane * equates["PLANE_STATE_SIZE"] for plane in range(PLANE_COUNT)] assert [equates[plane] for plane in planes] == expected diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py index e3a46a622..9e1b7baf4 100644 --- a/tests/unit/sampletones_player/nsf/test_song.py +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -79,8 +79,8 @@ class TestSongBytes: """The exact bytes a hand-built song serializes to. The layout is the contract the driver reads the song through, so the literal states it in - full: the header, the timer every pitch sounds at, the dictionary the tokens name, and the - eight token streams. The timer table is named rather than transcribed, since it is the + full: the header, the timer every pitch sounds at, the dictionary the tokens name, and one + token stream per plane. The timer table is named rather than transcribed, since it is the tuning's own table and the block carries whatever that table holds. """ @@ -89,20 +89,29 @@ class TestSongBytes: b"\xca\x7f" b"\x02\x00" b"\xff\xff" - b"\x2b\x00" - b"\xfb\x00" - b"\xfc\x00\xff\x00\x02\x01\x05\x01\x08\x01\x0b\x01\x0e\x01\x11\x01" - b"\xfc\x00\xff\x00\x02\x01\x05\x01\x08\x01\x0b\x01\x0e\x01\x11\x01" + b"\x37\x00" + b"\x07\x01" + b"\x08\x01\x0b\x01\x0e\x01" + b"\x11\x01\x14\x01\x17\x01" + b"\x1a\x01\x1d\x01\x20\x01" + b"\x23\x01\x26\x01" + b"\x08\x01\x0b\x01\x0e\x01" + b"\x11\x01\x14\x01\x17\x01" + b"\x1a\x01\x1d\x01\x20\x01" + b"\x23\x01\x26\x01" ) EXPECTED_STREAMS: Final[bytes] = ( b"\x00" b"\x41\x3f\x30" b"\x40\x21\x00" + b"\x40\x00\x00" b"\x40\x30\x00" b"\x40\x21\x00" + b"\x40\x00\x00" b"\x40\x80\x00" b"\x40\x21\x00" + b"\x40\x00\x00" b"\x40\x30\x00" b"\x40\x0a\x00" ) From 75c6fc2d198baed1c66b243236a24382dae29e81 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 13:49:14 +0200 Subject: [PATCH 142/142] Fixed: incorrect benchmarks --- docs/concepts/reconstruction.md | 9 ++- docs/development/bugs-and-todos.md | 6 ++ tests/benchmarks/test_pitch_bend.py | 59 --------------- .../reconstruction/test_pitch_refinement.py | 71 +++++++++++++++++-- 4 files changed, 79 insertions(+), 66 deletions(-) diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index d954e7e48..52945c0dc 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -321,8 +321,13 @@ a note owns tens of dividers. The refinement enumerates no candidate, rescores nothing, and leaves the library, the per-frame matching and the decoder's lattice exactly as they were. What it adds is one transform per -recording and a small walk per channel: a conversion measures **around 2 % longer** with it than -without. +recording and a small walk per channel. + +What that transform costs depends on the machine, and the spread is wide: on a CUDA build it +disappears into the noise, while on a CPU build it is a measurable share of a short conversion — +a tenth or more, since the reading needs a handful of bins per frame and the transform computes +every bin the spectrum covers. Restricting it to the bins the chosen notes actually name is the +work `docs/development/bugs-and-todos.md` records under **Features**. A frame makes no proposal where it rests, where its channel is not pitched — the noise channel's sixteen periods have no finer grid — or where its reading falls below the confidence threshold. A diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 6a986d304..04a3613e5 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -54,6 +54,12 @@ starts carrying. measures around **2.1 s per second of audio**, against a whole conversion's ~1.2 s, so it would nearly triple a run to change no decision. It is worth revisiting only against material where the reading is shown to misfire. +* Reading only the bins the refinement asks for. `InstantaneousPitch` transforms every bin the + spectrum covers and then reads five of them per frame, so it computes around twenty times the + work its reading uses. On a CUDA build that vanishes; on a CPU build one transform measures a + tenth or more of a short conversion, and a CI runner has measured it at a third. The kernel is a + matrix of one row per bin, so restricting it to the rows the chosen notes name is a slice — what + needs care is that the union of harmonic bins over a whole stream is wider than any one frame's. * Calibrating the pitch refinement. `generation.refinement`'s confidence threshold, change weight and window are chosen by hand; `docs/concepts/calibration.md`'s experiment measures the criterion blend and could measure these beside it. The change weight is the one with an audible trade-off: diff --git a/tests/benchmarks/test_pitch_bend.py b/tests/benchmarks/test_pitch_bend.py index 8c1385b7d..e53e73a15 100644 --- a/tests/benchmarks/test_pitch_bend.py +++ b/tests/benchmarks/test_pitch_bend.py @@ -1,30 +1,18 @@ -from pathlib import Path from time import process_time from typing import Final, List -import numpy as np import pytest -from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.configs.generation import GenerationConfig, RefinementConfig from sampletones_core.constants.enums import ChannelName from sampletones_core.generators.render import render_instructions from sampletones_core.instructions import PulseInstruction -from sampletones_core.reconstructions import Reconstructor -from tests.integration.assets.reconstruction import build_mini_library FRAMES: Final[int] = 6000 PITCH: Final[int] = 60 VOLUME: Final[int] = 12 REPEATS: Final[int] = 3 BEND_OVERHEAD_LIMIT: Final[float] = 1.25 -REFINEMENT_OVERHEAD_LIMIT: Final[float] = 1.20 -CONVERSION_SECONDS: Final[float] = 2.0 -LOWER_TONE: Final[float] = 261.0 -UPPER_TONE: Final[float] = 393.0 -NOISE_LEVEL: Final[float] = 0.05 -NOISE_SEED: Final[int] = 23 def _stream(bent: bool) -> List[PulseInstruction]: @@ -70,50 +58,3 @@ def test_a_bent_stream_renders_in_what_an_unbent_one_takes(self, config: Config) bent = _render_seconds(config, _stream(bent=True)) assert bent < unbent * BEND_OVERHEAD_LIMIT, f"unbent {unbent:.4f}s, bent {bent:.4f}s" - - -def _conversion_config(*, refining: bool) -> Config: - return Config(generation=GenerationConfig(refinement=RefinementConfig(enabled=refining))) - - -def _target(path: Path, config: Config) -> Path: - """A two-tone target under light noise, which is the shape a conversion works hardest on.""" - sample_rate = config.library.sample_rate - count = int(sample_rate * CONVERSION_SECONDS) - time = np.arange(count) / sample_rate - audio = 0.5 * np.sin(2 * np.pi * LOWER_TONE * time) + 0.3 * np.sin(2 * np.pi * UPPER_TONE * time) - audio += np.random.default_rng(NOISE_SEED).normal(0.0, NOISE_LEVEL, count) - - write_wave(path, sample_rate, audio) - return path - - -def _conversion_seconds(config: Config, audio_path: Path) -> float: - """The best of several conversions of the same target, library build excluded.""" - library = build_mini_library(config) - readings: List[float] = [] - for _ in range(REPEATS): - started = process_time() - Reconstructor(config, library=library)(audio_path) - readings.append(process_time() - started) - - return min(readings) - - -class TestRefiningCostsLittleOnTopOfAConversion: - """The refinement reads a phase the transform already carries, over a handful of harmonics. - - It enumerates no candidate and rescores nothing, so what it adds to a conversion is one more - transform per recording and a small walk per channel. The reading is a ratio against the same - conversion with the refinement off, since what a machine converts a second of audio in is its - own; what the bound catches is a refinement that started doing the matching's kind of work. - """ - - def test_a_refined_conversion_costs_about_what_a_plain_one_costs(self, tmp_path: Path) -> None: - plain = _conversion_config(refining=False) - audio_path = _target(tmp_path / "target.wav", plain) - - unrefined = _conversion_seconds(plain, audio_path) - refined = _conversion_seconds(_conversion_config(refining=True), audio_path) - - assert refined < unrefined * REFINEMENT_OVERHEAD_LIMIT, f"unrefined {unrefined:.3f}s, refined {refined:.3f}s" diff --git a/tests/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py index 06198e7ae..22c1fb87b 100644 --- a/tests/integration/reconstruction/test_pitch_refinement.py +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -8,10 +8,11 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.configs.generation import GenerationConfig, RefinementConfig -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.fft import Window from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.fft.instantaneous import InstantaneousPitch from sampletones_core.generators import PulseGenerator, get_generators_by_channels from sampletones_core.instructions import InstructionUnion, PulseInstruction from sampletones_core.library import ( @@ -20,6 +21,7 @@ InstructionLibraryFragment, ) from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstructor.refinement import refiner PITCH: Final[int] = 60 NEIGHBORHOOD: Final[range] = range(PITCH - 2, PITCH + 3) @@ -62,13 +64,13 @@ def _library(config: Config) -> InstructionLibrary: return library -def _square_path(path: Path, config: Config, cents: float) -> Path: +def _square_path(path: Path, config: Config, cents: float, seconds: float = SECONDS) -> Path: """A steady square tone standing ``cents`` off the note the catalog holds.""" sample_rate = config.library.sample_rate generator = PulseGenerator(config, ChannelName.PULSE1) frequency = generator.sounds_at(PITCH, 0) * 2 ** (cents / CENTS_PER_OCTAVE) - count = int(sample_rate * SECONDS) + count = int(sample_rate * seconds) phase = (np.arange(count) * frequency / sample_rate) % 1.0 audio = np.where(phase < 0.5, 0.4, -0.4) @@ -182,8 +184,6 @@ def test_a_conversion_with_the_refinement_off_records_the_bend_as_the_channels( tmp_path: Path, ) -> None: """Nothing bent means nothing chosen, so both dimensions stay the channel's own.""" - from sampletones_core.constants.enums import FeatureKey - reconstruction = _reconstruct(plain, _square_path(tmp_path / "held.wav", plain, DETUNE_CENTS)) held = reconstruction.held_features[ChannelName.PULSE1] @@ -199,3 +199,64 @@ def test_a_source_with_no_pitch_to_read_is_left_unbent( reconstruction = _reconstruct(refining, _noise_path(tmp_path / "noise.wav", refining)) assert all(not frame.bent for frame in _sounding(reconstruction)) + + +class TestWhatTheRefinementReads: + """The refinement reads a recording rather than searching it. + + What it adds to a conversion is one transform per stem and a walk over the frames: no + candidate is enumerated and nothing is rescored. Counting the readings holds that where a + clock cannot — what one transform costs is the machine's own, and a CUDA build and a CPU + build disagree about it by two orders of magnitude, so a reading per stem is the claim worth + pinning. + """ + + @staticmethod + def _counted(monkeypatch: pytest.MonkeyPatch) -> List[int]: + """The frames of each recording the refinement reads, in the order it reads them.""" + readings: List[int] = [] + + def counting(recording: np.ndarray, sample_rate: int, hop_length: int) -> InstantaneousPitch: + readings.append(len(recording)) + return InstantaneousPitch(recording, sample_rate, hop_length) + + monkeypatch.setattr(refiner, "InstantaneousPitch", counting) + return readings + + def test_a_refined_conversion_reads_each_recording_once( + self, + refining: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readings = self._counted(monkeypatch) + _reconstruct(refining, _square_path(tmp_path / "read.wav", refining, DETUNE_CENTS)) + + assert len(readings) == 1 + + def test_a_longer_source_is_read_no_more_often( + self, + refining: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A reading per stem is what keeps the cost off the frame count and off the catalog.""" + readings = self._counted(monkeypatch) + _reconstruct(refining, _square_path(tmp_path / "short.wav", refining, DETUNE_CENTS)) + short = len(readings) + + readings.clear() + _reconstruct(refining, _square_path(tmp_path / "long.wav", refining, DETUNE_CENTS, seconds=SECONDS * 3)) + + assert len(readings) == short + + def test_a_conversion_with_the_refinement_off_reads_nothing( + self, + plain: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readings = self._counted(monkeypatch) + _reconstruct(plain, _square_path(tmp_path / "unread.wav", plain, DETUNE_CENTS)) + + assert not readings